Compare commits

...

22 Commits

Author SHA1 Message Date
mertalev
6b325ee3ec disable security limit 2026-07-15 13:53:03 -04:00
Alex
12061f3bf8 fix: Ken Burns effect on sliver app bar jittery on Android (#29890) 2026-07-14 19:21:33 +00:00
Daniel Dietzler
2460d431af feat: system integrity docs (#29911) 2026-07-14 14:00:27 -05:00
Matthew Momjian
24556cd3fc fix(docs): update fdroid link (#29797)
Co-authored-by: Brandon Wees <brandonwees@gmail.com>
2026-07-14 20:04:59 +02:00
Adam Gastineau
c84ab54889 fix(mobile): clear zoom state when swapping assets (#29909) 2026-07-14 12:18:18 -05:00
renovate[bot]
19e59ebd98 chore(deps): update github-actions (#29894) 2026-07-14 18:44:32 +02:00
Mert
89b0d906e8 fix(server): set hvc1 tag when using hwa (#29908)
fix hvc1 check
2026-07-14 11:03:30 -04:00
Daniel Dietzler
3c43e8d6c1 fix: revert "fix(mobile): send a full datetime from the android memories widget" (#29913) 2026-07-14 14:41:43 +00:00
Daniel Dietzler
2ed8b2bddd fix: memory search date validation (#29907) 2026-07-14 16:34:38 +02:00
Santo Shakil
9c8d718ddc fix(mobile): send a full datetime from the android memories widget (#29689) 2026-07-14 09:25:06 -05:00
renovate[bot]
20e123c839 chore(deps): update base-image to v202607132058 (#29895) 2026-07-14 11:16:42 +02:00
Daniel Dietzler
35fcca6254 fix: locked visibility in search random endpoint (#29887) 2026-07-13 20:03:20 +00:00
shenlong
ffafc144c6 refactor: use single bucket for none grouping (#29880)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-07-13 15:01:59 -05:00
bo0tzz
89d31aaba1 feat: publish app releases to FUTO F-Droid repo (#29804)
* feat: publish app releases to FUTO F-Droid repo

* chore: separate parse step
2026-07-13 14:57:40 -05:00
Alex
f2ddace584 fix: system appbar icon color has incorrect color in asset viewer (#29885) 2026-07-13 14:50:38 -05:00
shenlong
99883096d6 fix: still part of live photo hidden on bg upload (#29884)
* fix: still part of live photo hidden on bg upload

* add foreground tests

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-13 14:50:06 -05:00
Daniel Dietzler
84dff19ca9 fix: unauthorized album owner update (#29883) 2026-07-13 11:46:18 -05:00
Priyanshu
da8774801b fix(mobile): correct person age singular pluralization (#29868)
The ICU plural string for person_age_years in i18n/en.json only defined
the other plural category, so the intl MessageFormat runtime always
fell back to it — displaying '1 years old' instead of '1 year old'.

Added the missing one plural category:

Before: {years, plural, other {# years}} old
After:  {years, plural, one {# year} other {# years}} old

No Dart source changes required. formatAge already uses the
localization system correctly.

Fixes #29865

Co-authored-by: priyanshuANDcoad <priyanshu23154050@akgec.ac.in>
2026-07-13 14:49:06 +00:00
Ben Beckford
9057ae9759 fix(web): closing plugin method picker selects "unknown" method (#29784) 2026-07-13 09:41:28 -05:00
Alex
557189d7a8 chore: add Adam to mobile's codeowner (#29816) 2026-07-10 15:10:52 -05:00
Mees Frensel
19313e75fd chore(web): fix testing deprecations (#29799) 2026-07-10 14:30:12 +02:00
Weblate (bot)
352c8086f9 chore(web): update translations (#29774)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/be/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/en_GB/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/eo/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/eu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ga/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ml/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/mr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sq/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Cyrl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/te/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/yue_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/
Translation: Immich/immich

Co-authored-by: Adam Havránek <adamhavra@seznam.cz>
Co-authored-by: Aiixi Bear <bearshen1019@outlook.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Alaksandr Łopart <laporte0723@gmail.com>
Co-authored-by: Alexis Rossfelder <rossfelderalexis@gmail.com>
Co-authored-by: AntonPalmqvist <apq@users.noreply.hosted.weblate.org>
Co-authored-by: Ardev Prisec <prisecardev@gmail.com>
Co-authored-by: Biepa <github@flippie.de>
Co-authored-by: Branko Kokanovic <branko@kokanovic.org>
Co-authored-by: Denis Pacquier <denis.pacquier@gmail.com>
Co-authored-by: DevServs <bonov@mail.ru>
Co-authored-by: Dusan Hlavaty <dhlavaty@gmail.com>
Co-authored-by: Emil Warelius <emilwarelius@gmail.com>
Co-authored-by: Enric Pagès i Gassull <enricpages@hotmail.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Frank Paul Silye <frankps@gmail.com>
Co-authored-by: Grzegorz Leks <grzegorz.leks@gmail.com>
Co-authored-by: Gyutaek Kang <gtkang2000@naver.com>
Co-authored-by: HackingAll <hacking.all.YT@gmail.com>
Co-authored-by: Happy <59247878+happy2452354@users.noreply.github.com>
Co-authored-by: Hosted Weblate user 157208 <benjamin3.hanke@gmail.com>
Co-authored-by: Hurricane_32 <rodrigorimo@hotmail.com>
Co-authored-by: Huy Trieu <thetop1102@gmail.com>
Co-authored-by: Hồ Nhất Duy <axicenia@gmail.com>
Co-authored-by: Indrek Haav <indrekhaav@users.noreply.hosted.weblate.org>
Co-authored-by: Ivan Dimitrov <idimitrov08@gmail.com>
Co-authored-by: Jesus Barba <jesusbarba11@gmail.com>
Co-authored-by: JiZPaper <JiZPaper@gmail.com>
Co-authored-by: Jins K Joy <mailjinskjoy@gmail.com>
Co-authored-by: Jonas Franke <90050350+JonasFranke@users.noreply.github.com>
Co-authored-by: Jordy H <jordy@hoebergen.net>
Co-authored-by: Jorge Montejo <jorgemon.lopez@gmail.com>
Co-authored-by: Jozef Gaal <preklady@mayday.sk>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Ketan Avhad <ketaavha@gmail.com>
Co-authored-by: Kevin wang <jiqi963@gmail.com>
Co-authored-by: Klenner Martins Barros <klenne.al@gmail.com>
Co-authored-by: Liviu Roman <contact@liviuroman.com>
Co-authored-by: Luis Fernando Illapa <moxb@outlook.es>
Co-authored-by: Marc Portabella Navarro <portabella.marc@gmail.com>
Co-authored-by: MarcSerraPeralta <marcserraperalta@gmail.com>
Co-authored-by: Matjaž T. <matjaz@moj-svet.si>
Co-authored-by: Matteo De Carli <matteo.de.carli01@gmail.com>
Co-authored-by: Mees Frensel <meesfrensel@gmail.com>
Co-authored-by: MrKoby07 <hirmerkorbinian122@gmail.com>
Co-authored-by: Muxutruk <156070698+Muxutruk2@users.noreply.github.com>
Co-authored-by: Nagy Krisztián <nkgy17@gmail.com>
Co-authored-by: Nico Sendan <nico@sendan.com>
Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Co-authored-by: Ohne Sonne <ohne_sonne_1312@protonmail.com>
Co-authored-by: Okie <leopetrovic11@gmail.com>
Co-authored-by: Olaf Nielsen <solluh@mail.de>
Co-authored-by: PPNplus <ppnplus@protonmail.com>
Co-authored-by: Pavel Miniutka <pavel.miniutka@gmail.com>
Co-authored-by: PiTrick <dev@pitrick.link>
Co-authored-by: Rubber Ducky <rd1945@gmail.com>
Co-authored-by: Sebastian <sebastiankiwidk@gmail.com>
Co-authored-by: Simone Ognibene <ognibene2001@gmail.com>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: TV Box <realceday.tvbox@gmail.com>
Co-authored-by: TheDevIsHere <bagigio2005@gmail.com>
Co-authored-by: Tiago <tiagojoaquimsamsung@gmail.com>
Co-authored-by: Tijs-B <tijs.bergmans@telenet.be>
Co-authored-by: Tim Morley <weblate.3919org@timsk.org>
Co-authored-by: Tom Golden <tmgldn@users.noreply.hosted.weblate.org>
Co-authored-by: Tomislav Renić <trenic@gmail.com>
Co-authored-by: Tushar Harsora <tusharharsora95@gmail.com>
Co-authored-by: User 123456789 <user123456789@users.noreply.hosted.weblate.org>
Co-authored-by: Vallabh Sharma <srivallabhsharma@gmail.com>
Co-authored-by: Yusuf Arda <yusufarda7813@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: bonjur <ivanpercsity08@gmail.com>
Co-authored-by: canxunwangluo <canxunwangluo@163.com>
Co-authored-by: kylo32 <kylo32@gmail.com>
Co-authored-by: nachtpfoetchen <nachtpfoetchen@posteo.de>
Co-authored-by: pluwen chen <pluwen@gmail.com>
Co-authored-by: puv <dev@puv.pm>
Co-authored-by: rubes <mail@armd.one>
Co-authored-by: vatahome <vataszilamer@pm.me>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
2026-07-09 20:01:50 +00:00
53 changed files with 631 additions and 151 deletions

View File

@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: 'zulu'
java-version: '17'
@@ -237,7 +237,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: '3.3'
bundler-cache: true

View File

@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -81,7 +81,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io

View File

@@ -39,7 +39,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:2a64c7f045cb7b580fbdf3614d7d1805f5775fec453e3d1023764180efa8c70b
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

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

View File

@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

56
.github/workflows/fdroid.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: Update F-Droid Repo
on:
release:
types: [published]
permissions: {}
jobs:
update-index:
runs-on: ubuntu-latest
if: ${{ !github.event.release.prerelease }}
permissions:
contents: read
steps:
- name: Checkout pubspec for versionCode
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
sparse-checkout: mobile/pubspec.yaml
sparse-checkout-cone-mode: false
- name: Update F-Droid repo
env:
GITLAB_TOKEN: ${{ secrets.FDROID_REPO_TOKEN }}
RELEASE: ${{ toJSON(github.event.release) }}
TAG: ${{ github.event.release.tag_name }}
run: |
VERSION_CODE=$(yq '.version' mobile/pubspec.yaml | cut -d+ -f2)
export VERSION_CODE
NEW_ENTRY=$(yq -P '
[.assets[] | select(.name == "app-release.apk")] as $matches |
with(select($matches | length == 0); error("app-release.apk not found in release assets")) |
{
"url": $matches[0].browser_download_url,
"sha256sum": ($matches[0].digest | sub("^sha256:", "")),
"date": (.published_at | split("T") | .[0]),
"version-code": env(VERSION_CODE)
} | .date tag= "!!timestamp"
' <<< "$RELEASE")
export NEW_ENTRY
git clone --depth 1 "https://oauth2:${GITLAB_TOKEN}@gitlab.futo.org/fdroid/repo-v2.git" fdroid-repo
cd fdroid-repo
yq -i '
with(select([.apks[] | select(."version-code" == env(VERSION_CODE))] | length > 0); error("version-code already in index")) |
.apks = [env(NEW_ENTRY)] + .apks
' apps/Immich/index.yml
git config user.name "Immich Release Bot"
git config user.email "bot@immich.app"
git commit -am "Immich: add version-code ${VERSION_CODE} (${TAG})"
git push

View File

@@ -20,7 +20,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2
uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5.6.0
with:
token: ${{ steps.token.outputs.token }}
mode: exactly

View File

@@ -17,6 +17,6 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
with:
repo-token: ${{ steps.token.outputs.token }}

View File

@@ -73,7 +73,7 @@ jobs:
# TODO move to mise
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Bump version
env:

View File

@@ -4,4 +4,4 @@
/web/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler
/mobile/ @shenlong-tanwen @santoshakil
/mobile/ @shenlong-tanwen @santoshakil @agg23

View File

@@ -1,5 +1,30 @@
# System Integrity
## Integrity report
At a [customizable interval](https://my.immich.app/admin/system-settings?isOpen=integrity-checks), Immich runs integrity checks to ensure that your library is still intact and there are no corrupt files.
There are three kind of issues Immich checks for:
- Untracked files: the path was found in Immich's directories but it is not referenced in Immich's database
- Missing files: the path is found in the Immich internal database, but does not actually exist on disk
- Checksum mismatches: the checksum of the file stored in Immich's database does not match the actual file's checksum anymore
All three run nightly at 3am by default. For the "Checksum files" check, there are additional time and progress limits, as those are the most taxing checks. With these additional limits, Immich can slowly check checksums of your files over the course of a couple of days.
You can see the results of these checks on the [maintenance](https://my.immich.app/admin/maintenance) page. Here, you can also trigger a full scan (a _check_) for specific jobs, or all of them. In addition, you can also _refresh_ checks. This will only look at items that have currently been reported on, and check if those have been fixed.
### Common causes
Most common are untracked files. In many cases those are corrupted thumbnails or encoded videos that have been partially generated at some point and never got cleaned up properly. These are usually fine to delete, as both can always be regenerated at a later point. Other files will need to be investigated on a case-by-case basis by checking they already exist in Immich and thinking about how they might have gotten untracked.
:::info
You might want to run the _missing_ jobs for thumbnails and encoded videos (https://my.immich.app/admin/queues) to make sure all your assets have proper thumbnails and encoded videos. Watch the server logs while running the jobs, in case there are any issues with some assets.
:::
Missing files are files where Immich references them internally, but they don't actually exist on disk in that location. It could be that you deleted a file on disk from the internal library (don't do that, Immich doesn't support it). It could also be that there are issues with your file storage. Please carefully investigate missing files, and never hesitate to reach out on [our Discord](https://discord.immich.app) if you have any questions!
Checksum mismatches are often indicative of file system corruption. It could also be that you previously edited a file from the internal library on the disk, which is also not supported and will cause a checksum mismatch. Again, the recommended action is to look at any reported item individually, check it out, try to remember if you changed it or some metadata on it at some point. If you edited the file, the supported resolution is to delete the mismatched asset from Immich and reupload it as a new asset.
## Folder checks
:::info
@@ -40,7 +65,7 @@ The above error messages show that the server has previously (successfully) writ
### Ignoring the checks
:::warning
:::danger
The checks are designed to catch common problems that we have seen users have in the past, and often indicate there's something wrong that you should solve. If you know what you're doing and you want to disable them you can set the following environment variable:
:::

View File

@@ -1,7 +1,7 @@
The mobile app can be downloaded from the following places:
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [Apple App Store](https://apps.apple.com/us/app/immich/id1613945652)
- [F-Droid](https://f-droid.org/packages/app.alextran.immich)
- [GitHub Releases (apk)](https://github.com/immich-app/immich/releases)
- [Google Play Store](https://play.google.com/store/apps/details?id=app.alextran.immich)
- [GitHub Releases (APK)](https://github.com/immich-app/immich/releases)
- Obtainium: You can get your Obtainium config link from the [Utilities page of your Immich server](https://my.immich.app/utilities).
- [F-Droid](https://app.futo.org/fdroid/repo/)

View File

@@ -796,5 +796,22 @@ describe('/albums', () => {
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no album.share access'));
});
it('should not allow an editor to change the role of an owner', async () => {
const album = await utils.createAlbum(user1.accessToken, {
albumName: 'testAlbum',
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
});
expect(album.albumUsers[1].role).toEqual(AlbumUserRole.Editor);
const { status, body } = await request(app)
.put(`/albums/${album.id}/user/${user1.userId}`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ role: AlbumUserRole.Editor });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('User is owner'));
});
});
});

View File

@@ -1621,7 +1621,7 @@
"person": "Person",
"person_age_months": "{months, plural, one {# month} other {# months}} old",
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
"person_age_years": "{years, plural, other {# years}} old",
"person_age_years": "{years, plural, one {# year} other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",

View File

@@ -431,8 +431,8 @@
"transcoding_realtime_description": "Permet au transcodage d'être réalisé en temps réel durant la diffusion du flux de la vidéo. Active la bascule automatique entre résolutions, mais peut entraîner une latence importante de lecture et des microcoupures en fonction des capacités du serveur.",
"transcoding_realtime_enabled": "Activer la transcodification en temps réel",
"transcoding_realtime_enabled_description": "Si désactivé, le serveur refusera de démarrer de nouvelles sessions de transcodifications en temps réel.",
"transcoding_realtime_resolutions": "Définitions",
"transcoding_realtime_resolutions_description": "Les définitions proposées pour le transcodage en temps réel. Des définitions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
"transcoding_realtime_resolutions": "Résolutions",
"transcoding_realtime_resolutions_description": "Les résolutions proposées pour le transcodage en temps réel. Des résolutions supérieures peuvent causer des soucis pendant la lecture si le serveur ne peut pas les transcoder suffisamment rapidement.",
"transcoding_realtime_video_codecs": "Codecs vidéo",
"transcoding_realtime_video_codecs_description": "Les codecs vidéo proposés pour le transcodage en temps réel. Les clients choisissent la meilleure option qu'ils supportent durant la lecture. AV1 est plus efficace que HEVC, qui est plus efficace que H.264. En utilisant l'accélération matérielle, sélectionnez seulement les codecs que l'accélérateur peut encoder. En utilisant le transcodage logiciel, notez que H.264 est plus rapide que AV1, qui est plus rapide que HEVC.",
"transcoding_reference_frames": "Trames de référence",

View File

@@ -26,7 +26,6 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
const String kShareDownloadGroup = 'group_share';
// Timeline constants
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
@@ -685,16 +684,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
}
List<Bucket> _generateBuckets(int count) {
final buckets = List.filled(
(count / kTimelineNoneSegmentSize).ceil(),
const Bucket(assetCount: kTimelineNoneSegmentSize),
);
if (count % kTimelineNoneSegmentSize != 0) {
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
}
return buckets;
}
List<Bucket> _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)];
extension on Expression<DateTime> {
Expression<String> dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) {

View File

@@ -99,6 +99,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case TimelineReloadEvent():
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
if (asset != _asset) {
_isZoomed = false;
_viewer.setZoomed(false);
setState(() => _asset = asset);
}
default:

View File

@@ -81,6 +81,12 @@ class AssetViewer extends ConsumerStatefulWidget {
}
class _AssetViewerState extends ConsumerState<AssetViewer> {
static const _viewerOverlayStyle = SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.light,
);
late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
late final _pageController = PageController(initialPage: widget.initialIndex);
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
@@ -280,49 +286,52 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_setSystemUIMode(controls, details);
});
return Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
return AnnotatedRegion(
value: _viewerOverlayStyle,
child: Scaffold(
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: false,
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: IgnorePointer(
ignoring: !showingControls,
child: AnimatedOpacity(
opacity: showingControls ? 1.0 : 0.0,
duration: Durations.short2,
child: const DownloadStatusFloatingButton(),
),
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
bottomNavigationBar: const ViewerBottomAppBar(),
body: Stack(
children: [
NotificationListener<ScrollEndNotification>(
onNotification: _onScrollEnd,
child: PhotoViewGestureDetectorScope(
axis: Axis.horizontal,
child: PageView.builder(
controller: _pageController,
physics: isZoomed
? const NeverScrollableScrollPhysics()
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
),
),
],
if (!CurrentPlatform.isIOS)
IgnorePointer(
child: AnimatedContainer(
duration: Durations.short2,
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
height: context.padding.top,
),
),
],
),
),
);
}

View File

@@ -317,6 +317,8 @@ class BackgroundUploadService {
priority: priority,
isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi,
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
fields: entity.isLivePhoto ? {'visibility': api.AssetVisibility.hidden.value} : null,
cloudId: entity.isLivePhoto ? null : asset.cloudId,
adjustmentTime: entity.isLivePhoto ? null : asset.adjustmentTime?.toIso8601String(),
latitude: entity.isLivePhoto ? null : asset.latitude?.toString(),
@@ -336,8 +338,7 @@ class BackgroundUploadService {
return null;
}
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
final fields = {'livePhotoVideoId': livePhotoVideoId};
final requiresWiFi = _shouldRequireWiFi(asset);
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
@@ -100,7 +101,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
}
@@ -126,7 +127,7 @@ class ForegroundUploadService {
continue;
}
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
await uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
}
@@ -143,7 +144,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
processItem: (asset) => uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
@@ -233,7 +234,8 @@ class ForegroundUploadService {
await Future.wait(workerFutures);
}
Future<void> _uploadSingleAsset(
@visibleForTesting
Future<void> uploadSingleAsset(
LocalAsset asset,
Completer<void>? cancelToken, {
required UploadCallbacks callbacks,

View File

@@ -393,10 +393,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -496,10 +496,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -472,10 +472,8 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [

View File

@@ -1,3 +1,4 @@
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -7,3 +8,5 @@ class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
class MockPartnerApiRepository extends Mock implements PartnerApiRepository {}
class MockConnectivityApi extends Mock implements ConnectivityApi {}

View File

@@ -0,0 +1,91 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import '../../../fixtures/asset.stub.dart';
/// Timeline displaying a single asset that we can swap
class _SwappableTimelineService extends TimelineService {
BaseAsset asset;
_SwappableTimelineService(this.asset)
: super((
assetSource: (_, __) async => [],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: TimelineOrigin.main,
));
@override
int get totalAssets => 1;
@override
BaseAsset? getAssetSafe(int index) => index == 0 ? asset : null;
}
class _ZoomedNotifier extends AssetViewerStateNotifier {
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: LocalAssetStub.image1, isZoomed: true);
}
}
void main() {
testWidgets('resets zoom when the displayed asset is replaced on reload', (tester) async {
final timeline = _SwappableTimelineService(LocalAssetStub.image1);
final container = ProviderContainer(
overrides: [
timelineServiceProvider.overrideWithValue(timeline),
assetViewerProvider.overrideWith(_ZoomedNotifier.new),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: Builder(
builder: (context) => MaterialApp(
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: const Material(child: AssetPage(index: 0, heroOffset: 0)),
),
),
),
),
);
await tester.pump(const Duration(milliseconds: 600));
// Ignore any image load errors
tester.takeException();
expect(container.read(assetViewerProvider).isZoomed, isTrue);
// Swap the asset, simulating a delete
timeline.asset = LocalAssetStub.image2;
EventStream.shared.emit(const TimelineReloadEvent());
await tester.idle();
await tester.pump();
tester.takeException();
expect(container.read(assetViewerProvider).isZoomed, isFalse);
});
}

View File

@@ -0,0 +1,85 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import '../../../fixtures/asset.stub.dart';
class _SeededAssetViewerNotifier extends AssetViewerStateNotifier {
@override
AssetViewerState build() {
super.build();
return AssetViewerState(currentAsset: LocalAssetStub.image1);
}
}
TimelineService _stubTimelineService() {
return TimelineService((
assetSource: (index, count) async => [LocalAssetStub.image1],
bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]),
origin: TimelineOrigin.main,
));
}
void main() {
testWidgets('status bar icons are light while the asset viewer is open in light mode', (tester) async {
// Emulate arriving from a light-themed page whose AppBar set dark status
// bar icons (the state the viewer is opened from in light mode).
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
await tester.pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWithValue(_stubTimelineService()),
assetViewerProvider.overrideWith(_SeededAssetViewerNotifier.new),
],
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: const Material(child: AssetViewer(initialIndex: 0)),
),
),
),
),
);
// Let post-frame callbacks and the initial frames run without waiting for
// infinite animations (loading spinners) to settle.
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
// Asset thumbnails cannot load in the test environment (no platform
// channels / real HTTP). Those image errors are irrelevant to the system
// chrome behaviour under test, so drain them.
tester.takeException();
expect(
SystemChrome.latestStyle?.statusBarIconBrightness,
Brightness.light,
reason:
'The asset viewer draws over a black background, so status bar '
'icons must be light regardless of the app theme',
);
});
}

View File

@@ -118,8 +118,24 @@ void main() {
expect(task, isNotNull);
// For live photos, extension should be changed to match the video file
expect(task!.fields['filename'], equals('OriginalLivePhoto.mov'));
expect(task.fields['visibility'], equals('hidden'));
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
test('should not set visibility for a regular photo', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/file.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 => 'Regular.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.fields.containsKey('visibility'), isFalse);
});
});
group('getLivePhotoUploadTask', () {
@@ -140,7 +156,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
expect(task.fields['visibility'], equals('hidden'));
expect(task.fields.containsKey('visibility'), isFalse);
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
});
@@ -334,7 +350,7 @@ void main() {
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123'));
expect(task.fields['visibility'], equals('hidden'));
expect(task.fields.containsKey('visibility'), isFalse);
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));

View File

@@ -0,0 +1,128 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../api.mocks.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
void main() {
late ForegroundUploadService sut;
late MockUploadRepository mockUploadRepository;
late MockStorageRepository mockStorageRepository;
late MockDriftBackupRepository mockBackupRepository;
late MockConnectivityApi mockConnectivityApi;
late MockAssetMediaRepository mockAssetMediaRepository;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(MethodCall methodCall) async => 'test',
);
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await SettingsRepository.ensureInitialized(db);
await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app');
await Store.put(StoreKey.deviceId, 'device-id');
registerFallbackValue(File('file'));
registerFallbackValue(<String, String>{});
});
setUp(() {
mockUploadRepository = MockUploadRepository();
mockStorageRepository = MockStorageRepository();
mockBackupRepository = MockDriftBackupRepository();
mockConnectivityApi = MockConnectivityApi();
mockAssetMediaRepository = MockAssetMediaRepository();
sut = ForegroundUploadService(
mockUploadRepository,
mockStorageRepository,
mockBackupRepository,
mockConnectivityApi,
mockAssetMediaRepository,
);
});
List<Map<String, String>> captureFields() {
final captured = <Map<String, 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 {
final fields = invocation.namedArguments[#fields] as Map<String, String>;
captured.add(Map.of(fields));
return UploadResult.success(remoteAssetId: 'remote-${captured.length}');
});
return captured;
}
group('uploadSingleAsset', () {
test('should upload the motion part hidden and keep the still image visible', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/still.heic');
final videoFile = File('/path/to/motion.mov');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => videoFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'live.heic');
final captured = captureFields();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(captured, hasLength(2));
expect(captured[0]['visibility'], equals('hidden'));
expect(captured[0].containsKey('livePhotoVideoId'), isFalse);
expect(captured[1].containsKey('visibility'), isFalse);
expect(captured[1]['livePhotoVideoId'], equals('remote-1'));
});
test('should not set visibility for a regular photo', () async {
final asset = LocalAssetStub.image1;
final mockEntity = MockAssetEntity();
final stillFile = File('/path/to/photo.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 => 'photo.jpg');
final captured = captureFields();
await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks());
expect(captured, hasLength(1));
expect(captured[0].containsKey('visibility'), isFalse);
});
});
}

View File

@@ -7139,9 +7139,9 @@
"in": "query",
"description": "Filter by date",
"schema": {
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"example": "2024-01-01T00:00:00.000Z",
"format": "date",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
"example": "2024-01-01",
"type": "string"
}
},
@@ -7308,9 +7308,9 @@
"in": "query",
"description": "Filter by date",
"schema": {
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"example": "2024-01-01T00:00:00.000Z",
"format": "date",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
"example": "2024-01-01",
"type": "string"
}
},

View File

@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder
FROM ghcr.io/immich-app/base-server-dev:202607132058@sha256:968709a3668167f11ef8ece5962e72ea8a4231e841f98bd5e0f35da4cc295cc7 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7
FROM ghcr.io/immich-app/base-server-prod:202607132058@sha256:85cca651310842265015f26a6710bb7420a54a8fd1ec22d519b7b620f5f93bb1
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
FROM ghcr.io/immich-app/base-server-dev:202607132058@sha256:968709a3668167f11ef8ece5962e72ea8a4231e841f98bd5e0f35da4cc295cc7 AS dev
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise

View File

@@ -4,13 +4,13 @@ import { HistoryBuilder } from 'src/decorators';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandomSchema, MemoryType, MemoryTypeSchema } from 'src/enum';
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import { isoDatetimeToDate, isoDateToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import z from 'zod';
const MemorySearchSchema = z
.object({
type: MemoryTypeSchema.optional(),
for: isoDatetimeToDate.optional().describe('Filter by date'),
for: isoDateToDate.optional().describe('Filter by date'),
isTrashed: stringToBool.optional().describe('Include trashed memories'),
isSaved: stringToBool.optional().describe('Filter by saved status'),
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),

View File

@@ -411,7 +411,7 @@ export class MediaRepository {
}
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata();
const { width = 0, height = 0, hasAlpha = false } = await sharp(input, { unlimited: true }).metadata();
return { width, height, isTransparent: hasAlpha };
}

View File

@@ -0,0 +1,16 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// (#29836) Reset the visibility of any still images that are hidden and have a motion part
await sql`
UPDATE "asset"
SET "visibility" = 'timeline'
WHERE "type" = 'IMAGE'
AND "visibility" = 'hidden'
AND "livePhotoVideoId" IS NOT NULL
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the previous 'hidden' value was itself the bug.
}

View File

@@ -663,6 +663,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.update.mockResolvedValue();
await sut.updateUser(AuthFactory.create(owner), album.id, user.id, { role: AlbumUserRole.Viewer });

View File

@@ -335,6 +335,14 @@ export class AlbumService extends BaseService {
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, userId, { withAssets: false });
const owner = album.albumUsers[0];
if (owner.user.id === userId) {
throw new BadRequestException('User is owner');
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}

View File

@@ -2232,6 +2232,26 @@ describe(MediaService.name, () => {
);
});
it('should include hevc tag when target is hevc and using hwa', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR10 });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: {
targetVideoCodec: VideoCodec.Hevc,
accel: TranscodeHardwareAcceleration.Nvenc,
},
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v', 'hevc_nvenc', '-tag:v', 'hvc1']),
twoPass: false,
}),
);
});
it('should copy audio stream when audio matches target', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.audioStreamAac });
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } });

View File

@@ -118,7 +118,11 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
const items = await this.searchRepository.searchRandom(dto.size || 250, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
});
return items.map((item) => mapAsset(item, { auth }));
}

View File

@@ -241,7 +241,8 @@ export class BaseConfig implements VideoCodecSWConfig {
options.push('-keyint_min', `${this.getGopSize()}`);
}
}
const isHvc = (videoCodec === 'copy' ? videoStream.codecName : videoCodec) === VideoCodec.Hevc;
const isHvc =
this.config.targetVideoCodec === VideoCodec.Hevc && (videoCodec !== 'copy' || videoStream.codecName === 'hevc');
if (isHvc) {
options.push('-tag:v', 'hvc1');
}

View File

@@ -196,4 +196,19 @@ describe(SearchService.name, () => {
expect(suggestions).toEqual(['Canon', null]);
});
});
describe('searchRandom', () => {
it('should filter out locked assets in a default session', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchRandom(auth, {});
expect(response.length).toBe(0);
});
});
});

View File

@@ -7,6 +7,16 @@ import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import AssetViewerNavBar from './AssetViewerNavBar.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
describe('AssetViewerNavBar component', () => {
const additionalProps = {
preAction: () => {},
@@ -24,15 +34,6 @@ describe('AssetViewerNavBar component', () => {
};
});
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: {
init: vi.fn(),
loadFeatureFlags: vi.fn(),
value: { smartSearch: true, trash: true },
} as never,
};
});
});
afterEach(() => {

View File

@@ -4,16 +4,14 @@ import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });

View File

@@ -4,6 +4,11 @@ import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
vi.hoisted(() => {
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
@@ -26,10 +31,6 @@ vi.hoisted(() => {
describe('Thumbnail component', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.mock('$lib/utils/navigation', () => ({
currentUrlReplaceAssetId: vi.fn(),
isSharedLinkRoute: vi.fn().mockReturnValue(false),
}));
});
it('should only contain a single tabbable element (the container)', () => {

View File

@@ -16,7 +16,7 @@ describe('RecentAlbums component', () => {
sdkMock.getAllAlbums.mockResolvedValueOnce([...albums]);
render(RecentAlbums);
expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
expect(sdkMock.getAllAlbums).toHaveBeenCalledOnce();
// wtf
await tick();

View File

@@ -35,7 +35,7 @@ describe('FormatMessage component', () => {
it('throws an error when locale is empty', async () => {
await locale.set(undefined);
expect(() => render(FormatMessage, { key: '' as Translations })).toThrowError();
expect(() => render(FormatMessage, { key: '' as Translations })).toThrow();
await locale.set('en');
});

View File

@@ -3,7 +3,6 @@ import { DateTime } from 'luxon';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { asLocalTimeISO } from '$lib/utils/date-time';
import { toTimelineAsset } from '$lib/utils/timeline-util';
type MemoryIndex = {
@@ -131,7 +130,7 @@ class MemoryManager {
}
private async load() {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
const memories = await searchMemories({ $for: DateTime.now().toFormat('yyyy-MM-dd') });
this.memories = memories.filter((memory) => memory.assets.length > 0);
}

View File

@@ -70,7 +70,7 @@ describe('TimelineManager', () => {
});
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBuckets).toHaveBeenCalledOnce();
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
});
@@ -133,13 +133,13 @@ describe('TimelineManager', () => {
it('loads a month', async () => {
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
it('ignores invalid months', async () => {
await timelineManager.loadTimelineMonth({ year: 2023, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
expect(sdkMock.getTimeBucket).not.toHaveBeenCalled();
});
it('cancels month loading', async () => {
@@ -147,7 +147,7 @@ describe('TimelineManager', () => {
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
month?.cancel();
expect(abortSpy).toBeCalledTimes(1);
expect(abortSpy).toHaveBeenCalledOnce();
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
});
@@ -157,10 +157,10 @@ describe('TimelineManager', () => {
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
timelineManager.loadTimelineMonth({ year: 2024, month: 1 }),
]);
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledOnce();
});
it('allows loading a canceled month', async () => {
@@ -283,7 +283,7 @@ describe('TimelineManager', () => {
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]);
expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(updateAssetsSpy).toHaveBeenCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1);
});
@@ -642,8 +642,8 @@ describe('TimelineManager', () => {
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
const previous = await timelineManager.getLaterAsset(a);
expect(previous).toEqual(b);
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
expect(previousMonthSpy).toBeCalledTimes(0);
expect(loadTimelineMonthSpy).not.toHaveBeenCalled();
expect(previousMonthSpy).not.toHaveBeenCalled();
});
it('skips removed assets', async () => {

View File

@@ -12,7 +12,7 @@
const { trigger, selectedKey, onClose }: Props = $props();
</script>
<BasicModal title={$t('add_step')} {onClose} size="medium">
<BasicModal title={$t('add_step')} onClose={() => onClose()} size="medium">
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />

View File

@@ -2,17 +2,15 @@ import type { ServerConfigDto } from '@immich/sdk';
import { asUrl } from '$lib/services/shared-link.service';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
describe('SharedLinkService', () => {
beforeAll(() => {
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
});
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
serverConfigManager: {
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
init: vi.fn(),
loadServerConfig: vi.fn(),
},
}));
describe('SharedLinkService', () => {
describe('asUrl', () => {
it('should properly encode characters in slug', () => {
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');

View File

@@ -1,6 +1,10 @@
import { writable } from 'svelte/store';
import { getAlbumDateRange, getShortDateRange } from './date-time';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
describe('getShortDateRange', () => {
beforeEach(() => {
vi.stubEnv('TZ', 'UTC');
@@ -41,10 +45,6 @@ describe('getShortDateRange', () => {
describe('getAlbumDate', () => {
beforeAll(() => {
process.env.TZ = 'UTC';
vitest.mock('$lib/stores/preferences.store', () => ({
locale: writable('en'),
}));
});
it('should work with only a start date', () => {

View File

@@ -34,7 +34,7 @@ describe('Executor Queue test', function () {
// The last task will be executed after 200ms and will finish at 400ms
void eq.addTask(() => timeoutPromiseBuilder(200, 'T4'));
expect(finished).not.toBeCalled();
expect(finished).not.toHaveBeenCalled();
expect(started).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(100);