Compare commits

...

11 Commits

Author SHA1 Message Date
Santo Shakil 6501246ce4 feat(mobile): stack original + edited photo on ios
Detect an iOS edit, upload the unedited original, and stack the edited
version on top of it. Reverting in Photos flips back to a single original.
Adds an optional stackParentId field to the asset upload on the server.
2026-05-21 15:54:29 +06:00
Daniel Dietzler 5a61e589e8 chore: always run ci when mise.toml changes and install flutter from aqua (#28521) 2026-05-20 14:43:30 +00:00
renovate[bot] 85192bb110 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.5.11 (#28522) 2026-05-20 14:29:17 +00:00
Timon c7ae97fa2b chore: handle docusaurus deprecation warning (#28516) 2026-05-20 15:27:33 +02:00
Timon 8d02f3625d chore: update mobile makefile command usage instructions (#28514) 2026-05-20 15:26:24 +02:00
bo0tzz a5a7380a26 feat: use lockfile for mise tools (#28503) 2026-05-20 11:37:33 +00:00
renovate[bot] d9ce3d2046 chore(deps): update dependency @types/node to ^24.12.4 (#28490) 2026-05-20 12:41:17 +02:00
renovate[bot] 815ff677fc chore(deps): update github-actions (#28493) 2026-05-19 22:22:44 +00:00
bo0tzz 915d865ce2 chore: use custom sticky-comment action (#28505) 2026-05-19 20:25:46 +00:00
immich-tofu[bot] c28e5f90b6 chore: modify .github/workflows/org-zizmor.yml 2026-05-19 10:45:23 +00:00
Timon 4383473ed6 fix: cleanup nestjs-zod properties (#28447)
* fix: cleanup nestjs-zod properties

* lint
2026-05-18 15:31:08 -04:00
76 changed files with 29550 additions and 339 deletions
+7 -7
View File
@@ -91,7 +91,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -159,14 +159,14 @@ jobs:
- name: Comment APK download link on PR
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'mobile-android-apk'
message: |
id: mobile-android-apk
token: ${{ steps.token.outputs.token }}
body: |
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
Download: ${{ env.APK_URL }}
@@ -216,7 +216,7 @@ jobs:
persist-credentials: false
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -231,7 +231,7 @@ jobs:
run: mise //mobile:codegen:pigeon
- name: Setup Ruby
uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@26ccb332c67a45ca649de9faf60552ef1b8260d9 # v0.0.46
uses: oasdiff/oasdiff-action/breaking@6147a58e5d1249a12f42fc864ab791d571a30015 # v0.0.47
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
# ️ 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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+3 -4
View File
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -213,12 +213,11 @@ jobs:
run: 'mise run //deployment:tf apply'
- name: Comment
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
if: ${{ steps.parameters.outputs.event == 'pr' }}
with:
id: docs-pr-url
token: ${{ steps.token.outputs.token }}
number: ${{ fromJson(needs.checks.outputs.parameters).pr_number }}
body: |
📖 Documentation deployed to [${{ steps.docs-output.outputs.subdomain }}](https://${{ steps.docs-output.outputs.subdomain }})
emojis: 'rocket'
body-include: '<!-- Docs PR URL -->'
+3 -4
View File
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -44,9 +44,8 @@ jobs:
run: 'mise run //deployment:tf destroy -- -refresh=false'
- name: Comment
uses: actions-cool/maintain-one-comment@909842216bc8e8658364c572ec52100f4c2cc50a # v3.3.0
uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
with:
id: docs-pr-url
token: ${{ steps.token.outputs.token }}
number: ${{ github.event.number }}
delete: true
body-include: '<!-- Docs PR URL -->'
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+1
View File
@@ -13,3 +13,4 @@ jobs:
actions: read
contents: read
security-events: write
secrets: inherit
+2 -2
View File
@@ -62,7 +62,7 @@ jobs:
ref: main
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -119,7 +119,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
+12 -12
View File
@@ -19,11 +19,11 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
id: preview-status
token: ${{ steps.token.outputs.token }}
body: 'Deploying preview environment to https://pr-${{ github.event.pull_request.number }}.preview.internal.immich.build/'
remove-label:
runs-on: ubuntu-latest
@@ -48,16 +48,16 @@ jobs:
name: 'preview'
})
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
if: ${{ github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.'
id: preview-status
token: ${{ steps.token.outputs.token }}
body: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
- uses: immich-app/devtools/actions/sticky-comment@0135acd12ad9f3369b94a2aa3c0ae8c835a4e926 # sticky-comment-action-v1.0.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'Preview environment has been removed.'
id: preview-status
token: ${{ steps.token.outputs.token }}
body: 'Preview environment has been removed.'
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+20 -13
View File
@@ -30,25 +30,32 @@ jobs:
filters: |
i18n:
- 'i18n/**'
- 'mise.toml'
web:
- 'web/**'
- 'i18n/**'
- 'packages/sdk/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
server:
- 'server/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
e2e:
- 'e2e/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
mobile:
- 'mobile/**'
- 'mise.toml'
machine-learning:
- 'machine-learning/**'
- 'mise.toml'
.github:
- '.github/**'
force-filters: |
@@ -76,7 +83,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -107,7 +114,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -138,7 +145,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -182,7 +189,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -220,7 +227,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -248,7 +255,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -298,7 +305,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -331,7 +338,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -550,7 +557,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -587,7 +594,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -618,7 +625,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -669,7 +676,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
@@ -727,7 +734,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
+65
View File
@@ -0,0 +1,65 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.opentofu]]
version = "1.11.6"
backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-x64"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
[tools.opentofu."platforms.macos-x64"]
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
[tools.opentofu."platforms.windows-x64"]
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
[[tools.terragrunt]]
version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
+3 -1
View File
@@ -10,7 +10,6 @@ const config = {
url: 'https://docs.immich.app',
baseUrl: '/',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
favicon: 'img/favicon.png',
// GitHub pages deployment config.
@@ -29,6 +28,9 @@ const config = {
// Mermaid diagrams
markdown: {
mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},
themes: ['@docusaurus/theme-mermaid'],
+5
View File
@@ -0,0 +1,5 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.66.0"
backend = "npm:wrangler"
+1 -1
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
+72
View File
@@ -0,0 +1,72 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.python]]
version = "3.11.15"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:243f794278eff6adba96ed3677ec6877175df84c25f140e17f09f9be82d0f12a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:52b4c52094ff8b383a45c694acf4c5c0e883152be6d5229a35a8186ce907c6eb"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:171dffd8c0f66e8a0725364a7428015b22fc18dd298b24f541392e17dd0e561f"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:2ac90fef8917ebd14826a6d667593a06cf0ae5f745ba9b1147dc086dd35f5284"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:fdfc363b538662eb7441a14e06f72c4a992c56af7f401f5730ea5081f8f8ad6e"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:5f1eb247cbca2c0ad5ccbf6d299a4f54b31b5c63b492d74c3531dc4344a42f88"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:756d7f148498b8822f6aedf44a020613576f09983161f346ad36dcef6238cdc3"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.uv]]
version = "0.8.15"
backend = "aqua:astral-sh/uv"
[tools.uv."platforms.linux-arm64"]
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.linux-arm64-musl"]
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.linux-x64"]
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.linux-x64-musl"]
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
[tools.uv."platforms.macos-arm64"]
checksum = "sha256:103367962c5cb00bf7370d84cbaa3fec5a9807be9cc833ea9d8eea400c119fa2"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-apple-darwin.tar.gz"
[tools.uv."platforms.macos-x64"]
checksum = "sha256:2bbef70982e97dfc36454de173f35ec1a5e83ae11e3885df6a50db3fd76171cb"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-apple-darwin.tar.gz"
[tools.uv."platforms.windows-x64"]
checksum = "sha256:459d95892a5cc5c21779532f4f41b9238594b79e312a5142da2148ecfa10e705"
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-pc-windows-msvc.zip"
+391
View File
@@ -0,0 +1,391 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.flutter]]
version = "3.41.9-stable"
backend = "asdf:flutter"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
github_attestations = "unavailable"
[[tools."github:extism/cli"]]
version = "1.6.3"
backend = "github:extism/cli"
[tools."github:extism/cli"."platforms.linux-arm64"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.linux-x64"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.linux-x64-musl"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.macos-arm64"]
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694029"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.macos-x64"]
checksum = "sha256:9a2f71b6e6009685a622cc3084e52d2a1a8e23c98d29ffa72e666e9dc699855f"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694026"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.windows-x64"]
checksum = "sha256:47e4ed2782445b2b08a4d1ac127211588f8b4d1fc25fd6481d4cb65151b5213c"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-windows-amd64.zip"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
github_attestations = "unavailable"
[[tools."github:extism/js-pdk"]]
version = "1.6.0"
backend = "github:extism/js-pdk"
[tools."github:extism/js-pdk"."platforms.linux-arm64"]
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.linux-x64"]
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.macos-arm64"]
checksum = "sha256:548e25bda3971a07c32d78a249135cf8cb7b3eede101e878e06e53e01ac2e0ce"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-macos-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223215"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.macos-x64"]
checksum = "sha256:d85a875c2a071f0c29fe572764c52c3a499f157ab7f9efac8939a4364390e29b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-macos-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223239"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.windows-x64"]
checksum = "sha256:97b7b746141e4777e1ca2b76febdeb16dc9d314ff6a4257df05a476b67228acc"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-windows-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
github_attestations = "unavailable"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[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"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
github_attestations = "unavailable"
[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"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
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"
github_attestations = "unavailable"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
[tools."github:webassembly/binaryen"."platforms.linux-arm64"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.linux-x64"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926134"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.macos-x64"]
checksum = "sha256:b389bb0731758d86c3cb266d01d28a12725c23bd3cabc3df34faa162af0887e9"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-macos.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926135"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.windows-x64"]
checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2203"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
github_attestations = "unavailable"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.15.0"
backend = "core:node"
[tools.node."platforms.linux-arm64"]
checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:31e98aa960a067da91edffd5d93bc46657b5d2a8029612c359f5f2ac0060152a"
url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64-musl.tar.gz"
[tools.node."platforms.linux-x64"]
checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"]
checksum = "sha256:f55af5bd489c5347b113ca6594cae00a54b30ba57ac5875324311bfc6f4762e3"
url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64-musl.tar.gz"
[tools.node."platforms.macos-arm64"]
checksum = "sha256:372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz"
[tools.node."platforms.macos-x64"]
checksum = "sha256:ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-x64.tar.gz"
[tools.node."platforms.windows-x64"]
checksum = "sha256:cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip"
[[tools."npm:oazapfts"]]
version = "7.5.0"
backend = "npm:oazapfts"
[[tools.opentofu]]
version = "1.11.6"
backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
[tools.opentofu."platforms.linux-x64"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
[tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
[tools.opentofu."platforms.macos-x64"]
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
[tools.opentofu."platforms.windows-x64"]
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
[[tools.pnpm]]
version = "10.33.1"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:909ced0038b00881d4d620ba2018c5d9691de373deea8e3c84b722b44324e47c"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-arm64"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:afdad60b83f4f482f4c95cc79325f29aef776d0922a324f023a312f40e0cc7d3"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-x64"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:67b23fd8c6800566b1cc04c446b170ff6e7977250084e4d8df9bfdbd8e6f4d02"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-win-x64.exe"
[[tools.terragrunt]]
version = "1.0.3"
backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
[tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
[tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
[tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
[tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
+5 -4
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
flutter = "3.41.9"
"aqua:flutter/flutter" = "3.41.9"
pnpm = "10.33.1"
terragrunt = "1.0.3"
opentofu = "1.11.6"
@@ -50,11 +50,12 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[settings]
experimental = true
pin = true
lockfile = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build"
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
]
[tasks.open-api-typescript]
@@ -76,8 +77,8 @@ run = [
{ task = "//server:install" },
{ task = "//server:build" },
{ task = "//server:sync-open-api" },
{ task = ":open-api-typescript"},
{ task = ":open-api-dart"},
{ task = ":open-api-typescript" },
{ task = ":open-api-dart" },
]
[tasks.sql]
+7
View File
@@ -89,6 +89,13 @@ flutter {
}
dependencies {
constraints {
implementation("androidx.glance:glance-appwidget") {
version { strictly libs.versions.glance.get() }
because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
}
}
implementation libs.okhttp
implementation libs.cronet.embedded
implementation libs.media3.datasource.okhttp
@@ -207,6 +207,18 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
}
}
enum class EditState(val raw: Int) {
NOT_EDITED(0),
EDITED(1),
UNKNOWN(2);
companion object {
fun ofRaw(raw: Int): EditState? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlatformAsset (
val id: String,
@@ -472,6 +484,52 @@ data class CloudIdResult (
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class BaseResource (
val path: String,
val sha1: String,
val sizeBytes: Long,
val mimeType: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
val path = pigeonVar_list[0] as String
val sha1 = pigeonVar_list[1] as String
val sizeBytes = pigeonVar_list[2] as Long
val mimeType = pigeonVar_list[3] as String
return BaseResource(path, sha1, sizeBytes, mimeType)
}
}
fun toList(): List<Any?> {
return listOf(
path,
sha1,
sizeBytes,
mimeType,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as BaseResource
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1) && MessagesPigeonUtils.deepEquals(this.sizeBytes, other.sizeBytes) && MessagesPigeonUtils.deepEquals(this.mimeType, other.mimeType)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sizeBytes)
result = 31 * result + MessagesPigeonUtils.deepHash(this.mimeType)
return result
}
}
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -481,30 +539,40 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it)
return (readValue(buffer) as Long?)?.let {
EditState.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it)
PlatformAsset.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
PlatformAlbum.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
SyncDelta.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BaseResource.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -514,26 +582,34 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
is PlatformAsset -> {
is EditState -> {
stream.write(130)
writeValue(stream, value.toList())
writeValue(stream, value.raw.toLong())
}
is PlatformAlbum -> {
is PlatformAsset -> {
stream.write(131)
writeValue(stream, value.toList())
}
is SyncDelta -> {
is PlatformAlbum -> {
stream.write(132)
writeValue(stream, value.toList())
}
is HashResult -> {
is SyncDelta -> {
stream.write(133)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
is HashResult -> {
stream.write(134)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(135)
writeValue(stream, value.toList())
}
is BaseResource -> {
stream.write(136)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -554,6 +630,8 @@ interface NativeSyncApi {
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
companion object {
/** The codec used by NativeSyncApi. */
@@ -764,6 +842,48 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -453,4 +453,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList()
}
// Android has no Photos-style edit original to stack; iOS-only.
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
callback(Result.success(null))
}
// iOS-only; Android assets never carry a Photos-style edit.
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
callback(Result.success(EditState.NOT_EDITED))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+118 -10
View File
@@ -183,6 +183,12 @@ enum PlatformAssetPlaybackStyle: Int {
case videoLooping = 5
}
enum EditState: Int {
case notEdited = 0
case edited = 1
case unknown = 2
}
/// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable {
var id: String
@@ -458,6 +464,52 @@ struct CloudIdResult: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct BaseResource: Hashable {
var path: String
var sha1: String
var sizeBytes: Int64
var mimeType: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
let path = pigeonVar_list[0] as! String
let sha1 = pigeonVar_list[1] as! String
let sizeBytes = pigeonVar_list[2] as! Int64
let mimeType = pigeonVar_list[3] as! String
return BaseResource(
path: path,
sha1: sha1,
sizeBytes: sizeBytes,
mimeType: mimeType
)
}
func toList() -> [Any?] {
return [
path,
sha1,
sizeBytes,
mimeType,
]
}
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
if Swift.type(of: lhs) != Swift.type(of: rhs) {
return false
}
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1) && deepEqualsMessages(lhs.sizeBytes, rhs.sizeBytes) && deepEqualsMessages(lhs.mimeType, rhs.mimeType)
}
func hash(into hasher: inout Hasher) {
hasher.combine("BaseResource")
deepHashMessages(value: path, hasher: &hasher)
deepHashMessages(value: sha1, hasher: &hasher)
deepHashMessages(value: sizeBytes, hasher: &hasher)
deepHashMessages(value: mimeType, hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -468,15 +520,23 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
}
return nil
case 130:
return PlatformAsset.fromList(self.readValue() as! [Any?])
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return EditState(rawValue: enumResultAsInt)
}
return nil
case 131:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
return PlatformAsset.fromList(self.readValue() as! [Any?])
case 132:
return SyncDelta.fromList(self.readValue() as! [Any?])
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 133:
return HashResult.fromList(self.readValue() as! [Any?])
return SyncDelta.fromList(self.readValue() as! [Any?])
case 134:
return HashResult.fromList(self.readValue() as! [Any?])
case 135:
return CloudIdResult.fromList(self.readValue() as! [Any?])
case 136:
return BaseResource.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -488,21 +548,27 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
if let value = value as? PlatformAssetPlaybackStyle {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
} else if let value = value as? EditState {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
} else if let value = value as? PlatformAlbum {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
} else if let value = value as? SyncDelta {
super.writeByte(133)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
} else if let value = value as? HashResult {
super.writeByte(134)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(135)
super.writeValue(value.toList())
} else if let value = value as? BaseResource {
super.writeByte(136)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -538,6 +604,8 @@ protocol NativeSyncApi {
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -738,5 +806,45 @@ class NativeSyncApiSetup {
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
let getBaseResourceChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getBaseResourceChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getBaseResourceChannel.setMessageHandler(nil)
}
let getEditStateChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getEditStateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getEditStateChannel.setMessageHandler(nil)
}
}
}
+132
View File
@@ -1,5 +1,6 @@
import Photos
import CryptoKit
import UniformTypeIdentifiers
struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset
@@ -415,4 +416,135 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return mappings;
}
func getBaseResource(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<BaseResource?, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
let resources = PHAssetResource.assetResources(for: asset)
let state = await Self.classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
guard state == .edited, let original = resources.first(where: { $0.type == .photo }) else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
do {
let result = try await self.streamBaseResource(
resource: original,
localId: asset.localIdentifier,
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(result))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
// Returns whether the asset carries a live Photos edit without reading the photo
// itself, only the small adjustment metadata. The revert probe relies on this to
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
// mistakes an unreadable edit for a revert.
func getEditState(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<EditState, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
// Not in the library don't let the caller act on a "not edited" answer.
return self.completeWhenActive(for: completion, with: .success(.unknown))
}
let state = await Self.classifyEdit(
resources: PHAssetResource.assetResources(for: asset),
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(state))
}
}
// adjustmentRenderTypes value of a photo with no live user edit a plain
// capture (incl. Photographic Style) or a reverted edit. Any real edit moves it.
private static let kNoEditRenderTypes = 27648
// Classifies an asset's edit state from its Adjustments.plist, reading only the
// adjustment metadata (not the photo). A Photos edit is authored by
// com.apple.mobileslideshow (or a third-party editor) and moves the render
// pipeline off the no-edit baseline; a capture (including a Photographic Style)
// is authored by com.apple.camera, and a revert keeps the Photos editor id but
// restores the baseline render types so requiring both excludes reverts.
// Cleanup/object-removal stays camera-attributed but writes
// AdjustmentsSecondary.data, the fallback. `.unknown` = the adjustment data
// couldn't be read (e.g. offloaded with network disallowed).
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
return .edited
}
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
return .notEdited
}
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
else {
return .unknown
}
let editor = plist["adjustmentEditorBundleID"] as? String
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
let isUserEdit = editor != nil && editor != "com.apple.camera" && renderTypes != kNoEditRenderTypes
return isUserEdit ? .edited : .notEdited
}
private func streamBaseResource(
resource: PHAssetResource,
localId: String,
allowNetworkAccess: Bool
) async throws -> BaseResource {
guard let data = await Self.collectResourceData(resource, allowNetworkAccess: allowNetworkAccess) else {
throw NSError(
domain: "NativeSyncApi",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to read base resource for \(localId)"]
)
}
let safeId = localId.replacingOccurrences(of: "/", with: "_")
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("immich_base", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let unique = UUID().uuidString.prefix(8)
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
try data.write(to: tempUrl, options: .atomic)
let sha1 = Data(Insecure.SHA1.hash(data: data)).base64EncodedString()
let mime = UTType(resource.uniformTypeIdentifier)?.preferredMIMEType ?? "application/octet-stream"
return BaseResource(path: tempUrl.path, sha1: sha1, sizeBytes: Int64(data.count), mimeType: mime)
}
private static func collectResourceData(
_ resource: PHAssetResource,
allowNetworkAccess: Bool
) async -> Data? {
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
var buffer = Data()
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { data in buffer.append(data) },
completionHandler: { error in continuation.resume(returning: error == nil ? buffer : nil) }
)
}
}
}
+1
View File
@@ -20,6 +20,7 @@ const String kSecuredPinCode = "secured_pin_code";
const String kManualUploadGroup = 'manual_upload_group';
const String kBackupGroup = 'backup_group';
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
const String kBackupEditPairGroup = 'backup_edit_pair_group';
const String kDownloadGroupImage = 'group_image';
const String kDownloadGroupVideo = 'group_video';
const String kDownloadGroupLivePhoto = 'group_livephoto';
@@ -12,6 +12,13 @@ class LocalAsset extends BaseAsset {
final double? latitude;
final double? longitude;
// Remote id of this asset's previous upload; used to stack a new edit under it.
final String? priorRemoteId;
// Local checksum at the last sync action; lets backup skip an already-handled
// local whose current render hashes fresh (the iOS revert case).
final String? syncedChecksum;
const LocalAsset({
required this.id,
String? remoteId,
@@ -32,6 +39,8 @@ class LocalAsset extends BaseAsset {
this.latitude,
this.longitude,
required super.isEdited,
this.priorRemoteId,
this.syncedChecksum,
}) : remoteAssetId = remoteId;
@override
@@ -120,6 +129,8 @@ class LocalAsset extends BaseAsset {
double? latitude,
double? longitude,
bool? isEdited,
String? priorRemoteId,
String? syncedChecksum,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -140,6 +151,8 @@ class LocalAsset extends BaseAsset {
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
);
}
}
@@ -0,0 +1,130 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
/// Handles the iOS revert case: the user reverted an edit in Photos, so the local
/// is no longer an edit but was already uploaded as one. Flips the stack primary
/// back to the original base (found via prior_remote_id), deletes the edit members,
/// and marks the local handled so it isn't re-uploaded.
class EditRevertService {
final NativeSyncApi _nativeSyncApi;
final DriftStackRepository _stackRepository;
final DriftLocalAssetRepository _localAssetRepository;
final AssetApiRepository _assetApiRepository;
final _log = Logger('EditRevertService');
EditRevertService({
required NativeSyncApi nativeSyncApi,
required DriftStackRepository stackRepository,
required DriftLocalAssetRepository localAssetRepository,
required AssetApiRepository assetApiRepository,
}) : _nativeSyncApi = nativeSyncApi,
_stackRepository = stackRepository,
_localAssetRepository = localAssetRepository,
_assetApiRepository = assetApiRepository;
/// Returns true if the asset was a revert and was handled (caller skips the
/// upload); false to fall through to the normal upload path.
Future<bool> tryDedupRevert(LocalAsset asset) async {
if (asset.priorRemoteId == null) {
return false;
}
// Only a confirmed "no live edit" counts as a revert. `edited` means a fresh
// edit, so bail and let the pair flow handle it. `unknown` means the native
// side couldn't read the adjustment (e.g. the asset is offloaded to iCloud and
// we didn't allow network); bail there too rather than risk treating an edit we
// simply couldn't read as a revert and deleting it. allowNetworkAccess=false
// keeps this a cheap, offline metadata read.
try {
final editState = await _nativeSyncApi.getEditState(asset.id, allowNetworkAccess: false);
if (editState != EditState.notEdited) {
return false;
}
} catch (error, stack) {
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
return false;
}
// No live edit, but this local was uploaded as an edit before → revert. iOS
// re-encodes a reverted render to fresh bytes that match nothing remote, so we
// never try to checksum-match. Flip the stack by structure: prior_remote_id is
// the latest edit (the stack primary); flip back to the stack's original base.
final String stackId;
final String baseId;
try {
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
if (foundStack == null) {
return false;
}
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
if (base == null) {
return false;
}
stackId = foundStack;
baseId = base;
} catch (error, stack) {
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
return false;
}
try {
await _assetApiRepository.setStackPrimary(stackId, baseId);
await _stackRepository.setPrimary(stackId, baseId);
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum ?? '');
} catch (error, stack) {
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
return false;
}
await _tearDownStackAfterFlip(stackId, baseId);
return true;
}
/// Finishes what [HashService] started: when a local checksum directly matched
/// a non-primary stack member and the primary was already flipped, drop the
/// other (now-stale) members.
Future<void> finishReconcile({required String stackId, required String newPrimaryId}) async {
await _tearDownStackAfterFlip(stackId, newPrimaryId);
}
// Trash the other stack members on the server, unstack, and mirror the result
// locally. Each step is best-effort — the flipped primary is the load-bearing
// change; the cleanup just tidies up.
Future<void> _tearDownStackAfterFlip(String stackId, String keepId) async {
final List<String> toDelete;
try {
toDelete = await _stackRepository.getOtherMemberIds(stackId, keepId);
} catch (error, stack) {
_log.warning("getOtherMemberIds failed for $stackId", error, stack);
return;
}
if (toDelete.isNotEmpty) {
try {
// Move the stale edit members to the trash rather than deleting outright,
// so a revert stays recoverable on the server (force: false = trash).
await _assetApiRepository.delete(toDelete, false);
} catch (error, stack) {
_log.warning("trashing reverted edit members failed for $toDelete", error, stack);
}
}
try {
await _assetApiRepository.unStack([stackId]);
} catch (error, stack) {
_log.warning("unstack failed for $stackId", error, stack);
}
try {
await _stackRepository.applyRevertCleanup(stackId, toDelete);
} catch (error, stack) {
_log.warning("local cleanup failed for $stackId", error, stack);
}
}
}
+64 -6
View File
@@ -2,11 +2,14 @@ import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
const String _kHashCancelledCode = "HASH_CANCELLED";
@@ -17,6 +20,9 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftStackRepository _stackRepository;
final AssetApiRepository _assetApiRepository;
final EditRevertService _editRevertService;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -25,6 +31,9 @@ class HashService {
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
required DriftStackRepository stackRepository,
required AssetApiRepository assetApiRepository,
required EditRevertService editRevertService,
bool Function()? cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
@@ -32,6 +41,9 @@ class HashService {
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_stackRepository = stackRepository,
_assetApiRepository = assetApiRepository,
_editRevertService = editRevertService,
_batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -45,6 +57,7 @@ class HashService {
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();
final hashedIds = <String>{};
for (final album in localAlbums) {
if (isCancelled) {
@@ -54,7 +67,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash);
await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
}
}
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
@@ -62,9 +75,15 @@ class HashService {
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) {
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
}
}
// iOS revert reconcile: a reverted edit re-hashes back to the original's
// bytes, which already exist as the stack base — flip the primary to it.
if (CurrentPlatform.isIOS && hashedIds.isNotEmpty && !isCancelled) {
await _reconcileReverts(hashedIds);
}
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
@@ -81,7 +100,12 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
Future<void> _hashAssets(
LocalAlbum album,
List<LocalAsset> assetsToHash, {
bool isTrashed = false,
required Set<String> hashedIds,
}) async {
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
@@ -92,16 +116,21 @@ class HashService {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash, isTrashed);
await _processBatch(album, toHash, isTrashed, hashedIds);
toHash.clear();
}
}
await _processBatch(album, toHash, isTrashed);
await _processBatch(album, toHash, isTrashed, hashedIds);
}
/// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
Future<void> _processBatch(
LocalAlbum album,
Map<String, LocalAsset> toHash,
bool isTrashed,
Set<String> hashedIds,
) async {
if (toHash.isEmpty) {
return;
}
@@ -141,5 +170,34 @@ class HashService {
} else {
await _localAssetRepository.updateHashes(hashed);
}
hashedIds.addAll(hashed.keys);
}
Future<void> _reconcileReverts(Set<String> localIds) async {
final List<StackReconcileTarget> targets;
try {
targets = await _stackRepository.findRevertReconcileTargets(localIds);
} catch (error, stack) {
_log.warning("findRevertReconcileTargets failed", error, stack);
return;
}
for (final target in targets) {
try {
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
// Roll priorRemoteId forward to the new primary (the matched member) so a
// later edit stacks onto THAT instead of the soon-deleted old primary.
await _localAssetRepository.markSynced(
target.localAssetId,
priorRemoteId: target.newPrimaryId,
syncedChecksum: target.localAssetChecksum,
);
} catch (error, stack) {
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
continue;
}
await _editRevertService.finishReconcile(stackId: target.stackId, newPrimaryId: target.newPrimaryId);
}
}
}
@@ -186,6 +186,22 @@ class BackgroundSyncManager {
});
}
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
/// joins an in-flight sync whose snapshot can pre-date a just-received change
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
/// first, then run a fresh one.
Future<void> runFreshRemoteSync() async {
final inflight = _syncTask;
if (inflight != null) {
try {
await inflight.future;
} catch (_) {
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
}
}
await syncRemote();
}
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
@@ -27,6 +27,14 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
// remote id of the previous upload (iOS edit-pair stacking)
TextColumn get priorRemoteId => text().nullable()();
// local checksum at the last sync action. Lets the backup query skip a local
// whose current hash matches nothing remote but is still "handled" — the iOS
// revert case, where the reverted render hashes fresh but is already reconciled.
TextColumn get syncedChecksum => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -51,5 +59,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
longitude: longitude,
cloudId: iCloudId,
isEdited: false,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
);
}
@@ -26,6 +26,8 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -45,6 +47,8 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
class $$LocalAssetEntityTableFilterComposer
@@ -141,6 +145,16 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$LocalAssetEntityTableOrderingComposer
@@ -231,6 +245,16 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$LocalAssetEntityTableAnnotationComposer
@@ -300,6 +324,16 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.playbackStyle,
builder: (column) => column,
);
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => column,
);
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => column,
);
}
class $$LocalAssetEntityTableTableManager
@@ -359,6 +393,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -376,6 +412,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
createCompanionCallback:
({
@@ -396,6 +434,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -413,6 +453,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -637,6 +679,28 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
).withConverter<i2.AssetPlaybackStyle>(
i1.$LocalAssetEntityTable.$converterplaybackStyle,
);
static const i0.VerificationMeta _priorRemoteIdMeta =
const i0.VerificationMeta('priorRemoteId');
@override
late final i0.GeneratedColumn<String> priorRemoteId =
i0.GeneratedColumn<String>(
'prior_remote_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _syncedChecksumMeta =
const i0.VerificationMeta('syncedChecksum');
@override
late final i0.GeneratedColumn<String> syncedChecksum =
i0.GeneratedColumn<String>(
'synced_checksum',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -655,6 +719,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -759,6 +825,24 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
);
}
if (data.containsKey('prior_remote_id')) {
context.handle(
_priorRemoteIdMeta,
priorRemoteId.isAcceptableOrUnknown(
data['prior_remote_id']!,
_priorRemoteIdMeta,
),
);
}
if (data.containsKey('synced_checksum')) {
context.handle(
_syncedChecksumMeta,
syncedChecksum.isAcceptableOrUnknown(
data['synced_checksum']!,
_syncedChecksumMeta,
),
);
}
return context;
}
@@ -839,6 +923,14 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
data['${effectivePrefix}playback_style'],
)!,
),
priorRemoteId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}prior_remote_id'],
),
syncedChecksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}synced_checksum'],
),
);
}
@@ -877,6 +969,8 @@ class LocalAssetEntityData extends i0.DataClass
final double? latitude;
final double? longitude;
final i2.AssetPlaybackStyle playbackStyle;
final String? priorRemoteId;
final String? syncedChecksum;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -894,6 +988,8 @@ class LocalAssetEntityData extends i0.DataClass
this.latitude,
this.longitude,
required this.playbackStyle,
this.priorRemoteId,
this.syncedChecksum,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -938,6 +1034,12 @@ class LocalAssetEntityData extends i0.DataClass
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
);
}
if (!nullToAbsent || priorRemoteId != null) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
}
if (!nullToAbsent || syncedChecksum != null) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
}
return map;
}
@@ -967,6 +1069,8 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
serializer.fromJson<int>(json['playbackStyle']),
),
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
);
}
@override
@@ -993,6 +1097,8 @@ class LocalAssetEntityData extends i0.DataClass
'playbackStyle': serializer.toJson<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
),
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
};
}
@@ -1013,6 +1119,8 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i2.AssetPlaybackStyle? playbackStyle,
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -1032,6 +1140,12 @@ class LocalAssetEntityData extends i0.DataClass
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId.present
? priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: syncedChecksum.present
? syncedChecksum.value
: this.syncedChecksum,
);
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData(
@@ -1061,6 +1175,12 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
priorRemoteId: data.priorRemoteId.present
? data.priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: data.syncedChecksum.present
? data.syncedChecksum.value
: this.syncedChecksum,
);
}
@@ -1082,7 +1202,9 @@ class LocalAssetEntityData extends i0.DataClass
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')'))
.toString();
}
@@ -1105,6 +1227,8 @@ class LocalAssetEntityData extends i0.DataClass
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
);
@override
bool operator ==(Object other) =>
@@ -1125,7 +1249,9 @@ class LocalAssetEntityData extends i0.DataClass
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude &&
other.playbackStyle == this.playbackStyle);
other.playbackStyle == this.playbackStyle &&
other.priorRemoteId == this.priorRemoteId &&
other.syncedChecksum == this.syncedChecksum);
}
class LocalAssetEntityCompanion
@@ -1146,6 +1272,8 @@ class LocalAssetEntityCompanion
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
final i0.Value<String?> priorRemoteId;
final i0.Value<String?> syncedChecksum;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1163,6 +1291,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
});
LocalAssetEntityCompanion.insert({
required String name,
@@ -1181,6 +1311,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id);
@@ -1201,6 +1333,8 @@ class LocalAssetEntityCompanion
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
i0.Expression<int>? playbackStyle,
i0.Expression<String>? priorRemoteId,
i0.Expression<String>? syncedChecksum,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1219,6 +1353,8 @@ class LocalAssetEntityCompanion
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (playbackStyle != null) 'playback_style': playbackStyle,
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
});
}
@@ -1239,6 +1375,8 @@ class LocalAssetEntityCompanion
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
i0.Value<String?>? priorRemoteId,
i0.Value<String?>? syncedChecksum,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1257,6 +1395,8 @@ class LocalAssetEntityCompanion
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
);
}
@@ -1317,6 +1457,12 @@ class LocalAssetEntityCompanion
),
);
}
if (priorRemoteId.present) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
}
if (syncedChecksum.present) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
}
return map;
}
@@ -1338,7 +1484,9 @@ class LocalAssetEntityCompanion
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')'))
.toString();
}
@@ -83,6 +83,13 @@ AND NOT EXISTS (
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
-- iOS edit-in-progress / revert: if this local was already uploaded (its
-- prior_remote_id resolves to a live remote), hide the local tile so the remote
-- (the edit, or the flipped-back original) is the single source of truth. Kills
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
)
ORDER BY created_at DESC
LIMIT $limit;
@@ -136,6 +143,10 @@ FROM
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
-- iOS edit-in-progress / revert: hide a local already represented by a live remote.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
)
)
GROUP BY bucket_date
ORDER BY bucket_date DESC;
+2 -2
View File
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds)) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds))) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),
@@ -58,7 +58,8 @@ class DriftBackupRepository extends DriftDatabaseRepository {
INNER JOIN main.local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id
AND la.backup_selection = ?3
);
)
AND (lae.synced_checksum IS NULL OR lae.synced_checksum != lae.checksum);
''';
final row = await _db
@@ -104,6 +105,10 @@ class DriftBackupRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
),
) &
// iOS revert: a reverted local hashes fresh (matches nothing remote),
// but if it was already reconciled (syncedChecksum == current checksum)
// it's handled — don't re-queue it as a fresh upload.
(lae.syncedChecksum.isNull() | lae.syncedChecksum.equalsExp(lae.checksum).not()) &
lae.id.isNotInQuery(_getExcludedSubquery()),
)
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
@@ -98,7 +98,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 26;
int get schemaVersion => 28;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -276,6 +276,12 @@ class Drift extends $Drift {
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await m.addColumn(v27.localAssetEntity, v27.localAssetEntity.priorRemoteId);
},
from27To28: (m, v28) async {
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.syncedChecksum);
},
),
);
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> markSynced(String localId, {required String priorRemoteId, required String syncedChecksum}) {
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
);
}
Future<void> delete(List<String> ids) {
if (ids.isEmpty) {
return Future.value();
@@ -1,8 +1,25 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class StackReconcileTarget {
final String stackId;
final String newPrimaryId;
final String localAssetId;
final String localAssetChecksum;
const StackReconcileTarget({
required this.stackId,
required this.newPrimaryId,
required this.localAssetId,
required this.localAssetChecksum,
});
}
class DriftStackRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftStackRepository(this._db) : super(_db);
@@ -14,6 +31,123 @@ class DriftStackRepository extends DriftDatabaseRepository {
return stack.toDto();
}).get();
}
// Per local id, find a stack member whose checksum matches the local's current
// checksum but isn't the stack primary — the iOS revert case where the local
// hashed back to the base while the primary still points at the edit.
Future<List<StackReconcileTarget>> findRevertReconcileTargets(Iterable<String> localAssetIds) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return const [];
}
final targets = <StackReconcileTarget>[];
for (final slice in ids.slices(kDriftMaxChunk)) {
final placeholders = List.filled(slice.length, '?').join(',');
final rows = await _db
.customSelect(
'''
SELECT
s.id AS stack_id,
member.id AS new_primary,
local.id AS local_id,
local.checksum AS local_checksum
FROM local_asset_entity local
INNER JOIN remote_asset_entity prior ON prior.id = local.prior_remote_id
INNER JOIN stack_entity s ON s.id = prior.stack_id
INNER JOIN remote_asset_entity member
ON member.stack_id = s.id
AND member.checksum = local.checksum
AND member.deleted_at IS NULL
WHERE local.id IN ($placeholders)
AND s.primary_asset_id != member.id
''',
variables: slice.map((id) => Variable<String>(id)).toList(),
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
)
.get();
for (final row in rows) {
targets.add(
StackReconcileTarget(
stackId: row.read<String>('stack_id'),
newPrimaryId: row.read<String>('new_primary'),
localAssetId: row.read<String>('local_id'),
localAssetChecksum: row.read<String>('local_checksum'),
),
);
}
}
return targets;
}
// The stack a remote asset belongs to, if any. Used by the revert path to find
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
Future<String?> findStackIdByRemoteId(String remoteId) async {
final row = await _db
.customSelect(
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
variables: [Variable<String>(remoteId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
return row?.read<String?>('stack_id');
}
// The stack's original base member to flip back to on revert: the earliest-
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
// before its edits, so oldest uploaded_at = the original.
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
final row = await _db
.customSelect(
'''
SELECT id FROM remote_asset_entity
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
LIMIT 1
''',
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
return row?.read<String?>('id');
}
// Optimistic local primary flip so the timeline updates immediately; the
// server's stack-update websocket rewrites it shortly after.
Future<void> setPrimary(String stackId, String primaryAssetId) {
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
);
}
// Optimistic local cleanup after a revert: detach every member, drop the stack,
// and delete the edit assets — atomically, so the timeline never shows members
// pointing at a deleted stack.
Future<void> applyRevertCleanup(String stackId, List<String> deletedAssetIds) {
return _db.batch((batch) {
batch.update(
_db.remoteAssetEntity,
const RemoteAssetEntityCompanion(stackId: Value(null)),
where: (e) => e.stackId.equals(stackId),
);
batch.deleteWhere(_db.stackEntity, (e) => e.id.equals(stackId));
for (final id in deletedAssetIds) {
batch.deleteWhere(_db.remoteAssetEntity, (e) => e.id.equals(id));
}
});
}
Future<List<String>> getOtherMemberIds(String stackId, String keepId) async {
final rows = await _db
.customSelect(
'SELECT id FROM remote_asset_entity WHERE stack_id = ? AND id != ? AND deleted_at IS NULL',
variables: [Variable<String>(stackId), Variable<String>(keepId)],
readsFrom: {_db.remoteAssetEntity},
)
.get();
return rows.map((r) => r.read<String>('id')).toList(growable: false);
}
}
extension on StackEntityData {
+110 -10
View File
@@ -88,6 +88,8 @@ int _deepHash(Object? value) {
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum EditState { notEdited, edited, unknown }
class PlatformAsset {
PlatformAsset({
required this.id,
@@ -395,6 +397,55 @@ class CloudIdResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class BaseResource {
BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
String path;
String sha1;
int sizeBytes;
String mimeType;
List<Object?> _toList() {
return <Object?>[path, sha1, sizeBytes, mimeType];
}
Object encode() {
return _toList();
}
static BaseResource decode(Object result) {
result as List<Object?>;
return BaseResource(
path: result[0]! as String,
sha1: result[1]! as String,
sizeBytes: result[2]! as int,
mimeType: result[3]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! BaseResource || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(sha1, other.sha1) &&
_deepEquals(sizeBytes, other.sizeBytes) &&
_deepEquals(mimeType, other.mimeType);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -405,21 +456,27 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
} else if (value is EditState) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) {
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
} else if (value is PlatformAlbum) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
} else if (value is SyncDelta) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
} else if (value is HashResult) {
buffer.putUint8(134);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
buffer.putUint8(135);
writeValue(buffer, value.encode());
} else if (value is BaseResource) {
buffer.putUint8(136);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@@ -432,15 +489,20 @@ class _PigeonCodec extends StandardMessageCodec {
final value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130:
return PlatformAsset.decode(readValue(buffer)!);
final value = readValue(buffer) as int?;
return value == null ? null : EditState.values[value];
case 131:
return PlatformAlbum.decode(readValue(buffer)!);
return PlatformAsset.decode(readValue(buffer)!);
case 132:
return SyncDelta.decode(readValue(buffer)!);
return PlatformAlbum.decode(readValue(buffer)!);
case 133:
return HashResult.decode(readValue(buffer)!);
return SyncDelta.decode(readValue(buffer)!);
case 134:
return HashResult.decode(readValue(buffer)!);
case 135:
return CloudIdResult.decode(readValue(buffer)!);
case 136:
return BaseResource.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@@ -672,4 +734,42 @@ class NativeSyncApi {
);
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
}
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as BaseResource?;
}
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as EditState;
}
}
@@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
@@ -11,7 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -45,11 +48,23 @@ final localSyncServiceProvider = Provider(
),
);
final editRevertServiceProvider = Provider(
(ref) => EditRevertService(
nativeSyncApi: ref.watch(nativeSyncApiProvider),
stackRepository: ref.watch(driftStackProvider),
localAssetRepository: ref.watch(localAssetRepository),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
),
);
final hashServiceProvider = Provider(
(ref) => HashService(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
stackRepository: ref.watch(driftStackProvider),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
editRevertService: ref.watch(editRevertServiceProvider),
),
);
@@ -105,6 +105,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
} catch (e) {
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
@@ -188,6 +189,13 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
}
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
// the stacked primary instead of briefly hiding the asset.
void _handleAssetStackUpdate(dynamic _) {
unawaited(_ref.read(backgroundSyncProvider).runFreshRemoteSync());
}
void _processBatchedAssetUploadReadyV1() {
if (_batchedAssetUploadReady.isEmpty) {
return;
@@ -67,6 +67,10 @@ class AssetApiRepository extends ApiRepository {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
}
Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
@@ -30,6 +30,11 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupEditPairGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
@@ -9,14 +9,18 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -31,6 +35,8 @@ final backgroundUploadServiceProvider = Provider((ref) {
ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(editRevertServiceProvider),
);
ref.onDispose(service.dispose);
@@ -43,13 +49,35 @@ class UploadTaskMetadata {
final bool isLivePhotos;
final String livePhotoVideoId;
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
// Marks the base upload of an edit pair. On completion the chained edit
// upload is enqueued with stackParentId = this base's remote id.
final bool isEditPair;
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
// Path of the native temp file backing this task (the edit base), so it can
// be cleaned up on terminal status.
final String basePath;
const UploadTaskMetadata({
required this.localAssetId,
required this.isLivePhotos,
required this.livePhotoVideoId,
this.isEditPair = false,
this.basePath = '',
});
UploadTaskMetadata copyWith({
String? localAssetId,
bool? isLivePhotos,
String? livePhotoVideoId,
bool? isEditPair,
String? basePath,
}) {
return UploadTaskMetadata(
localAssetId: localAssetId ?? this.localAssetId,
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
isEditPair: isEditPair ?? this.isEditPair,
basePath: basePath ?? this.basePath,
);
}
@@ -58,6 +86,8 @@ class UploadTaskMetadata {
'localAssetId': localAssetId,
'isLivePhotos': isLivePhotos,
'livePhotoVideoId': livePhotoVideoId,
'isEditPair': isEditPair,
'basePath': basePath,
};
}
@@ -66,6 +96,8 @@ class UploadTaskMetadata {
localAssetId: map['localAssetId'] as String,
isLivePhotos: map['isLivePhotos'] as bool,
livePhotoVideoId: map['livePhotoVideoId'] as String,
isEditPair: (map['isEditPair'] as bool?) ?? false,
basePath: (map['basePath'] as String?) ?? '',
);
}
@@ -76,7 +108,7 @@ class UploadTaskMetadata {
@override
String toString() =>
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
@override
bool operator ==(covariant UploadTaskMetadata other) {
@@ -86,11 +118,18 @@ class UploadTaskMetadata {
return other.localAssetId == localAssetId &&
other.isLivePhotos == isLivePhotos &&
other.livePhotoVideoId == livePhotoVideoId;
other.livePhotoVideoId == livePhotoVideoId &&
other.isEditPair == isEditPair &&
other.basePath == basePath;
}
@override
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
int get hashCode =>
localAssetId.hashCode ^
isLivePhotos.hashCode ^
livePhotoVideoId.hashCode ^
isEditPair.hashCode ^
basePath.hashCode;
}
/// Service for handling background uploads using iOS URLSession (background_downloader)
@@ -104,6 +143,8 @@ class BackgroundUploadService {
this._localAssetRepository,
this._backupRepository,
this._assetMediaRepository,
this._nativeSyncApi,
this._editRevertService,
) {
_uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
@@ -114,6 +155,8 @@ class BackgroundUploadService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository;
final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final EditRevertService _editRevertService;
final Logger _logger = Logger('BackgroundUploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
@@ -205,9 +248,20 @@ class BackgroundUploadService {
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
UploadTaskMetadata? metadata;
if (update.task.metaData.isNotEmpty) {
try {
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
} catch (_) {
metadata = null;
}
}
switch (update.status) {
case TaskStatus.complete:
unawaited(_handleLivePhoto(update));
unawaited(_handleLivePhoto(update, metadata));
unawaited(_handleEditPair(update, metadata));
unawaited(_recordPriorRemoteIdOnSuccess(update, metadata));
if (CurrentPlatform.isIOS) {
try {
@@ -220,19 +274,20 @@ class BackgroundUploadService {
break;
case TaskStatus.failed:
case TaskStatus.canceled:
case TaskStatus.notFound:
unawaited(_cleanupTempResourceOnFailure(metadata));
break;
default:
break;
}
}
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (update.task.metaData.isEmpty || update.task.metaData == '') {
return;
}
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
if (!metadata.isLivePhotos) {
if (metadata == null || !metadata.isLivePhotos) {
return;
}
@@ -258,6 +313,161 @@ class BackgroundUploadService {
}
}
/// When an edit-pair base upload completes, enqueue the edit upload stacked
/// onto it (stackParentId = the base's freshly-returned remote id).
Future<void> _handleEditPair(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || !metadata.isEditPair) {
return;
}
if (metadata.basePath.isNotEmpty) {
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
if (update.responseBody == null || update.responseBody!.isEmpty) {
return;
}
final baseRemoteId = jsonDecode(update.responseBody!)['id'] as String?;
if (baseRemoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
final editTask = await getEditUploadTask(localAsset, baseRemoteId);
if (editTask != null) {
await enqueueTasks([editTask]);
}
} catch (error, stackTrace) {
dPrint(() => "Error handling edit pair task: $error $stackTrace");
}
}
/// Records the uploaded remote id as the asset's priorRemoteId so a later
/// edit stacks onto it. Skipped for edit-pair base uploads — those become
/// stack members; the chained edit's success records the prior.
Future<void> _recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || metadata.isEditPair || metadata.localAssetId.isEmpty) {
return;
}
if (update.responseBody == null || update.responseBody!.isEmpty) {
return;
}
final remoteId = jsonDecode(update.responseBody!)['id'] as String?;
if (remoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
await _localAssetRepository.markSynced(
metadata.localAssetId,
priorRemoteId: remoteId,
syncedChecksum: localAsset?.checksum ?? '',
);
} catch (error, stackTrace) {
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
}
}
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
if (metadata == null || metadata.basePath.isEmpty) {
return;
}
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
/// Resolves how an iOS edit should stack: a [String] prior remote id to use
/// as stackParentId (absorption), a [BaseResource] whose bytes must upload as
/// a fresh base first, or null when there's no edit to handle.
Future<Object?> _tryGetBaseOrPrior(LocalAsset asset) async {
if (asset.priorRemoteId != null) {
return asset.priorRemoteId;
}
BaseResource? base;
try {
base = await _nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
} catch (error, stack) {
_logger.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
return null;
}
if (base == null) {
return null;
}
// Identical bytes (e.g. auto-HDR) — no real edit to stack.
if (base.sha1 == asset.checksum) {
try {
await File(base.path).delete();
} catch (_) {}
return null;
}
return base;
}
Future<UploadTask> _buildBaseUploadTask(LocalAsset asset, BaseResource base) async {
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
basePath: base.path,
).toJson();
// The base is the unedited original (no adjustmentTime); the `_base`
// deviceAssetId keeps it distinct from the chained edit task.
return buildUploadTask(
File(base.path),
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
deviceAssetId: '${asset.id}_base',
metadata: metadata,
group: kBackupGroup,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getEditUploadTask(LocalAsset asset, String stackParentId) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
final fields = {'stackParentId': stackParentId};
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '').toJson();
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
fields: fields,
group: kBackupEditPairGroup,
priority: 0,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
@@ -266,6 +476,24 @@ class BackgroundUploadService {
return null;
}
// iOS edit pair: stack a user edit onto the original. Reuse a prior upload
// as the stack parent (absorption), or upload the unedited bytes as a fresh
// base first. Live-photo edits aren't handled.
if (!entity.isLivePhoto && CurrentPlatform.isIOS) {
// A reverted edit dedups against the existing stack base — flip the primary
// back and skip the upload entirely.
if (asset.priorRemoteId != null && await _editRevertService.tryDedupRevert(asset)) {
return null;
}
final pairResolution = await _tryGetBaseOrPrior(asset);
if (pairResolution is BaseResource) {
return _buildBaseUploadTask(asset, pairResolution);
}
if (pairResolution is String) {
return getEditUploadTask(asset, pairResolution);
}
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
@@ -6,16 +6,21 @@ 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';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
@@ -39,6 +44,9 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(localAssetRepository),
ref.watch(editRevertServiceProvider),
);
});
@@ -54,6 +62,9 @@ class ForegroundUploadService {
this._backupRepository,
this._connectivityApi,
this._assetMediaRepository,
this._nativeSyncApi,
this._localAssetRepository,
this._editRevertService,
);
final UploadRepository _uploadRepository;
@@ -61,6 +72,9 @@ class ForegroundUploadService {
final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi;
final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final DriftLocalAssetRepository _localAssetRepository;
final EditRevertService _editRevertService;
final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false;
@@ -250,6 +264,13 @@ class ForegroundUploadService {
return;
}
// A reverted iOS edit dedups against the existing stack base instead of
// re-uploading; tryDedupRevert flips the primary back and skips the upload.
if (Platform.isIOS && asset.priorRemoteId != null && await _editRevertService.tryDedupRevert(asset)) {
callbacks.onSuccess?.call(asset.localId!, asset.priorRemoteId!);
return;
}
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
@@ -371,6 +392,11 @@ class ForegroundUploadService {
]);
}
final stackParentId = await _maybeUploadBaseResource(asset, Map.of(fields), cancelToken);
if (stackParentId != null) {
fields['stackParentId'] = stackParentId;
}
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile(
file: file,
@@ -384,6 +410,13 @@ class ForegroundUploadService {
);
if (result.isSuccess && result.remoteAssetId != null) {
unawaited(
_localAssetRepository.markSynced(
asset.localId!,
priorRemoteId: result.remoteAssetId!,
syncedChecksum: asset.checksum ?? '',
),
);
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
@@ -415,6 +448,60 @@ class ForegroundUploadService {
}
}
/// For an edited iOS photo, uploads the original camera bytes and returns its
/// remote id to use as the edit's stackParentId. Returns null for non-edits.
Future<String?> _maybeUploadBaseResource(
LocalAsset asset,
Map<String, String> baseFields,
Completer<void>? cancelToken,
) async {
if (!Platform.isIOS) {
return null;
}
// A prior upload exists, so stack the new edit onto it instead of creating a
// duplicate top-level asset.
if (asset.priorRemoteId != null) {
return asset.priorRemoteId;
}
BaseResource? base;
try {
base = await _nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
} catch (error, stack) {
_logger.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
return null;
}
if (base == null) {
return null;
}
final baseFile = File(base.path);
// Identical bytes (e.g. auto-HDR) — no real edit to stack.
if (base.sha1 == asset.checksum) {
try {
await baseFile.delete();
} catch (_) {}
return null;
}
try {
final baseName = p.setExtension(asset.name, p.extension(base.path));
final result = await _uploadRepository.uploadFile(
file: baseFile,
originalFileName: baseName,
fields: baseFields,
cancelToken: cancelToken,
logContext: 'baseResource[${asset.localId}]',
);
return result.isSuccess ? result.remoteAssetId : null;
} finally {
try {
await baseFile.delete();
} catch (_) {}
}
}
Future<UploadResult> _uploadSingleFile(
File file, {
required String deviceAssetId,
+8 -8
View File
@@ -1,26 +1,26 @@
.PHONY: build watch create_app_icon create_splash build_release_android pigeon test analyze format migration translation
build:
@printf "This command has been removed. Please use:\n\n mise codegen # or mise //:mobile:codegen:dart from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise codegen # or mise //mobile:codegen:dart from another directory\n\n" >&2 && exit 1
pigeon:
@printf "This command has been removed. Please use:\n\n mise pigeon # or mise //:mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise pigeon # or mise //mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1
build_release_android:
@printf "This command has been removed. Please use:\n\n mise run build:android # or mise //:mobile:build:android from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise run build:android # or mise //mobile:build:android from another directory\n\n" >&2 && exit 1
migration:
@printf "This command has been removed. Please use:\n\n mise migration # or mise //:mobile:drift:migration from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise migration # or mise //mobile:drift:migration from another directory\n\n" >&2 && exit 1
translation:
@printf "This command has been removed. Please use:\n\n mise translation # or mise //:mobile:codegen:translation from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise translation # or mise //mobile:codegen:translation from another directory\n\n" >&2 && exit 1
analyze:
@printf "This command has been removed. Please use:\n\n mise analyze # or mise //:mobile:lint from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise analyze # or mise //mobile:lint from another directory\n\n" >&2 && exit 1
format:
@printf "This command has been removed. Please use:\n\n mise format # or mise //:mobile:format from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise format # or mise //mobile:format from another directory\n\n" >&2 && exit 1
test:
@printf "This command has been removed. Please use:\n\n mise test # or mise //:mobile:test from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise test # or mise //mobile:test from another directory\n\n" >&2 && exit 1
+13 -3
View File
@@ -1252,8 +1252,11 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@@ -1317,6 +1320,10 @@ class AssetsApi {
mp.fields[r'sidecarData'] = sidecarData.field;
mp.files.add(sidecarData);
}
if (stackParentId != null) {
hasFields = true;
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
}
if (visibility != null) {
hasFields = true;
mp.fields[r'visibility'] = parameterToString(visibility);
@@ -1376,9 +1383,12 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+23
View File
@@ -110,6 +110,21 @@ class CloudIdResult {
const CloudIdResult({required this.assetId, this.error, this.cloudId});
}
class BaseResource {
final String path;
final String sha1;
final int sizeBytes;
final String mimeType;
const BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
}
// Whether an iOS asset currently carries a user edit, as opposed to a
// capture-time Photographic Style or a reverted edit. `unknown` means the
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
// network wasn't allowed), so callers must not treat it as "not edited".
enum EditState { notEdited, edited, unknown }
@HostApi()
abstract class NativeSyncApi {
bool shouldFullSync();
@@ -144,4 +159,12 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
}
+3
View File
@@ -1,3 +1,4 @@
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -11,3 +12,5 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockEditRevertService extends Mock implements EditRevertService {}
+8
View File
@@ -30,6 +30,8 @@ import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -87,6 +89,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v25.DatabaseAtV25(db);
case 26:
return v26.DatabaseAtV26(db);
case 27:
return v27.DatabaseAtV27(db);
case 28:
return v28.DatabaseAtV28(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -119,5 +125,7 @@ class GeneratedHelper implements SchemaInstantiationHelper {
24,
25,
26,
27,
28,
];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
@@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
@@ -6,6 +6,7 @@ import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -13,9 +14,11 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
@@ -28,10 +31,13 @@ void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockDriftBackupRepository mockBackupRepository;
late MockAssetMediaRepository mockAssetMediaRepository;
late MockNativeSyncApi mockNativeSyncApi;
late MockEditRevertService mockEditRevertService;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
registerFallbackValue(LocalAssetStub.image1);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(MethodCall methodCall) async => 'test',
@@ -50,6 +56,8 @@ void main() {
mockLocalAssetRepository = MockDriftLocalAssetRepository();
mockBackupRepository = MockDriftBackupRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockNativeSyncApi = MockNativeSyncApi();
mockEditRevertService = MockEditRevertService();
sut = BackgroundUploadService(
mockUploadRepository,
@@ -57,8 +65,18 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
// Default: no edit base, so getUploadTask falls through to the normal path.
when(
() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => null);
// Default: not a revert, so getUploadTask proceeds with the normal flow.
when(() => mockEditRevertService.tryDedupRevert(any())).thenAnswer((_) async => false);
mockUploadRepository.onUploadStatus = (_) {};
mockUploadRepository.onTaskProgress = (_) {};
});
@@ -122,6 +140,71 @@ void main() {
});
});
group('getUploadTask edit pair', () {
test('absorption: stacks the edit under the prior upload via stackParentId', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(priorRemoteId: 'prior-remote-1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupEditPairGroup);
expect(task.fields['stackParentId'], 'prior-remote-1');
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('builds a base upload task for an unsynced edit', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(checksum: 'edited-sha1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'original-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.metaData, contains('"isEditPair":true'));
});
test('falls through to a normal upload when base bytes match the checksum', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(checksum: 'same-sha1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'same-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.fields.containsKey('stackParentId'), isFalse);
});
});
group('getLivePhotoUploadTask', () {
test('should call getOriginalFilename for live photo upload task', () async {
final asset = LocalAssetStub.image1;
@@ -172,6 +255,8 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutWithV24.dispose());
@@ -222,6 +307,8 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutAndroid.dispose());
@@ -262,6 +349,8 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutWithV24.dispose());
@@ -302,6 +391,8 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutWithV24.dispose());
+7
View File
@@ -4,11 +4,15 @@ import 'package:mocktail/mocktail.dart' as mocktail;
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class UnitMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final stack = MockDriftStackRepository();
final assetApi = MockAssetApiRepository();
final editRevert = MockEditRevertService();
final nativeApi = MockNativeSyncApi();
@@ -31,6 +35,9 @@ class UnitMocks {
mocktail.reset(localAlbum);
mocktail.reset(localAsset);
mocktail.reset(trashedAsset);
mocktail.reset(stack);
mocktail.reset(assetApi);
mocktail.reset(editRevert);
mocktail.reset(nativeApi);
}
}
@@ -0,0 +1,121 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
import '../mocks.dart';
void main() {
late EditRevertService sut;
final mocks = UnitMocks();
LocalAsset asset({String? priorRemoteId, String? checksum = 'reverted-sha1'}) => LocalAsset(
id: 'local-1',
name: 'photo.jpg',
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
priorRemoteId: priorRemoteId,
checksum: checksum,
);
setUp(() {
sut = EditRevertService(
nativeSyncApi: mocks.nativeApi,
stackRepository: mocks.stack,
localAssetRepository: mocks.localAsset,
assetApiRepository: mocks.assetApi,
);
});
tearDown(() {
mocks.reset();
});
group('tryDedupRevert', () {
test('returns false when the asset was never uploaded as an edit', () async {
expect(await sut.tryDedupRevert(asset(priorRemoteId: null)), isFalse);
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('returns false (lets the pair flow run) when there is still a live edit', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.edited);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the edit state cannot be read (offloaded to iCloud)', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.unknown);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the prior remote is not in a stack', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('returns false when the stack has no base member to flip back to', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('flips the primary back to the base via prior_remote_id and trashes the edit', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(
() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit'),
).thenAnswer((_) async => 'remote-base');
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
when(() => mocks.stack.getOtherMemberIds('stack-1', 'remote-base')).thenAnswer((_) async => ['remote-edit']);
when(() => mocks.assetApi.delete(['remote-edit'], false)).thenAnswer((_) async {});
when(() => mocks.assetApi.unStack(['stack-1'])).thenAnswer((_) async {});
when(() => mocks.stack.applyRevertCleanup('stack-1', ['remote-edit'])).thenAnswer((_) async {});
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isTrue);
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
verify(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).called(1);
verify(() => mocks.assetApi.delete(['remote-edit'], false)).called(1);
verify(() => mocks.assetApi.unStack(['stack-1'])).called(1);
verify(() => mocks.stack.applyRevertCleanup('stack-1', ['remote-edit'])).called(1);
});
});
}
@@ -18,6 +18,9 @@ void main() {
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
editRevertService: mocks.editRevert,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
@@ -110,6 +113,9 @@ void main() {
nativeSyncApi: mocks.nativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
editRevertService: mocks.editRevert,
);
final album = LocalAlbumFactory.create();
+6 -21
View File
@@ -11,9 +11,6 @@
"required": true,
"in": "query",
"description": "Album ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -25,9 +22,6 @@
"required": false,
"in": "query",
"description": "Asset ID (if activity is for an asset)",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -38,9 +32,6 @@
"name": "level",
"required": false,
"in": "query",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"$ref": "#/components/schemas/ReactionLevel"
}
@@ -49,9 +40,6 @@
"name": "type",
"required": false,
"in": "query",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"$ref": "#/components/schemas/ReactionType"
}
@@ -61,9 +49,6 @@
"required": false,
"in": "query",
"description": "Filter by user ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -187,9 +172,6 @@
"required": true,
"in": "query",
"description": "Album ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -201,9 +183,6 @@
"required": false,
"in": "query",
"description": "Asset ID (if activity is for an asset)",
"x-nestjs_zod-parent-metadata": {
"description": "Activity"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -16511,6 +16490,12 @@
"format": "binary",
"type": "string"
},
"stackParentId": {
"description": "Stack this asset onto the parent asset, with the new asset as the stack primary",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"visibility": {
"$ref": "#/components/schemas/AssetVisibility"
}
+1 -1
View File
@@ -25,7 +25,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
+1 -1
View File
@@ -27,7 +27,7 @@
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@types/node": "^24.11.0",
"@types/node": "^24.12.4",
"esbuild": "^0.27.3",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
+1 -1
View File
@@ -24,7 +24,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"typescript": "^6.0.0"
}
}
+2
View File
@@ -630,6 +630,8 @@ export type AssetMediaCreateDto = {
metadata?: AssetMetadataUpsertItemDto[];
/** Sidecar file data */
sidecarData?: Blob;
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
stackParentId?: string;
visibility?: AssetVisibility;
};
export type AssetMediaResponseDto = {
+175 -175
View File
@@ -128,8 +128,8 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
'@types/pg':
specifier: ^8.15.1
version: 8.20.0
@@ -195,10 +195,10 @@ importers:
version: 5.2.1(encoding@0.1.13)
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/cli:
dependencies:
@@ -240,8 +240,8 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
'@vitest/coverage-v8':
specifier: ^4.0.0
version: 4.1.5(vitest@4.1.5)
@@ -286,10 +286,10 @@ importers:
version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)
vite:
specifier: ^8.0.0
version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.5)
@@ -333,8 +333,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1
'@types/node':
specifier: ^24.11.0
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
esbuild:
specifier: ^0.27.3
version: 0.27.4
@@ -352,8 +352,8 @@ importers:
version: 1.2.0
devDependencies:
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
typescript:
specifier: ^6.0.0
version: 6.0.3
@@ -524,7 +524,7 @@ importers:
version: 2.1.1
nest-commander:
specifier: ^3.16.0
version: 3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.2)(typescript@6.0.3)
version: 3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.4)(typescript@6.0.3)
nestjs-cls:
specifier: ^6.0.0
version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -609,7 +609,7 @@ importers:
version: 10.0.1(eslint@10.2.1(jiti@2.6.1))
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)
version: 11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(prettier@3.8.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
@@ -662,8 +662,8 @@ importers:
specifier: ^2.0.0
version: 2.1.0
'@types/node':
specifier: ^24.12.2
version: 24.12.2
specifier: ^24.12.4
version: 24.12.4
'@types/nodemailer':
specifier: ^8.0.0
version: 8.0.0
@@ -690,7 +690,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^10.0.0
version: 10.2.1(jiti@2.6.1)
@@ -741,10 +741,10 @@ importers:
version: 1.5.9(@swc/core@1.15.30(@swc/helpers@0.5.21))(rollup@4.55.1)
vite-tsconfig-paths:
specifier: ^6.0.0
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
web:
dependencies:
@@ -5321,8 +5321,8 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
'@types/node@24.12.4':
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
@@ -12962,11 +12962,11 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/schematics-cli@19.2.24(@types/node@24.12.2)(chokidar@4.0.3)':
'@angular-devkit/schematics-cli@19.2.24(@types/node@24.12.4)(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
'@inquirer/prompts': 7.3.2(@types/node@24.12.2)
'@inquirer/prompts': 7.3.2(@types/node@24.12.4)
ansi-colors: 4.1.3
symbol-observable: 4.0.0
yargs-parser: 21.1.1
@@ -15476,143 +15476,143 @@ snapshots:
'@inquirer/ansi@1.0.2': {}
'@inquirer/checkbox@4.3.2(@types/node@24.12.2)':
'@inquirer/checkbox@4.3.2(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/confirm@5.1.21(@types/node@24.12.2)':
'@inquirer/confirm@5.1.21(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/core@10.3.2(@types/node@24.12.2)':
'@inquirer/core@10.3.2(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/editor@4.2.23(@types/node@24.12.2)':
'@inquirer/editor@4.2.23(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/expand@4.0.23(@types/node@24.12.2)':
'@inquirer/expand@4.0.23(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/external-editor@1.0.3(@types/node@24.12.2)':
'@inquirer/external-editor@1.0.3(@types/node@24.12.4)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/figures@1.0.15': {}
'@inquirer/input@4.3.1(@types/node@24.12.2)':
'@inquirer/input@4.3.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/number@3.0.23(@types/node@24.12.2)':
'@inquirer/number@3.0.23(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/password@4.0.23(@types/node@24.12.2)':
'@inquirer/password@4.0.23(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/prompts@7.10.1(@types/node@24.12.2)':
'@inquirer/prompts@7.10.1(@types/node@24.12.4)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.12.2)
'@inquirer/confirm': 5.1.21(@types/node@24.12.2)
'@inquirer/editor': 4.2.23(@types/node@24.12.2)
'@inquirer/expand': 4.0.23(@types/node@24.12.2)
'@inquirer/input': 4.3.1(@types/node@24.12.2)
'@inquirer/number': 3.0.23(@types/node@24.12.2)
'@inquirer/password': 4.0.23(@types/node@24.12.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.2)
'@inquirer/search': 3.2.2(@types/node@24.12.2)
'@inquirer/select': 4.4.2(@types/node@24.12.2)
'@inquirer/checkbox': 4.3.2(@types/node@24.12.4)
'@inquirer/confirm': 5.1.21(@types/node@24.12.4)
'@inquirer/editor': 4.2.23(@types/node@24.12.4)
'@inquirer/expand': 4.0.23(@types/node@24.12.4)
'@inquirer/input': 4.3.1(@types/node@24.12.4)
'@inquirer/number': 3.0.23(@types/node@24.12.4)
'@inquirer/password': 4.0.23(@types/node@24.12.4)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.4)
'@inquirer/search': 3.2.2(@types/node@24.12.4)
'@inquirer/select': 4.4.2(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/prompts@7.3.2(@types/node@24.12.2)':
'@inquirer/prompts@7.3.2(@types/node@24.12.4)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.12.2)
'@inquirer/confirm': 5.1.21(@types/node@24.12.2)
'@inquirer/editor': 4.2.23(@types/node@24.12.2)
'@inquirer/expand': 4.0.23(@types/node@24.12.2)
'@inquirer/input': 4.3.1(@types/node@24.12.2)
'@inquirer/number': 3.0.23(@types/node@24.12.2)
'@inquirer/password': 4.0.23(@types/node@24.12.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.2)
'@inquirer/search': 3.2.2(@types/node@24.12.2)
'@inquirer/select': 4.4.2(@types/node@24.12.2)
'@inquirer/checkbox': 4.3.2(@types/node@24.12.4)
'@inquirer/confirm': 5.1.21(@types/node@24.12.4)
'@inquirer/editor': 4.2.23(@types/node@24.12.4)
'@inquirer/expand': 4.0.23(@types/node@24.12.4)
'@inquirer/input': 4.3.1(@types/node@24.12.4)
'@inquirer/number': 3.0.23(@types/node@24.12.4)
'@inquirer/password': 4.0.23(@types/node@24.12.4)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.4)
'@inquirer/search': 3.2.2(@types/node@24.12.4)
'@inquirer/select': 4.4.2(@types/node@24.12.4)
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/rawlist@4.1.11(@types/node@24.12.2)':
'@inquirer/rawlist@4.1.11(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/search@3.2.2(@types/node@24.12.2)':
'@inquirer/search@3.2.2(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/select@4.4.2(@types/node@24.12.2)':
'@inquirer/select@4.4.2(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@inquirer/type@3.0.10(@types/node@24.12.2)':
'@inquirer/type@3.0.10(@types/node@24.12.4)':
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@internationalized/date@3.12.1':
dependencies:
@@ -15644,7 +15644,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -16020,12 +16020,12 @@ snapshots:
bullmq: 5.76.1
tslib: 2.8.1
'@nestjs/cli@11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)':
'@nestjs/cli@11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(prettier@3.8.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics-cli': 19.2.24(@types/node@24.12.2)(chokidar@4.0.3)
'@inquirer/prompts': 7.10.1(@types/node@24.12.2)
'@angular-devkit/schematics-cli': 19.2.24(@types/node@24.12.4)(chokidar@4.0.3)
'@inquirer/prompts': 7.10.1(@types/node@24.12.4)
'@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)
ansis: 4.2.0
chokidar: 4.0.3
@@ -17359,7 +17359,7 @@ snapshots:
'@types/accepts@1.3.7':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/archiver@7.0.0':
dependencies:
@@ -17371,16 +17371,16 @@ snapshots:
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/bonjour@3.5.13':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/braces@3.0.5': {}
@@ -17402,21 +17402,21 @@ snapshots:
'@types/cli-progress@3.11.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/compression@1.8.1':
dependencies:
'@types/express': 5.0.6
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/content-disposition@0.5.9': {}
@@ -17433,11 +17433,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/express': 5.0.6
'@types/keygrip': 1.0.6
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/d3-array@3.2.2': {}
@@ -17564,13 +17564,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2': 1.15.5
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2': 1.15.5
'@types/dom-to-image@2.6.7': {}
@@ -17595,14 +17595,14 @@ snapshots:
'@types/express-serve-static-core@4.19.7':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -17628,7 +17628,7 @@ snapshots:
'@types/fluent-ffmpeg@2.1.28':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/geojson@7946.0.16': {}
@@ -17656,7 +17656,7 @@ snapshots:
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/inquirer@8.2.12':
dependencies:
@@ -17680,7 +17680,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/justified-layout@4.1.4': {}
@@ -17699,7 +17699,7 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.9
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/leaflet@1.9.21':
dependencies:
@@ -17729,7 +17729,7 @@ snapshots:
'@types/mock-fs@4.13.4':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ms@2.1.0': {}
@@ -17739,7 +17739,7 @@ snapshots:
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/node@17.0.45': {}
@@ -17747,7 +17747,7 @@ snapshots:
dependencies:
undici-types: 5.26.5
'@types/node@24.12.2':
'@types/node@24.12.4':
dependencies:
undici-types: 7.16.0
@@ -17758,13 +17758,13 @@ snapshots:
'@types/nodemailer@8.0.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/oidc-provider@9.5.0':
dependencies:
'@types/keygrip': 1.0.6
'@types/koa': 3.0.1
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/parse5@5.0.3': {}
@@ -17774,13 +17774,13 @@ snapshots:
'@types/pg@8.15.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/pg@8.20.0':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
pg-protocol: 1.13.0
pg-types: 2.2.0
@@ -17788,13 +17788,13 @@ snapshots:
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/prismjs@1.26.5': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/qs@6.14.0': {}
@@ -17823,24 +17823,24 @@ snapshots:
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/retry@0.12.2': {}
'@types/sax@1.2.7':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/semver@7.7.1': {}
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/send@1.2.1':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/serve-index@1.9.4':
dependencies:
@@ -17849,25 +17849,25 @@ snapshots:
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/send': 0.17.6
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2-streams@0.1.13':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -17878,7 +17878,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 24.12.2
'@types/node': 24.12.4
form-data: 4.0.5
'@types/supercluster@7.1.3':
@@ -17892,7 +17892,7 @@ snapshots:
'@types/through@0.0.33':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/trusted-types@2.0.7': {}
@@ -17908,7 +17908,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/yargs-parser@21.0.3': {}
@@ -18015,7 +18015,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -18030,7 +18030,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -18046,7 +18046,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4':
dependencies:
@@ -18065,21 +18065,21 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
@@ -19896,7 +19896,7 @@ snapshots:
engine.io@6.6.5:
dependencies:
'@types/cors': 2.8.19
'@types/node': 24.12.2
'@types/node': 24.12.4
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@@ -20315,7 +20315,7 @@ snapshots:
eval@0.1.8:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
require-like: 0.1.2
event-emitter@0.3.5:
@@ -20876,7 +20876,7 @@ snapshots:
happy-dom@20.9.0:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
@@ -21301,9 +21301,9 @@ snapshots:
inline-style-parser@0.2.7: {}
inquirer@8.2.7(@types/node@24.12.2):
inquirer@8.2.7(@types/node@24.12.4):
dependencies:
'@inquirer/external-editor': 1.0.3(@types/node@24.12.2)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.4)
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
@@ -21522,7 +21522,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 24.12.2
'@types/node': 24.12.4
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -21530,13 +21530,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -22777,7 +22777,7 @@ snapshots:
neo-async@2.6.2: {}
nest-commander@3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.2)(typescript@6.0.3):
nest-commander@3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.4)(typescript@6.0.3):
dependencies:
'@fig/complete-commander': 3.2.0(commander@11.1.0)
'@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)
@@ -22786,7 +22786,7 @@ snapshots:
'@types/inquirer': 8.2.12
commander: 11.1.0
cosmiconfig: 8.3.6(typescript@6.0.3)
inquirer: 8.2.7(@types/node@24.12.2)
inquirer: 8.2.7(@types/node@24.12.4)
transitivePeerDependencies:
- '@types/node'
- typescript
@@ -23939,7 +23939,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
long: 5.3.2
protobufjs@8.0.1:
@@ -23954,7 +23954,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.12.2
'@types/node': 24.12.4
long: 5.3.2
protocol-buffers-schema@3.6.1: {}
@@ -25945,13 +25945,13 @@ snapshots:
- rollup
- supports-color
vite-node@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite-node@3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -25966,17 +25966,17 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@6.0.3)
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.4)
@@ -25985,7 +25985,7 @@ snapshots:
rollup: 4.55.1
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.32.0
@@ -25994,7 +25994,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -26002,7 +26002,7 @@ snapshots:
rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.2
'@types/node': 24.12.4
esbuild: 0.28.0
fsevents: 2.3.3
jiti: 2.6.1
@@ -26034,13 +26034,13 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@4.1.5):
dependencies:
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -26058,12 +26058,12 @@ snapshots:
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.12.2
'@types/node': 24.12.4
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
@@ -26080,10 +26080,10 @@ snapshots:
- tsx
- yaml
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -26100,11 +26100,11 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.12.2
'@types/node': 24.12.4
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
+1 -1
View File
@@ -55,7 +55,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
+1 -1
View File
@@ -138,7 +138,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.12.2",
"@types/node": "^24.12.4",
"@types/nodemailer": "^8.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
+5 -7
View File
@@ -36,18 +36,16 @@ const ActivityStatisticsResponseSchema = z
})
.meta({ id: 'ActivityStatisticsResponseDto' });
const ActivitySchema = z
.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
})
.describe('Activity');
const ActivitySchema = z.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
});
const ActivitySearchSchema = ActivitySchema.extend({
type: ReactionTypeSchema.optional(),
level: ReactionLevelSchema.optional(),
userId: z.uuidv4().optional().describe('Filter by user ID'),
}).describe('Activity search');
});
const ActivityCreateSchema = ActivitySchema.extend({
type: ReactionTypeSchema,
+4
View File
@@ -48,6 +48,10 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
isFavorite: stringToBool.optional().describe('Mark as favorite'),
visibility: AssetVisibilitySchema.optional(),
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
stackParentId: z
.uuidv4()
.optional()
.describe('Stack this asset onto the parent asset, with the new asset as the stack primary'),
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
[UploadFieldName.SIDECAR_DATA]: z
.any()
+58 -1
View File
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import { StackTable } from 'src/schema/tables/stack.table';
import { asUuid, withDefaultVisibility } from 'src/utils/database';
import { asUuid, isStackPrimaryConstraint, withDefaultVisibility } from 'src/utils/database';
export interface StackSearch {
ownerId: string;
@@ -124,6 +124,63 @@ export class StackRepository {
});
}
async linkAsset(
ownerId: string,
newAssetId: string,
parentId: string,
): Promise<{ stackId: string; created: boolean } | null> {
try {
return await this.db.transaction().execute(async (tx) => {
// Lock the parent so two concurrent uploads can't each create a stack for it.
const parent = await tx
.selectFrom('asset')
.select(['id', 'ownerId', 'stackId', 'deletedAt'])
.where('id', '=', asUuid(parentId))
.forUpdate()
.executeTakeFirst();
if (!parent || parent.ownerId !== ownerId || parent.deletedAt) {
return null;
}
if (parent.stackId) {
await tx
.updateTable('asset')
.set({ stackId: parent.stackId, updatedAt: new Date() })
.where('id', '=', asUuid(newAssetId))
.execute();
await tx
.updateTable('stack')
.set({ primaryAssetId: newAssetId, updatedAt: new Date() })
.where('id', '=', parent.stackId)
.execute();
return { stackId: parent.stackId, created: false };
}
const stack = await tx
.insertInto('stack')
.values({ ownerId, primaryAssetId: newAssetId })
.returning('id')
.executeTakeFirstOrThrow();
await tx
.updateTable('asset')
.set({ stackId: stack.id, updatedAt: new Date() })
.where('id', 'in', [asUuid(newAssetId), parent.id])
.execute();
return { stackId: stack.id, created: true };
});
} catch (error) {
// newAssetId may already be another stack's primary (e.g. a retried upload) —
// treat the unique-constraint hit as "couldn't stack" rather than a 500.
if (isStackPrimaryConstraint(error)) {
return null;
}
throw error;
}
}
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> {
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
@@ -418,6 +418,79 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.update).not.toHaveBeenCalled();
});
it('should stack a new asset onto the parent and emit the populated stackId', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const parent = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
mocks.asset.create.mockResolvedValue(assetEntity);
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: true });
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'id_1',
status: AssetMediaStatus.CREATED,
});
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, assetEntity.id, parent.id);
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
asset: expect.objectContaining({ stackId: 'stack-1' }),
});
});
it('should reject stacking onto a trashed asset', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const parent = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce({ ...getForAsset(parent), deletedAt: new Date() });
await expect(
sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.stack.linkAsset).not.toHaveBeenCalled();
});
it('should adopt a duplicate into the stack when stacking', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 0,
};
const parent = AssetFactory.create();
const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
mocks.asset.create.mockRejectedValue(error);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('dup-id');
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: false });
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'dup-id',
status: AssetMediaStatus.DUPLICATE,
});
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, 'dup-id', parent.id);
});
it('should hide the linked motion asset', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const asset = AssetFactory.create();
+56 -4
View File
@@ -140,26 +140,63 @@ export class AssetMediaService extends BaseService {
this.requireQuota(auth, file.size);
if (dto.stackParentId) {
if (auth.sharedLink) {
throw new BadRequestException('Cannot stack an asset uploaded via shared link');
}
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.stackParentId] });
const parent = await this.assetRepository.getById(dto.stackParentId);
if (!parent || parent.deletedAt) {
throw new BadRequestException('Cannot stack onto a trashed or missing asset');
}
}
if (dto.livePhotoVideoId) {
await onBeforeLink(
{ asset: this.assetRepository, event: this.eventRepository },
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
);
}
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
// When stacking, defer the AssetCreate event and emit it below with the
// populated stackId, so clients don't briefly see the asset as standalone.
const asset = await this.create(auth.user.id, dto, file, sidecarFile, { skipEventEmit: !!dto.stackParentId });
if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, asset.id);
}
if (dto.stackParentId) {
const linkResult = await this.linkToStackParent(auth.user.id, asset.id, dto.stackParentId);
await this.eventRepository.emit('AssetCreate', {
asset: linkResult ? { ...asset, stackId: linkResult.stackId } : asset,
});
}
await this.userRepository.updateUsage(auth.user.id, file.size);
return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
}
}
private async linkToStackParent(
ownerId: string,
newAssetId: string,
parentId: string,
): Promise<{ stackId: string; created: boolean } | null> {
const result = await this.stackRepository.linkAsset(ownerId, newAssetId, parentId);
if (!result) {
this.logger.warn(`Could not link asset ${newAssetId} to stack parent ${parentId}: parent missing or not owned`);
return null;
}
await this.eventRepository.emit(result.created ? 'StackCreate' : 'StackUpdate', {
stackId: result.stackId,
userId: ownerId,
});
return result;
}
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -290,6 +327,7 @@ export class AssetMediaService extends BaseService {
auth: AuthDto,
file: UploadFile,
sidecarFile?: UploadFile,
stackParentId?: string,
): Promise<AssetMediaResponseDto> {
// clean up files
await this.jobRepository.queue({
@@ -309,6 +347,12 @@ export class AssetMediaService extends BaseService {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
if (stackParentId) {
// Adopt the existing duplicate into the stack so a re-uploaded edit still
// stacks instead of silently staying separate.
await this.linkToStackParent(auth.user.id, duplicateId, stackParentId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
@@ -317,7 +361,13 @@ export class AssetMediaService extends BaseService {
throw error;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
private async create(
ownerId: string,
dto: AssetMediaCreateDto,
file: UploadFile,
sidecarFile?: UploadFile,
options?: { skipEventEmit?: boolean },
) {
const asset = await this.assetRepository.create({
ownerId,
libraryId: null,
@@ -356,7 +406,9 @@ export class AssetMediaService extends BaseService {
lockedPropertiesBehavior: 'override',
});
await this.eventRepository.emit('AssetCreate', { asset });
if (!options?.skipEventEmit) {
await this.eventRepository.emit('AssetCreate', { asset });
}
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
+6
View File
@@ -76,6 +76,12 @@ export const isAssetChecksumConstraint = (error: unknown) => {
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
};
export const STACK_PRIMARY_CONSTRAINT = 'stack_primaryAssetId_uq';
export const isStackPrimaryConstraint = (error: unknown) => {
return (error as PostgresError)?.constraint_name === STACK_PRIMARY_CONSTRAINT;
};
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
}