mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 13:54:05 +03:00
Compare commits
20 Commits
feat/custo
...
fix/auto-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5302d6ea0e | ||
|
|
564cda5088 | ||
|
|
a7f1d495c6 | ||
|
|
b9f6c4aaf2 | ||
|
|
23778551f7 | ||
|
|
457da17c5d | ||
|
|
12f741d911 | ||
|
|
a0c7b81142 | ||
|
|
20997823d8 | ||
|
|
8a875f5978 | ||
|
|
8435d219e4 | ||
|
|
8bbc252703 | ||
|
|
2d3cf8ffaa | ||
|
|
33cba2f9af | ||
|
|
75053ebfba | ||
|
|
ba9ec7c80e | ||
|
|
99370b2ac4 | ||
|
|
0f5bf6071f | ||
|
|
998fd5f6a8 | ||
|
|
f413c446e3 |
4
.github/workflows/build-mobile.yml
vendored
4
.github/workflows/build-mobile.yml
vendored
@@ -106,7 +106,7 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||||
|
|
||||||
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
@@ -241,7 +241,7 @@ jobs:
|
|||||||
run: flutter build ios --config-only --no-codesign
|
run: flutter build ios --config-only --no-codesign
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
|
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
2
.github/workflows/check-openapi.yml
vendored
2
.github/workflows/check-openapi.yml
vendored
@@ -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@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
|
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6
|
||||||
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
|
||||||
|
|||||||
2
.github/workflows/close-duplicates.yml
vendored
2
.github/workflows/close-duplicates.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
|||||||
needs: [get_body, should_run]
|
needs: [get_body, should_run]
|
||||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
|
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.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,7 +71,7 @@ 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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.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
|
||||||
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
2
.github/workflows/prepare-release.yml
vendored
2
.github/workflows/prepare-release.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
|||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "1.0.3"
|
terragrunt = "1.1.1"
|
||||||
opentofu = "1.11.6"
|
opentofu = "1.12.4"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -2,37 +2,35 @@
|
|||||||
# 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.7"
|
version = "4.52.8"
|
||||||
constraints = "4.52.7"
|
constraints = "4.52.8"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
|
||||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
|
||||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
|
||||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
|
||||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
|
||||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
|
||||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
|
||||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
|
||||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
|
||||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
|
||||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
|
||||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
|
||||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
|
||||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
|
||||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
|
||||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
|
||||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
|
||||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
|
||||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
|
||||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
|
||||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
|
||||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
|
||||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
|
||||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
|
||||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
|
||||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
|
||||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
|
||||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.52.7"
|
version = "4.52.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,37 +2,35 @@
|
|||||||
# 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.7"
|
version = "4.52.8"
|
||||||
constraints = "4.52.7"
|
constraints = "4.52.8"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=",
|
"h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
|
||||||
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=",
|
"h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
|
||||||
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=",
|
"h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
|
||||||
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=",
|
"h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
|
||||||
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=",
|
"h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
|
||||||
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=",
|
"h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
|
||||||
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=",
|
"h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
|
||||||
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=",
|
"h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
|
||||||
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=",
|
"h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
|
||||||
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=",
|
"h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
|
||||||
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=",
|
"h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
|
||||||
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=",
|
"h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
|
||||||
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=",
|
"h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
|
||||||
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=",
|
"zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
|
||||||
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b",
|
"zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
|
||||||
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e",
|
"zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
|
||||||
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10",
|
"zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
|
||||||
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
|
|
||||||
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
|
|
||||||
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||||
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13",
|
"zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
|
||||||
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d",
|
"zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
|
||||||
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f",
|
"zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
|
||||||
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d",
|
"zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
|
||||||
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe",
|
"zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
|
||||||
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455",
|
"zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
|
||||||
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2",
|
"zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
|
||||||
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b",
|
"zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
|
||||||
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe",
|
"zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
cloudflare = {
|
cloudflare = {
|
||||||
source = "cloudflare/cloudflare"
|
source = "cloudflare/cloudflare"
|
||||||
version = "4.52.7"
|
version = "4.52.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ services:
|
|||||||
command: ['./run.sh', '-disable-reporting']
|
command: ['./run.sh', '-disable-reporting']
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
|
image: grafana/grafana:12.4.5-ubuntu@sha256:00396460e499415c828b7c298f19287c8a0f95e72412ee37ac11723655c2d6b9
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 KiB |
@@ -35,6 +35,7 @@ Found Admin:
|
|||||||
- Email=admin@example.com
|
- Email=admin@example.com
|
||||||
- Name=Immich Admin
|
- Name=Immich Admin
|
||||||
? Please choose a new password (optional) immich-is-cool
|
? Please choose a new password (optional) immich-is-cool
|
||||||
|
? Invalidate existing sessions? Yes
|
||||||
The admin password has been updated.
|
The admin password has been updated.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -650,7 +650,7 @@ describe(`immich upload`, () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stdout).toBe('');
|
expect(stdout).toBe('');
|
||||||
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`);
|
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`);
|
||||||
expect(exitCode).not.toBe(0);
|
expect(exitCode).not.toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ describe(`immich-admin`, () => {
|
|||||||
child.stdout.on('data', (chunk) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
data += chunk;
|
data += chunk;
|
||||||
if (data.includes('Please choose a new password (optional)')) {
|
if (data.includes('Please choose a new password (optional)')) {
|
||||||
|
child.stdin.write('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.includes('Invalidate existing sessions?')) {
|
||||||
child.stdin.end('\n');
|
child.stdin.end('\n');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
132
mise.lock
132
mise.lock
@@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
|
|||||||
version = "7.1.3-6"
|
version = "7.1.3-6"
|
||||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||||
|
|
||||||
|
[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"."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]
|
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||||
|
|
||||||
@@ -112,17 +129,12 @@ version = "7.1.3-6"
|
|||||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||||
|
|
||||||
[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_mac64-gpl.tar.xz"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
||||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
||||||
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_mac64-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/408995889"
|
||||||
|
|
||||||
[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"]]
|
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||||
version = "7.1.3-6"
|
version = "7.1.3-6"
|
||||||
@@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
|
|||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
|
||||||
|
|
||||||
[[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_mac64-gpl.tar.xz"
|
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
|
||||||
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
|
||||||
|
|
||||||
[[tools."github:webassembly/binaryen"]]
|
[[tools."github:webassembly/binaryen"]]
|
||||||
version = "version_124"
|
version = "version_124"
|
||||||
backend = "github:webassembly/binaryen"
|
backend = "github:webassembly/binaryen"
|
||||||
@@ -248,43 +248,43 @@ version = "7.5.0"
|
|||||||
backend = "npm:oazapfts"
|
backend = "npm:oazapfts"
|
||||||
|
|
||||||
[[tools.opentofu]]
|
[[tools.opentofu]]
|
||||||
version = "1.11.6"
|
version = "1.12.4"
|
||||||
backend = "aqua:opentofu/opentofu"
|
backend = "aqua:opentofu/opentofu"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64"]
|
[tools.opentofu."platforms.linux-arm64"]
|
||||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64"]
|
[tools.opentofu."platforms.linux-x64"]
|
||||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64-musl"]
|
[tools.opentofu."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-arm64"]
|
[tools.opentofu."platforms.macos-arm64"]
|
||||||
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
checksum = "sha256:7c06e4390d9ccd467773e37ff1c3d833c7ca0c24742cd9e9ad47284bea472247"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646544"
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-x64"]
|
[tools.opentofu."platforms.macos-x64"]
|
||||||
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
checksum = "sha256:ead1d2ce643addb4ffeb93240b9377ae1c2fd793a6bd22d65922ac37adfdf546"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646688"
|
||||||
|
|
||||||
[tools.opentofu."platforms.windows-x64"]
|
[tools.opentofu."platforms.windows-x64"]
|
||||||
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
checksum = "sha256:a4d86a07755c8d151f20f945e6cfb5b40deeed942af36a9bd385c5c2e965d5dd"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_windows_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
|
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646543"
|
||||||
|
|
||||||
[[tools.pnpm]]
|
[[tools.pnpm]]
|
||||||
version = "11.13.1"
|
version = "11.13.1"
|
||||||
@@ -327,40 +327,40 @@ url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981"
|
|||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[[tools.terragrunt]]
|
[[tools.terragrunt]]
|
||||||
version = "1.0.3"
|
version = "1.1.1"
|
||||||
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:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
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/406654253"
|
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
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/406654253"
|
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:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
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/406654196"
|
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
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/406654196"
|
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:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
|
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628"
|
||||||
|
|
||||||
[tools.terragrunt."platforms.macos-x64"]
|
[tools.terragrunt."platforms.macos-x64"]
|
||||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
|
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520"
|
||||||
|
|
||||||
[tools.terragrunt."platforms.windows-x64"]
|
[tools.terragrunt."platforms.windows-x64"]
|
||||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
|
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
experimental_monorepo_root = true
|
monorepo_root = true
|
||||||
|
|
||||||
[monorepo]
|
[monorepo]
|
||||||
config_roots = [
|
config_roots = [
|
||||||
@@ -17,8 +17,8 @@ config_roots = [
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24.15.0"
|
node = "24.15.0"
|
||||||
pnpm = "11.13.1"
|
pnpm = "11.13.1"
|
||||||
terragrunt = "1.0.3"
|
terragrunt = "1.1.1"
|
||||||
opentofu = "1.11.6"
|
opentofu = "1.12.4"
|
||||||
"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"
|
||||||
@@ -35,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
|
|||||||
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
|
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
|
||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
experimental = true
|
|
||||||
pin = true
|
pin = true
|
||||||
lockfile = true
|
lockfile = true
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,10 @@ class ImmichAPI {
|
|||||||
|
|
||||||
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
||||||
// get URL
|
// get URL
|
||||||
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
|
let localDay = date.formatted(
|
||||||
|
Date.ISO8601FormatStyle(timeZone: .current).year().month().day().dateSeparator(.dash)
|
||||||
|
)
|
||||||
|
let memoryParams = [URLQueryItem(name: "for", value: localDay)]
|
||||||
guard
|
guard
|
||||||
let searchURL = buildRequestURL(
|
let searchURL = buildRequestURL(
|
||||||
serverConfig: serverConfig,
|
serverConfig: serverConfig,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const Map<String, Locale> locales = {
|
|||||||
// Additional locales
|
// Additional locales
|
||||||
'Arabic (ar)': Locale('ar'),
|
'Arabic (ar)': Locale('ar'),
|
||||||
'Basque (eu)': Locale('eu'),
|
'Basque (eu)': Locale('eu'),
|
||||||
|
'Belarusian (be)': Locale('be'),
|
||||||
'Bosnian (bl)': Locale('bn'),
|
'Bosnian (bl)': Locale('bn'),
|
||||||
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
|
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
|
||||||
'Bulgarian (bg)': Locale('bg'),
|
'Bulgarian (bg)': Locale('bg'),
|
||||||
|
|||||||
@@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget {
|
|||||||
_QuickLink(
|
_QuickLink(
|
||||||
title: context.t.recently_added,
|
title: context.t.recently_added,
|
||||||
icon: Icons.upload_outlined,
|
icon: Icons.upload_outlined,
|
||||||
isTop: true,
|
|
||||||
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
|
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
|
||||||
),
|
),
|
||||||
_QuickLink(
|
_QuickLink(
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool isRouteInStack(Ref ref, String routeName) {
|
||||||
|
final router = ref.watch(appRouterProvider);
|
||||||
|
void onChange() => ref.invalidateSelf();
|
||||||
|
router.addListener(onChange);
|
||||||
|
ref.onDispose(() => router.removeListener(onChange));
|
||||||
|
return router.stackData.any((route) => route.name == routeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
final inLockedViewProvider = Provider<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
|
||||||
|
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
|
||||||
|
|
||||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
|
||||||
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
|
|
||||||
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||||
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||||
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);
|
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
|
|
||||||
class AppNavigationObserver extends AutoRouterObserver {
|
class AppNavigationObserver extends AutoRouterObserver {
|
||||||
/// Riverpod Instance
|
/// Riverpod Instance
|
||||||
@@ -12,46 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver {
|
|||||||
|
|
||||||
AppNavigationObserver({required this.ref});
|
AppNavigationObserver({required this.ref});
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
|
|
||||||
unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didPush(Route route, Route? previousRoute) {
|
void didPush(Route route, Route? previousRoute) {
|
||||||
_handleDriftLockedFolderState(route, previousRoute);
|
|
||||||
Future(() {
|
Future(() {
|
||||||
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
||||||
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
||||||
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
|
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
|
||||||
if (route.settings.name == AssetViewerRoute.name) {
|
|
||||||
ref.read(isAssetViewerOpenProvider.notifier).state = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didPop(Route route, Route? previousRoute) {
|
|
||||||
_handleDriftLockedFolderState(previousRoute ?? route, null);
|
|
||||||
if (route.settings.name == AssetViewerRoute.name) {
|
|
||||||
Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
|
|
||||||
final isInLockedView = ref.read(inLockedViewProvider);
|
|
||||||
final isFromLockedViewToDetailView =
|
|
||||||
route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name;
|
|
||||||
|
|
||||||
final isFromDetailViewToInfoPanelView =
|
|
||||||
route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView;
|
|
||||||
|
|
||||||
if (route.settings.name == DriftLockedFolderRoute.name ||
|
|
||||||
isFromLockedViewToDetailView ||
|
|
||||||
isFromDetailViewToInfoPanelView) {
|
|
||||||
Future(() => ref.read(inLockedViewProvider.notifier).state = true);
|
|
||||||
} else {
|
|
||||||
Future(() => ref.read(inLockedViewProvider.notifier).state = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,13 +290,10 @@ class BackgroundUploadService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||||
final hasExtension = p.extension(fileName).isNotEmpty;
|
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
|
||||||
if (!hasExtension) {
|
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
|
||||||
fileName = p.setExtension(fileName, p.extension(asset.name));
|
final originalFileName = p.setExtension(fileName, extension);
|
||||||
}
|
|
||||||
|
|
||||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
|
||||||
|
|
||||||
String metadata = UploadTaskMetadata(
|
String metadata = UploadTaskMetadata(
|
||||||
localAssetId: asset.id,
|
localAssetId: asset.id,
|
||||||
|
|||||||
@@ -309,17 +309,10 @@ class ForegroundUploadService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||||
|
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
|
||||||
/// Handle special file name from DJI or Fusion app
|
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
|
||||||
/// If the file name has no extension, likely due to special renaming template by specific apps
|
final originalFileName = p.setExtension(fileName, extension);
|
||||||
/// we append the original extension from the asset name
|
|
||||||
final hasExtension = p.extension(fileName).isNotEmpty;
|
|
||||||
if (!hasExtension) {
|
|
||||||
fileName = p.setExtension(fileName, p.extension(asset.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
|
||||||
final deviceId = Store.get(StoreKey.deviceId);
|
final deviceId = Store.get(StoreKey.deviceId);
|
||||||
|
|
||||||
final fields = {
|
final fields = {
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ String sanitizeUrl(String url) {
|
|||||||
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates a user-entered server URL
|
||||||
|
bool isValidServerUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.contains('://')) {
|
||||||
|
// Prepend conforming scheme for URL validation
|
||||||
|
// Uri.tryParse will not parse out host without a scheme provided, even though we assume http(s) when connecting
|
||||||
|
url = 'http://$url';
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsedUrl = Uri.tryParse(url);
|
||||||
|
return parsedUrl != null &&
|
||||||
|
(parsedUrl.scheme.isEmpty || parsedUrl.scheme.startsWith(RegExp(r'https?'))) &&
|
||||||
|
parsedUrl.host.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
String? getServerUrl() {
|
String? getServerUrl() {
|
||||||
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
|
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
|
||||||
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
||||||
|
|||||||
@@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
|
|
||||||
final log = Logger('LoginForm');
|
final log = Logger('LoginForm');
|
||||||
|
|
||||||
String? _validateUrl(String? url) {
|
String? _validateUrl(String? url) => isValidServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
|
||||||
if (url == null || url.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final parsedUrl = Uri.tryParse(url);
|
|
||||||
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
|
|
||||||
return 'login_form_err_invalid_url'.tr();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateEmail(String? email) {
|
String? _validateEmail(String? email) {
|
||||||
if (email == null || email == '') {
|
if (email == null || email == '') {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
@@ -10,6 +9,7 @@ import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
@@ -255,11 +255,11 @@ class _BackupDelaySlider extends ConsumerWidget {
|
|||||||
_ => 600,
|
_ => 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
static String formatBackupDelaySliderValue(int v) => switch (v) {
|
static String formatBackupDelaySliderValue(BuildContext context, int v) => switch (v) {
|
||||||
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
|
0 => context.t.setting_notifications_notify_seconds(count: 5),
|
||||||
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
|
1 => context.t.setting_notifications_notify_seconds(count: 30),
|
||||||
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
|
2 => context.t.setting_notifications_notify_minutes(count: 2),
|
||||||
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
|
_ => context.t.setting_notifications_notify_minutes(count: 10),
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -272,8 +272,8 @@ class _BackupDelaySlider extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 24.0, top: 8.0),
|
padding: const EdgeInsets.only(left: 24.0, top: 8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'backup_controller_page_background_delay'.tr(
|
context.t.backup_controller_page_background_delay(
|
||||||
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
|
duration: formatBackupDelaySliderValue(context, currentValue),
|
||||||
),
|
),
|
||||||
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
@@ -291,7 +291,7 @@ class _BackupDelaySlider extends ConsumerWidget {
|
|||||||
max: 3.0,
|
max: 3.0,
|
||||||
min: 0.0,
|
min: 0.0,
|
||||||
divisions: 3,
|
divisions: 3,
|
||||||
label: formatBackupDelaySliderValue(currentValue),
|
label: formatBackupDelaySliderValue(context, currentValue),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,6 +77,40 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('isValidServerUrl', () {
|
||||||
|
test('should treat null as valid', () {
|
||||||
|
expect(isValidServerUrl(null), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should treat empty string as valid', () {
|
||||||
|
expect(isValidServerUrl(''), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept a bare host', () {
|
||||||
|
expect(isValidServerUrl('demo.immich.app'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept a bare host with a port', () {
|
||||||
|
expect(isValidServerUrl('192.168.1.1:2283'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept an http URL', () {
|
||||||
|
expect(isValidServerUrl('http://demo.immich.app'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept an https URL', () {
|
||||||
|
expect(isValidServerUrl('https://demo.immich.app:2283/api'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject a non-http scheme', () {
|
||||||
|
expect(isValidServerUrl('ftp://demo.immich.app'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject scheme only input', () {
|
||||||
|
expect(isValidServerUrl('https://'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('punycodeDecodeUrl', () {
|
group('punycodeDecodeUrl', () {
|
||||||
test('should return null for null input', () {
|
test('should return null for null input', () {
|
||||||
expect(punycodeDecodeUrl(null), isNull);
|
expect(punycodeDecodeUrl(null), isNull);
|
||||||
|
|||||||
@@ -136,6 +136,51 @@ void main() {
|
|||||||
expect(task, isNotNull);
|
expect(task, isNotNull);
|
||||||
expect(task!.fields.containsKey('visibility'), isFalse);
|
expect(task!.fields.containsKey('visibility'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
final mockFile = File('/path/to/IMG_6499.jpg');
|
||||||
|
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.fields['filename'], equals('IMG_6499.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the .dng extension for a genuine RAW original', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
final mockFile = File('/path/to/IMG_5210.dng');
|
||||||
|
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.fields['filename'], equals('IMG_5210.dng'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
final mockFile = File('/path/to/DJI_0001');
|
||||||
|
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
|
||||||
|
|
||||||
|
final task = await sut.getUploadTask(asset);
|
||||||
|
expect(task, isNotNull);
|
||||||
|
expect(task!.fields['filename'], equals('DJI_0001.jpg'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('getLivePhotoUploadTask', () {
|
group('getLivePhotoUploadTask', () {
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ void main() {
|
|||||||
return captured;
|
return captured;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> captureOriginalFileNames() {
|
||||||
|
final captured = <String>[];
|
||||||
|
when(
|
||||||
|
() => mockUploadRepository.uploadFile(
|
||||||
|
file: any(named: 'file'),
|
||||||
|
originalFileName: any(named: 'originalFileName'),
|
||||||
|
fields: any(named: 'fields'),
|
||||||
|
cancelToken: any(named: 'cancelToken'),
|
||||||
|
onProgress: any(named: 'onProgress'),
|
||||||
|
logContext: any(named: 'logContext'),
|
||||||
|
),
|
||||||
|
).thenAnswer((invocation) async {
|
||||||
|
captured.add(invocation.namedArguments[#originalFileName] as String);
|
||||||
|
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
|
||||||
|
});
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
group('uploadSingleAsset', () {
|
group('uploadSingleAsset', () {
|
||||||
test('should upload the motion part hidden and keep the still image visible', () async {
|
test('should upload the motion part hidden and keep the still image visible', () async {
|
||||||
final asset = LocalAssetStub.image1;
|
final asset = LocalAssetStub.image1;
|
||||||
@@ -124,5 +142,59 @@ void main() {
|
|||||||
expect(captured, hasLength(1));
|
expect(captured, hasLength(1));
|
||||||
expect(captured[0].containsKey('visibility'), isFalse);
|
expect(captured[0].containsKey('visibility'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('corrects the extension when iOS returns a rendered file for a .dng asset', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
final stillFile = File('/path/to/IMG_6499.jpg');
|
||||||
|
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng');
|
||||||
|
|
||||||
|
final names = captureOriginalFileNames();
|
||||||
|
|
||||||
|
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||||
|
|
||||||
|
expect(names, equals(['IMG_6499.jpg']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the .dng extension for a genuine RAW original', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
final stillFile = File('/path/to/IMG_5210.dng');
|
||||||
|
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng');
|
||||||
|
|
||||||
|
final names = captureOriginalFileNames();
|
||||||
|
|
||||||
|
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||||
|
|
||||||
|
expect(names, equals(['IMG_5210.dng']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async {
|
||||||
|
final asset = LocalAssetStub.image1;
|
||||||
|
final mockEntity = MockAssetEntity();
|
||||||
|
final stillFile = File('/path/to/DJI_0001');
|
||||||
|
|
||||||
|
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||||
|
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
|
||||||
|
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
|
||||||
|
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001');
|
||||||
|
|
||||||
|
final names = captureOriginalFileNames();
|
||||||
|
|
||||||
|
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
|
||||||
|
|
||||||
|
expect(names, equals(['DJI_0001.jpg']));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ program
|
|||||||
.usage('[paths...] [options]')
|
.usage('[paths...] [options]')
|
||||||
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
|
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
|
||||||
.addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS'))
|
.addOption(new Option('-i, --ignore <pattern>', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS'))
|
||||||
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
|
.addOption(new Option('--skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
|
||||||
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
|
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option('-a, --album', 'Automatically create albums based on folder name')
|
new Option('-a, --album', 'Automatically create albums based on folder name')
|
||||||
|
|||||||
@@ -308,6 +308,76 @@
|
|||||||
},
|
},
|
||||||
"uiHints": ["Filter"]
|
"uiHints": ["Filter"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "assetExifFilter",
|
||||||
|
"title": "Filter by EXIF metadata",
|
||||||
|
"description": "Filter assets by their EXIF properties",
|
||||||
|
"types": ["AssetV1"],
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"property": {
|
||||||
|
"title": "Property",
|
||||||
|
"description": "EXIF property to match",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"make",
|
||||||
|
"model",
|
||||||
|
"exifImageWidth",
|
||||||
|
"exifImageHeight",
|
||||||
|
"fileSizeInByte",
|
||||||
|
"orientation",
|
||||||
|
"lensModel",
|
||||||
|
"fNumber",
|
||||||
|
"focalLength",
|
||||||
|
"iso",
|
||||||
|
"description",
|
||||||
|
"fps",
|
||||||
|
"exposureTime",
|
||||||
|
"livePhotoCID",
|
||||||
|
"timeZone",
|
||||||
|
"projectionType",
|
||||||
|
"profileDescription",
|
||||||
|
"colorspace",
|
||||||
|
"bitsPerSample",
|
||||||
|
"rating"
|
||||||
|
],
|
||||||
|
"uiHint": {
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Pattern",
|
||||||
|
"description": "Text or regex pattern to match against property value",
|
||||||
|
"uiHint": {
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"matchType": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Match type",
|
||||||
|
"enum": ["contains", "startsWith", "exact", "regex"],
|
||||||
|
"default": "contains",
|
||||||
|
"description": "Type of pattern matching to perform",
|
||||||
|
"uiHint": {
|
||||||
|
"order": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"caseSensitive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"title": "Case sensitive",
|
||||||
|
"description": "Whether matching should be case-sensitive",
|
||||||
|
"uiHint": {
|
||||||
|
"order": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["property", "pattern"]
|
||||||
|
},
|
||||||
|
"uiHints": ["Filter"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "assetArchive",
|
"name": "assetArchive",
|
||||||
"title": "Archive asset",
|
"title": "Archive asset",
|
||||||
|
|||||||
@@ -2,6 +2,42 @@ import { wrapper } from '@immich/plugin-sdk';
|
|||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import type { Manifest } from '../dist/index.d.ts';
|
import type { Manifest } from '../dist/index.d.ts';
|
||||||
|
|
||||||
|
type MatchValueConfig = {
|
||||||
|
pattern: string;
|
||||||
|
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchValueResult = (value: string, config: MatchValueConfig) => {
|
||||||
|
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||||
|
const searchName = caseSensitive ? value : value.toLowerCase();
|
||||||
|
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||||
|
|
||||||
|
switch (matchType) {
|
||||||
|
case 'contains': {
|
||||||
|
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'exact': {
|
||||||
|
return { workflow: { continue: searchName === searchPattern } };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'startsWith': {
|
||||||
|
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'regex': {
|
||||||
|
const flags = caseSensitive ? '' : 'i';
|
||||||
|
const regex = new RegExp(searchPattern, flags);
|
||||||
|
return { workflow: { continue: regex.test(value) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const methods = wrapper<Manifest>({
|
const methods = wrapper<Manifest>({
|
||||||
assetAddToAlbums: ({ config, data, functions }) => {
|
assetAddToAlbums: ({ config, data, functions }) => {
|
||||||
const assetId = data.asset.id;
|
const assetId = data.asset.id;
|
||||||
@@ -53,39 +89,7 @@ const methods = wrapper<Manifest>({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
assetFileFilter: ({ data, config }) => {
|
assetFileFilter: ({ data, config }) => matchValueResult(data.asset.originalFileName || '', config),
|
||||||
const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config;
|
|
||||||
|
|
||||||
const { asset } = data;
|
|
||||||
|
|
||||||
const fileName = usePath ? asset.originalPath : asset.originalFileName;
|
|
||||||
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
|
|
||||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
|
||||||
|
|
||||||
switch (matchType) {
|
|
||||||
case 'contains': {
|
|
||||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'exact': {
|
|
||||||
return { workflow: { continue: searchName === searchPattern } };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'startsWith': {
|
|
||||||
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'regex': {
|
|
||||||
const flags = caseSensitive ? '' : 'i';
|
|
||||||
const regex = new RegExp(searchPattern, flags);
|
|
||||||
return { workflow: { continue: regex.test(fileName) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
assetLocationFilter: ({ config, data }) => {
|
assetLocationFilter: ({ config, data }) => {
|
||||||
if (
|
if (
|
||||||
@@ -124,6 +128,14 @@ const methods = wrapper<Manifest>({
|
|||||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
assetExifFilter: ({ config, data }) => {
|
||||||
|
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
|
||||||
|
return { workflow: { continue: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
|
||||||
|
},
|
||||||
|
|
||||||
assetDateFilter: ({ config, data }) => {
|
assetDateFilter: ({ config, data }) => {
|
||||||
const assetDate = new Date(data.asset.localDateTime);
|
const assetDate = new Date(data.asset.localDateTime);
|
||||||
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
|
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
|
||||||
@@ -200,6 +212,7 @@ const {
|
|||||||
assetFavorite,
|
assetFavorite,
|
||||||
assetFileFilter,
|
assetFileFilter,
|
||||||
assetLocationFilter,
|
assetLocationFilter,
|
||||||
|
assetExifFilter,
|
||||||
assetDateFilter,
|
assetDateFilter,
|
||||||
assetLock,
|
assetLock,
|
||||||
assetMissingTimeZoneFilter,
|
assetMissingTimeZoneFilter,
|
||||||
@@ -217,6 +230,7 @@ export {
|
|||||||
assetFavorite,
|
assetFavorite,
|
||||||
assetFileFilter,
|
assetFileFilter,
|
||||||
assetLocationFilter,
|
assetLocationFilter,
|
||||||
|
assetExifFilter,
|
||||||
assetDateFilter,
|
assetDateFilter,
|
||||||
assetLock,
|
assetLock,
|
||||||
assetMissingTimeZoneFilter,
|
assetMissingTimeZoneFilter,
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
} from 'src/commands/media-location.command';
|
} from 'src/commands/media-location.command';
|
||||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
||||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
||||||
import { SchemaCheck } from 'src/commands/schema-check';
|
import { SchemaCheck } from 'src/commands/schema-check';
|
||||||
import { VersionCommand } from 'src/commands/version.command';
|
import { VersionCommand } from 'src/commands/version.command';
|
||||||
|
|
||||||
export const commandsAndQuestions = [
|
export const commandsAndQuestions = [
|
||||||
ResetAdminPasswordCommand,
|
ResetAdminPasswordCommand,
|
||||||
PromptPasswordQuestions,
|
PromptPasswordResetQuestions,
|
||||||
PromptEmailQuestion,
|
PromptEmailQuestion,
|
||||||
EnablePasswordLoginCommand,
|
EnablePasswordLoginCommand,
|
||||||
DisablePasswordLoginCommand,
|
DisablePasswordLoginCommand,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
|||||||
import { CliService } from 'src/services/cli.service';
|
import { CliService } from 'src/services/cli.service';
|
||||||
|
|
||||||
const prompt = (inquirer: InquirerService) => {
|
const prompt = (inquirer: InquirerService) => {
|
||||||
return function ask(admin: UserAdminResponseDto) {
|
return (admin: UserAdminResponseDto) => {
|
||||||
const { id, oauthId, email, name } = admin;
|
const { id, oauthId, email, name } = admin;
|
||||||
console.log(`Found Admin:
|
console.log(`Found Admin:
|
||||||
- ID=${id}
|
- ID=${id}
|
||||||
@@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => {
|
|||||||
- Email=${email}
|
- Email=${email}
|
||||||
- Name=${name}`);
|
- Name=${name}`);
|
||||||
|
|
||||||
return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password);
|
return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@QuestionSet({ name: 'prompt-password' })
|
@QuestionSet({ name: 'prompt-password-reset' })
|
||||||
export class PromptPasswordQuestions {
|
export class PromptPasswordResetQuestions {
|
||||||
@Question({
|
@Question({
|
||||||
message: 'Please choose a new password (optional)',
|
message: 'Please choose a new password (optional)',
|
||||||
name: 'password',
|
name: 'newPassword',
|
||||||
})
|
})
|
||||||
parsePassword(value: string) {
|
parsePassword(value: string) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Question({
|
||||||
|
type: 'confirm',
|
||||||
|
message: 'Invalidate existing sessions?',
|
||||||
|
default: true,
|
||||||
|
name: 'invalidateSessions',
|
||||||
|
})
|
||||||
|
parseInvalidate(value: boolean): boolean {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -568,6 +568,34 @@ describe(AssetService.name, () => {
|
|||||||
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
|
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should delete the stack when a non-primary asset is deleted and only the primary would remain', async () => {
|
||||||
|
const asset = AssetFactory.from().build();
|
||||||
|
const deletionAsset = {
|
||||||
|
...getForAssetDeletion(asset),
|
||||||
|
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }] },
|
||||||
|
};
|
||||||
|
mocks.stack.delete.mockResolvedValue();
|
||||||
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
|
||||||
|
|
||||||
|
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||||
|
|
||||||
|
expect(mocks.stack.delete).toHaveBeenCalledWith(deletionAsset.stack.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep the stack when a non-primary asset is deleted and the primary plus another asset remain', async () => {
|
||||||
|
const asset = AssetFactory.from().build();
|
||||||
|
const deletionAsset = {
|
||||||
|
...getForAssetDeletion(asset),
|
||||||
|
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }, { id: newUuid() }] },
|
||||||
|
};
|
||||||
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
|
||||||
|
|
||||||
|
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||||
|
|
||||||
|
expect(mocks.stack.delete).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should delete a live photo', async () => {
|
it('should delete a live photo', async () => {
|
||||||
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
||||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||||
|
|||||||
@@ -316,18 +316,25 @@ export class AssetService extends BaseService {
|
|||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace the parent of the stack children with a new asset
|
if (asset.stack) {
|
||||||
if (asset.stack?.primaryAssetId === id) {
|
// asset.stack.assets only includes timeline visible assets and excludes the primary asset
|
||||||
// this only includes timeline visible assets and excludes the primary asset
|
const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id);
|
||||||
const stackAssetIds = asset.stack.assets.map((a) => a.id);
|
|
||||||
if (stackAssetIds.length >= 2) {
|
// the primary survives unless it is the asset being deleted
|
||||||
const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
|
let remainingCount = remainingStackAssetIds.length;
|
||||||
|
if (asset.stack.primaryAssetId !== id) {
|
||||||
|
remainingCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingCount < 2) {
|
||||||
|
// 0 or 1 asset would remain: dissolve the stack so it does not linger as a single-asset stack
|
||||||
|
await this.stackRepository.delete(asset.stack.id);
|
||||||
|
} else if (asset.stack.primaryAssetId === id) {
|
||||||
|
// the primary is being deleted but others remain: promote a new primary
|
||||||
await this.stackRepository.update(asset.stack.id, {
|
await this.stackRepository.update(asset.stack.id, {
|
||||||
id: asset.stack.id,
|
id: asset.stack.id,
|
||||||
primaryAssetId: newPrimaryAssetId,
|
primaryAssetId: remainingStackAssetIds[0],
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
await this.stackRepository.delete(asset.stack.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe(CliService.name, () => {
|
|||||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
|
|
||||||
const ask = vitest.fn().mockImplementation(() => {});
|
const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false });
|
||||||
|
|
||||||
const response = await sut.resetAdminPassword(ask);
|
const response = await sut.resetAdminPassword(ask);
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ describe(CliService.name, () => {
|
|||||||
expect(ask).toHaveBeenCalled();
|
expect(ask).toHaveBeenCalled();
|
||||||
expect(id).toEqual(admin.id);
|
expect(id).toEqual(admin.id);
|
||||||
expect(update.password).toBeDefined();
|
expect(update.password).toBeDefined();
|
||||||
|
expect(mocks.session.invalidateAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the supplied password', async () => {
|
it('should use the supplied password', async () => {
|
||||||
@@ -55,7 +56,7 @@ describe(CliService.name, () => {
|
|||||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
mocks.user.update.mockResolvedValue(admin);
|
mocks.user.update.mockResolvedValue(admin);
|
||||||
|
|
||||||
const ask = vitest.fn().mockResolvedValue('new-password');
|
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false });
|
||||||
|
|
||||||
const response = await sut.resetAdminPassword(ask);
|
const response = await sut.resetAdminPassword(ask);
|
||||||
|
|
||||||
@@ -66,6 +67,20 @@ describe(CliService.name, () => {
|
|||||||
expect(id).toEqual(admin.id);
|
expect(id).toEqual(admin.id);
|
||||||
expect(update.password).toBeDefined();
|
expect(update.password).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should invalidate existing sessions when requested', async () => {
|
||||||
|
const admin = UserFactory.create({ isAdmin: true });
|
||||||
|
|
||||||
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
|
mocks.user.update.mockResolvedValue(admin);
|
||||||
|
mocks.session.invalidateAll.mockResolvedValue(void 0);
|
||||||
|
|
||||||
|
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true });
|
||||||
|
|
||||||
|
await sut.resetAdminPassword(ask);
|
||||||
|
|
||||||
|
expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('disablePasswordLogin', () => {
|
describe('disablePasswordLogin', () => {
|
||||||
|
|||||||
@@ -58,18 +58,24 @@ export class CliService extends BaseService {
|
|||||||
return users.map((user) => mapUserAdmin(user));
|
return users.map((user) => mapUserAdmin(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
|
async resetAdminPassword(
|
||||||
|
ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>,
|
||||||
|
) {
|
||||||
const admin = await this.userRepository.getAdmin();
|
const admin = await this.userRepository.getAdmin();
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new Error('Admin account does not exist');
|
throw new Error('Admin account does not exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
const providedPassword = await ask(mapUserAdmin(admin));
|
const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin));
|
||||||
const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
|
const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
|
||||||
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
|
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
|
||||||
|
|
||||||
await this.userRepository.update(admin.id, { password: hashedPassword });
|
await this.userRepository.update(admin.id, { password: hashedPassword });
|
||||||
|
|
||||||
|
if (invalidateSessions) {
|
||||||
|
await this.sessionRepository.invalidateAll({ userId: admin.id });
|
||||||
|
}
|
||||||
|
|
||||||
return { admin, password, provided: !!providedPassword };
|
return { admin, password, provided: !!providedPassword };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -433,9 +433,10 @@ describe('core plugin', () => {
|
|||||||
describe('assetDateFilter', () => {
|
describe('assetDateFilter', () => {
|
||||||
it('should favorite assets created during the first 7 days of a specific year and month', async () => {
|
it('should favorite assets created during the first 7 days of a specific year and month', async () => {
|
||||||
const { user } = await ctx.newUser();
|
const { user } = await ctx.newUser();
|
||||||
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
|
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([
|
||||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-01') }),
|
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-01') }),
|
||||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-07T23:59:59Z') }),
|
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-07T23:59:59Z') }),
|
||||||
|
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const workflow = await createWorkflow({
|
const workflow = await createWorkflow({
|
||||||
@@ -461,6 +462,46 @@ describe('core plugin', () => {
|
|||||||
|
|
||||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
|
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
|
||||||
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
|
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
|
||||||
|
|
||||||
|
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
|
||||||
|
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match recurring dates regardless of the year', async () => {
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([
|
||||||
|
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2026-03-01') }),
|
||||||
|
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('1998-12-21') }),
|
||||||
|
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
|
||||||
|
]);
|
||||||
|
await ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2010-06-15') });
|
||||||
|
|
||||||
|
const workflow = await createWorkflow({
|
||||||
|
ownerId: user.id,
|
||||||
|
trigger: WorkflowTrigger.AssetCreate,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: 'immich-plugin-core#assetDateFilter',
|
||||||
|
config: {
|
||||||
|
startDate: { day: 12, month: 12, year: 2000 },
|
||||||
|
endDate: { day: 30, month: 3, year: 2001 },
|
||||||
|
recurring: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'immich-plugin-core#assetFavorite',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id });
|
||||||
|
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
|
||||||
|
|
||||||
|
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
|
||||||
|
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
|
||||||
|
|
||||||
|
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
|
||||||
|
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
|
<div class="overflow-x-auto pb-1">
|
||||||
<div class="grid grid-flow-col grid-rows-7 gap-0.5">
|
<div class="grid grid-flow-col grid-rows-7 gap-0.5">
|
||||||
<div class="row-span-7 grid grid-rows-subgrid">
|
<div class="row-span-7 grid grid-rows-subgrid">
|
||||||
{#if Info.getStartOfWeek({ locale: $locale }) === 7}
|
{#if Info.getStartOfWeek({ locale: $locale }) === 7}
|
||||||
@@ -81,8 +82,9 @@
|
|||||||
></div>
|
></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span>{$t('less')}</span>
|
<span>{$t('less')}</span>
|
||||||
<span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span>
|
<span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span>
|
||||||
<span class="size-3 rounded-sm bg-immich-primary/30"></span>
|
<span class="size-3 rounded-sm bg-immich-primary/30"></span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AssetOrder, type AssetResponseDto } from '@immich/sdk';
|
import { AssetOrder, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
import { getOrderingDate, plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||||
import { TimelineManager } from '../timeline-manager.svelte';
|
import { TimelineManager } from '../timeline-manager.svelte';
|
||||||
import type { TimelineMonth } from '../timeline-month.svelte';
|
import type { TimelineMonth } from '../timeline-month.svelte';
|
||||||
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||||
@@ -125,7 +125,14 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const assetOrder: AssetOrder = timelineManager.getAssetOrder();
|
const assetOrder: AssetOrder = timelineManager.getAssetOrder();
|
||||||
if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) {
|
const orderBy: AssetOrderBy = timelineManager.getOrderBy();
|
||||||
|
if (
|
||||||
|
plainDateTimeCompare(
|
||||||
|
assetOrder === AssetOrder.Desc,
|
||||||
|
getOrderingDate(startAsset, orderBy),
|
||||||
|
getOrderingDate(endAsset, orderBy),
|
||||||
|
) < 0
|
||||||
|
) {
|
||||||
[startAsset, endAsset] = [endAsset, startAsset];
|
[startAsset, endAsset] = [endAsset, startAsset];
|
||||||
// eslint-disable-next-line no-useless-assignment
|
// eslint-disable-next-line no-useless-assignment
|
||||||
[startTimelineMonth, endTimelineMonth] = [endTimelineMonth, startTimelineMonth];
|
[startTimelineMonth, endTimelineMonth] = [endTimelineMonth, startTimelineMonth];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { AssetOrderBy, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
@@ -808,4 +808,44 @@ describe('TimelineManager', () => {
|
|||||||
expect(b.showAssetOwners).toBe(true);
|
expect(b.showAssetOwners).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('retrieveRange', () => {
|
||||||
|
it('uses createdAt ordering in the Recently Added view (orderBy=CreatedAt)', async () => {
|
||||||
|
// Simulate the "Recently Added" bug: two assets whose localDateTime order is
|
||||||
|
// the reverse of their createdAt (upload) order. Before the fix, retrieveRange
|
||||||
|
// compared localDateTime and selected the wrong range direction.
|
||||||
|
const timelineManager = new TimelineManager();
|
||||||
|
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||||
|
await timelineManager.updateOptions({ orderBy: AssetOrderBy.CreatedAt });
|
||||||
|
|
||||||
|
// assetA was taken recently (2024) but uploaded first (2025-01)
|
||||||
|
const assetA = timelineAssetFactory.build({
|
||||||
|
localDateTime: fromISODateTimeUTCToObject('2024-06-01T00:00:00.000Z'),
|
||||||
|
createdAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'),
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
// assetB was taken long ago (2018) but uploaded second (2025-02)
|
||||||
|
const assetB = timelineAssetFactory.build({
|
||||||
|
localDateTime: fromISODateTimeUTCToObject('2018-03-15T00:00:00.000Z'),
|
||||||
|
createdAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'),
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
// assetC is an unrelated asset that should not appear in the selection
|
||||||
|
const assetC = timelineAssetFactory.build({
|
||||||
|
localDateTime: fromISODateTimeUTCToObject('2022-01-01T00:00:00.000Z'),
|
||||||
|
createdAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'),
|
||||||
|
fileCreatedAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineManager.upsertAssets([assetA, assetB, assetC]);
|
||||||
|
|
||||||
|
// Shift-click from assetB (uploaded most recently) to assetA — the range
|
||||||
|
// between them in the "Recently Added" timeline should contain only those two.
|
||||||
|
const range = await timelineManager.retrieveRange({ id: assetB.id }, { id: assetA.id });
|
||||||
|
const ids = range.map((a) => a.id);
|
||||||
|
expect(ids).toContain(assetA.id);
|
||||||
|
expect(ids).toContain(assetB.id);
|
||||||
|
expect(ids).not.toContain(assetC.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -625,6 +625,10 @@ export class TimelineManager extends VirtualScrollManager {
|
|||||||
return this.#options.order ?? AssetOrder.Desc;
|
return this.#options.order ?? AssetOrder.Desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getOrderBy() {
|
||||||
|
return this.#options.orderBy ?? AssetOrderBy.TakenAt;
|
||||||
|
}
|
||||||
|
|
||||||
protected postCreateSegments(): void {
|
protected postCreateSegments(): void {
|
||||||
this.months.sort((a, b) => {
|
this.months.sort((a, b) => {
|
||||||
return a.yearMonth.year === b.yearMonth.year
|
return a.yearMonth.year === b.yearMonth.year
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
</Stack>
|
</Stack>
|
||||||
</AdminCard>
|
</AdminCard>
|
||||||
|
|
||||||
<div class="col-span-2 px-4 py-2">
|
<div class="col-span-full px-4 py-2">
|
||||||
<div class="flex gap-2 text-primary">
|
<div class="flex gap-2 text-primary">
|
||||||
<Icon icon={mdiCloudUploadOutline} size="1.5rem" />
|
<Icon icon={mdiCloudUploadOutline} size="1.5rem" />
|
||||||
<CardTitle>{$t('uploads')}</CardTitle>
|
<CardTitle>{$t('uploads')}</CardTitle>
|
||||||
|
|||||||
Reference in New Issue
Block a user