Compare commits

..

26 Commits

Author SHA1 Message Date
shenlong-tanwen
e75d79bee3 more changes 2025-12-11 16:38:39 -06:00
Alex
04dbd8b3b1 chore: modify for testing 2025-12-11 15:19:27 -06:00
Alex
e809ffe37b Merge branch 'main' into feat/sync-cloud-id 2025-12-11 14:27:58 -06:00
Alex
4b345e02ff fix: refresh appear in list after asset is added to a current or new album (#24523) 2025-12-11 11:06:53 -06:00
Yaros
8cf900bafa fix(mobile): local videos with '#' don't play on android (#24373)
* fix(mobile): videos with '#' don't play on android

* refactor: one line

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix: depend on platform

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-12-11 10:57:37 -06:00
Yaros
59a3f0f455 feat(mobile): create new album from add to modal (#24431)
* feat(mobile): create new album from add to modal

* refactor: use statefulwidget instead of hook

* chore: rename createalbumbutton
2025-12-11 09:47:31 -06:00
Sergey Katsubo
c5d99711f7 fix(web): show inferred timezone in date editor (#24513)
fix(web): show inferred timezone of asset in date editor
2025-12-11 09:20:51 -06:00
Yaros
4c0a41723f feat(web): asset selection bar in tags view (#24522)
* feat(web): asset selection tab in tags view

* chore: remove unused imports
2025-12-11 15:20:29 +00:00
Bart van Velden
f73511a754 fix(docs): typo in maintenance mode command (#24518) 2025-12-11 09:19:33 -06:00
hubert-taieb
e637387082 fix(server): prevent metadata extraction failures on large video files (#24094)
* prevent  metadata extraction failures on large video files

Increases ExifTool timeout from 20s to 120s to prevent GPS metadata
extraction failures on large video files (>2GB, 10+ minutes).

Issue: Large videos timeout during metadata extraction, causing GPS
coordinates to be lost even though ExifTool can extract them given
enough time.

Testing: 2.6GB, 10:52min video that previously timed out now
successfully extracts GPS metadata.

* redundant comment

Increased task timeout for processing large videos.

* chore: lint

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-12-11 15:18:19 +00:00
renovate[bot]
baad38f0e6 fix(deps): update typescript-projects (#24476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-12-11 00:13:06 +00:00
Min Idzelis
161147af51 feat: timeline-manager improvement to use AssetResponseDto efficiently (#24421) 2025-12-11 01:07:31 +01:00
renovate[bot]
cbdf5011f9 chore(deps): update docker.io/valkey/valkey:9 docker digest to fb8d272 (#24474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:39 +01:00
renovate[bot]
f0f1d279c4 chore(deps): update prom/prometheus docker digest to d936808 (#24475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:20 +01:00
renovate[bot]
5821f2fe61 chore(deps): update github-actions (#24477)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 00:59:03 +01:00
shenlong-tanwen
7447d407eb remove button from sync status page 2025-12-05 23:24:45 +05:30
shenlong-tanwen
3a796f6a3d fix test 2025-12-05 23:23:27 +05:30
shenlong-tanwen
d47e9a5db4 add server version check & auto sync cloud ids on compatible servers 2025-12-04 22:36:24 +05:30
shenlong-tanwen
0a84783841 merge main 2025-12-04 21:56:35 +05:30
shenlong-tanwen
12febaf4a5 re-sync updated eTags 2025-12-04 02:26:35 +05:30
shenlong-tanwen
a2a5b7e758 more fixes 2025-12-04 02:09:52 +05:30
shenlong-tanwen
b06f591b7a fixes fixes 2025-12-03 23:42:32 +05:30
shenlong-tanwen
e76b6baf23 feat: sync cloudId and eTag on sync 2025-12-03 20:10:24 +05:30
shenlong-tanwen
44b50bfa23 fix equals check 2025-12-03 11:24:00 +05:30
shenlong-tanwen
d90d22f44a migration 2025-12-03 11:24:00 +05:30
shenlong-tanwen
3ec8e5b253 use adjustment time in iOS for hash reset 2025-12-03 11:23:59 +05:30
74 changed files with 11605 additions and 827 deletions

View File

@@ -108,7 +108,7 @@ jobs:
working-directory: ./mobile working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'

View File

@@ -44,7 +44,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

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

View File

@@ -69,7 +69,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './docs/.nvmrc' node-version-file: './docs/.nvmrc'
cache: 'pnpm' cache: 'pnpm'

View File

@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -32,7 +32,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'

View File

@@ -31,7 +31,7 @@ jobs:
- name: Generate a token - name: Generate a token
id: generate_token id: generate_token
if: ${{ inputs.skip != true }} if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,7 +49,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -68,7 +68,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -126,7 +126,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -144,7 +144,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
draft: true draft: true
tag_name: ${{ env.IMMICH_VERSION }} tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -36,7 +36,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -159,7 +159,7 @@ jobs:
- name: Create PR - name: Create PR
id: create-pr id: create-pr
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with: with:
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}' commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -52,7 +52,7 @@ jobs:
steps: steps:
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with: with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }} app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -80,7 +80,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }} github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release - name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
tag_name: ${{ steps.version.outputs.result }} tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }} token: ${{ steps.generate-token.outputs.token }}

View File

@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './open-api/typescript-sdk/.nvmrc' node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

@@ -77,7 +77,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -121,7 +121,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -168,7 +168,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './cli/.nvmrc' node-version-file: './cli/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -210,7 +210,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -254,7 +254,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -292,7 +292,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './web/.nvmrc' node-version-file: './web/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -340,7 +340,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -387,7 +387,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -426,7 +426,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -481,7 +481,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './e2e/.nvmrc' node-version-file: './e2e/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -617,7 +617,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './.github/.nvmrc' node-version-file: './.github/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -668,7 +668,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -730,7 +730,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version-file: './server/.nvmrc' node-version-file: './server/.nvmrc'
cache: 'pnpm' cache: 'pnpm'

View File

@@ -127,7 +127,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -83,7 +83,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:49214755b6153f90a597adcbff0252cc61069f8ab69ce8411285cd4a560e8038 image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus

View File

@@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27 image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@@ -52,7 +52,7 @@ Password login has been enabled.
Disable Maintenance Mode Disable Maintenance Mode
``` ```
immich-admin disable-maintenace-mode immich-admin disable-maintenance-mode
Maintenance mode has been disabled. Maintenance mode has been disabled.
``` ```

View File

@@ -2042,6 +2042,7 @@
"sync": "Sync", "sync": "Sync",
"sync_albums": "Sync albums", "sync_albums": "Sync albums",
"sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums",
"sync_cloud_ids": "Sync Cloud IDs",
"sync_local": "Sync Local", "sync_local": "Sync Local",
"sync_remote": "Sync Remote", "sync_remote": "Sync Remote",
"sync_status": "Sync Status", "sync_status": "Sync Status",

View File

@@ -315,6 +315,7 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit) fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing() fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>> fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): Map<String, String?>
companion object { companion object {
/** The codec used by NativeSyncApi. */ /** The codec used by NativeSyncApi. */
@@ -508,6 +509,23 @@ interface NativeSyncApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdsArg = args[0] as List<String>
val wrapped: List<Any?> = try {
listOf(api.getCloudIdForAssetIds(assetIdsArg))
} catch (exception: Throwable) {
MessagesPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
} }
} }
} }

View File

@@ -316,4 +316,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
hashTask?.cancel() hashTask?.cancel()
hashTask = null hashTask = null
} }
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): Map<String, String?> {
return emptyMap()
}
} }

File diff suppressed because one or more lines are too long

View File

@@ -377,6 +377,7 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]] func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [String: String?]
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -560,5 +561,22 @@ class NativeSyncApiSetup {
} else { } else {
getTrashedAssetsChannel.setMessageHandler(nil) getTrashedAssetsChannel.setMessageHandler(nil)
} }
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdsArg = args[0] as! [String]
do {
let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg)
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
} }
} }

View File

@@ -390,4 +390,22 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
return PHAsset.fetchAssets(in: album, options: options) return PHAsset.fetchAssets(in: album, options: options)
} }
} }
func getCloudIdForAssetIds(assetIds: [String]) throws -> [String : String?] {
guard #available(iOS 16, *) else {
return Dictionary(
uniqueKeysWithValues: assetIds.map { ($0, nil as String?) }
)
}
var mappings: [String: String?] = [:]
let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds)
for (key, value) in result {
// Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH"
if let cloudId = try? value.get().stringValue, !cloudId.hasSuffix(":") {
mappings[key] = cloudId
}
}
return mappings;
}
} }

View File

@@ -0,0 +1,62 @@
enum RemoteAssetMetadataKey {
mobileApp("mobile-app");
final String key;
const RemoteAssetMetadataKey(this.key);
}
abstract class RemoteAssetMetadataValue {
const RemoteAssetMetadataValue();
Map<String, dynamic> toJson();
}
class RemoteAssetMetadataItem {
final RemoteAssetMetadataKey key;
final RemoteAssetMetadataValue value;
const RemoteAssetMetadataItem({required this.key, required this.value});
Map<String, Object?> toJson() {
return {'key': key.key, 'value': value};
}
}
class RemoteAssetMobileAppMetadata extends RemoteAssetMetadataValue {
final String? cloudId;
final String? createdAt;
final String? adjustmentTime;
final String? latitude;
final String? longitude;
const RemoteAssetMobileAppMetadata({
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, dynamic> toJson() {
final map = <String, Object?>{};
if (cloudId != null) {
map["iCloudId"] = cloudId;
}
if (createdAt != null) {
map["createdAt"] = createdAt;
}
if (adjustmentTime != null) {
map["adjustmentTime"] = adjustmentTime;
}
if (latitude != null) {
map["latitude"] = latitude;
}
if (longitude != null) {
map["longitude"] = longitude;
}
return map;
}
}

View File

@@ -3,6 +3,7 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset { class LocalAsset extends BaseAsset {
final String id; final String id;
final String? remoteAssetId; final String? remoteAssetId;
final String? cloudId;
final int orientation; final int orientation;
final DateTime? adjustmentTime; final DateTime? adjustmentTime;
@@ -12,6 +13,7 @@ class LocalAsset extends BaseAsset {
const LocalAsset({ const LocalAsset({
required this.id, required this.id,
String? remoteId, String? remoteId,
this.cloudId,
required super.name, required super.name,
super.checksum, super.checksum,
required super.type, required super.type,
@@ -54,6 +56,8 @@ class LocalAsset extends BaseAsset {
height: ${height ?? "<NA>"}, height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"}, durationInSeconds: ${durationInSeconds ?? "<NA>"},
remoteId: ${remoteId ?? "<NA>"} remoteId: ${remoteId ?? "<NA>"}
cloudId: ${cloudId ?? "<NA>"}
checksum: ${checksum ?? "<NA>"},
isFavorite: $isFavorite, isFavorite: $isFavorite,
orientation: $orientation, orientation: $orientation,
adjustmentTime: $adjustmentTime, adjustmentTime: $adjustmentTime,
@@ -69,6 +73,7 @@ class LocalAsset extends BaseAsset {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return super == other && return super == other &&
id == other.id && id == other.id &&
cloudId == other.cloudId &&
orientation == other.orientation && orientation == other.orientation &&
adjustmentTime == other.adjustmentTime && adjustmentTime == other.adjustmentTime &&
latitude == other.latitude && latitude == other.latitude &&
@@ -88,6 +93,7 @@ class LocalAsset extends BaseAsset {
LocalAsset copyWith({ LocalAsset copyWith({
String? id, String? id,
String? remoteId, String? remoteId,
String? cloudId,
String? name, String? name,
String? checksum, String? checksum,
AssetType? type, AssetType? type,
@@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset {
return LocalAsset( return LocalAsset(
id: id ?? this.id, id: id ?? this.id,
remoteId: remoteId ?? this.remoteId, remoteId: remoteId ?? this.remoteId,
cloudId: cloudId ?? this.cloudId,
name: name ?? this.name, name: name ?? this.name,
checksum: checksum ?? this.checksum, checksum: checksum ?? this.checksum,
type: type ?? this.type, type: type ?? this.type,

View File

@@ -40,6 +40,9 @@ class HashService {
_log.info("Starting hashing of assets"); _log.info("Starting hashing of assets");
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
try { try {
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
await _migrateHashes();
// Sorted by backupSelection followed by isCloud // Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums(); final localAlbums = await _localAlbumRepository.getBackupAlbums();
@@ -75,6 +78,15 @@ class HashService {
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms"); _log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
} }
Future<void> _migrateHashes() async {
final hashMappings = await _localAssetRepository.getHashMappingFromCloudId();
if (hashMappings.isEmpty) {
return;
}
await _localAssetRepository.updateHashes(hashMappings);
}
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB /// 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 /// 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. /// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.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_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.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/platform/native_sync_api.g.dart';
@@ -18,6 +19,7 @@ import 'package:logging/logging.dart';
class LocalSyncService { class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository; final DriftLocalAlbumRepository _localAlbumRepository;
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi; final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final LocalFilesManagerRepository _localFilesManager; final LocalFilesManagerRepository _localFilesManager;
@@ -26,11 +28,13 @@ class LocalSyncService {
LocalSyncService({ LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository, required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository, required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required LocalFilesManagerRepository localFilesManager, required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository, required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi, required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository, }) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository, _trashedLocalAssetRepository = trashedLocalAssetRepository,
_localFilesManager = localFilesManager, _localFilesManager = localFilesManager,
_storageRepository = storageRepository, _storageRepository = storageRepository,
@@ -47,6 +51,12 @@ class LocalSyncService {
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing"); _log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
} }
} }
if (CurrentPlatform.isIOS) {
final assets = await _localAssetRepository.getEmptyCloudIdAssets();
await _mapIosCloudIds(assets);
}
if (full || await _nativeSyncApi.shouldFullSync()) { if (full || await _nativeSyncApi.shouldFullSync()) {
_log.fine("Full sync request from ${full ? "user" : "native"}"); _log.fine("Full sync request from ${full ? "user" : "native"}");
return await fullSync(); return await fullSync();
@@ -63,8 +73,9 @@ class LocalSyncService {
final deviceAlbums = await _nativeSyncApi.getAlbums(); final deviceAlbums = await _nativeSyncApi.getAlbums();
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums()); await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
final newAssets = delta.updates.toLocalAssets();
await _localAlbumRepository.processDelta( await _localAlbumRepository.processDelta(
updates: delta.updates.toLocalAssets(), updates: newAssets,
deletes: delta.deletes, deletes: delta.deletes,
assetAlbums: delta.assetAlbums, assetAlbums: delta.assetAlbums,
); );
@@ -92,6 +103,8 @@ class LocalSyncService {
} }
await updateAlbum(dbAlbum, album); await updateAlbum(dbAlbum, album);
} }
await _mapIosCloudIds(newAssets);
} }
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();
} catch (e, s) { } catch (e, s) {
@@ -130,9 +143,12 @@ class LocalSyncService {
try { try {
_log.fine("Adding device album ${album.name}"); _log.fine("Adding device album ${album.name}");
final assets = album.assetCount > 0 ? await _nativeSyncApi.getAssetsForAlbum(album.id) : <PlatformAsset>[]; final assets = album.assetCount > 0
? await _nativeSyncApi.getAssetsForAlbum(album.id).then((a) => a.toLocalAssets())
: <LocalAsset>[];
await _localAlbumRepository.upsert(album, toUpsert: assets.toLocalAssets()); await _localAlbumRepository.upsert(album, toUpsert: assets);
await _mapIosCloudIds(assets);
_log.fine("Successfully added device album ${album.name}"); _log.fine("Successfully added device album ${album.name}");
} catch (e, s) { } catch (e, s) {
_log.warning("Error while adding device album", e, s); _log.warning("Error while adding device album", e, s);
@@ -202,13 +218,16 @@ class LocalSyncService {
return false; return false;
} }
final newAssets = await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime); final newAssets = await _nativeSyncApi
.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime)
.then((a) => a.toLocalAssets());
await _localAlbumRepository.upsert( await _localAlbumRepository.upsert(
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection), deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
toUpsert: newAssets.toLocalAssets(), toUpsert: newAssets,
); );
await _mapIosCloudIds(newAssets);
return true; return true;
} catch (e, s) { } catch (e, s) {
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s); _log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
@@ -240,6 +259,7 @@ class LocalSyncService {
if (dbAlbum.assetCount == 0) { if (dbAlbum.assetCount == 0) {
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB."); _log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice); await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
await _mapIosCloudIds(assetsInDevice);
return true; return true;
} }
@@ -277,6 +297,7 @@ class LocalSyncService {
} }
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete); await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
await _mapIosCloudIds(assetsToUpsert);
return true; return true;
} catch (e, s) { } catch (e, s) {
@@ -285,6 +306,16 @@ class LocalSyncService {
return true; return true;
} }
Future<void> _mapIosCloudIds(List<LocalAsset> assets) async {
if (!CurrentPlatform.isIOS || assets.isEmpty) {
return;
}
final assetIds = assets.map((a) => a.id).toList();
final cloudMapping = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
await _localAlbumRepository.updateCloudMapping(cloudMapping);
}
bool _assetsEqual(LocalAsset a, LocalAsset b) { bool _assetsEqual(LocalAsset a, LocalAsset b) {
if (CurrentPlatform.isAndroid) { if (CurrentPlatform.isAndroid) {
return a.updatedAt.isAtSameMomentAs(b.updatedAt) && return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&

View File

@@ -118,6 +118,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsV1(data.cast()); return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1: case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast()); return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1:
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
case SyncEntityType.partnerAssetV1: case SyncEntityType.partnerAssetV1:
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner'); return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
case SyncEntityType.partnerAssetBackfillV1: case SyncEntityType.partnerAssetBackfillV1:

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:immich_mobile/domain/utils/migrate_cloud_ids.dart' as m;
import 'package:immich_mobile/domain/utils/sync_linked_album.dart'; import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart'; import 'package:immich_mobile/utils/isolate.dart';
@@ -22,8 +23,13 @@ class BackgroundSyncManager {
final SyncCallback? onHashingComplete; final SyncCallback? onHashingComplete;
final SyncErrorCallback? onHashingError; final SyncErrorCallback? onHashingError;
final SyncCallback? onCloudIdSyncStart;
final SyncCallback? onCloudIdSyncComplete;
final SyncErrorCallback? onCloudIdSyncError;
Cancelable<bool?>? _syncTask; Cancelable<bool?>? _syncTask;
Cancelable<void>? _syncWebsocketTask; Cancelable<void>? _syncWebsocketTask;
Cancelable<void>? _cloudIdSyncTask;
Cancelable<void>? _deviceAlbumSyncTask; Cancelable<void>? _deviceAlbumSyncTask;
Cancelable<void>? _linkedAlbumSyncTask; Cancelable<void>? _linkedAlbumSyncTask;
Cancelable<void>? _hashTask; Cancelable<void>? _hashTask;
@@ -38,6 +44,9 @@ class BackgroundSyncManager {
this.onHashingStart, this.onHashingStart,
this.onHashingComplete, this.onHashingComplete,
this.onHashingError, this.onHashingError,
this.onCloudIdSyncStart,
this.onCloudIdSyncComplete,
this.onCloudIdSyncError,
}); });
Future<void> cancel() async { Future<void> cancel() async {
@@ -55,6 +64,12 @@ class BackgroundSyncManager {
_syncWebsocketTask?.cancel(); _syncWebsocketTask?.cancel();
_syncWebsocketTask = null; _syncWebsocketTask = null;
if (_cloudIdSyncTask != null) {
futures.add(_cloudIdSyncTask!.future);
}
_cloudIdSyncTask?.cancel();
_cloudIdSyncTask = null;
if (_linkedAlbumSyncTask != null) { if (_linkedAlbumSyncTask != null) {
futures.add(_linkedAlbumSyncTask!.future); futures.add(_linkedAlbumSyncTask!.future);
} }
@@ -121,7 +136,6 @@ class BackgroundSyncManager {
}); });
} }
// No need to cancel the task, as it can also be run when the user logs out
Future<void> hashAssets() { Future<void> hashAssets() {
if (_hashTask != null) { if (_hashTask != null) {
return _hashTask!.future; return _hashTask!.future;
@@ -192,6 +206,25 @@ class BackgroundSyncManager {
_linkedAlbumSyncTask = null; _linkedAlbumSyncTask = null;
}); });
} }
Future<void> syncCloudIds() {
if (_cloudIdSyncTask != null) {
return _cloudIdSyncTask!.future;
}
onCloudIdSyncStart?.call();
_cloudIdSyncTask = runInIsolateGentle(computation: m.syncCloudIds);
return _cloudIdSyncTask!
.whenComplete(() {
onCloudIdSyncComplete?.call();
_cloudIdSyncTask = null;
})
.catchError((error) {
onCloudIdSyncError?.call(error.toString());
_cloudIdSyncTask = null;
});
}
} }
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle( Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(

View File

@@ -0,0 +1,93 @@
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart';
Future<void> syncCloudIds(ProviderContainer ref) async {
final db = ref.read(driftProvider);
// Populate cloud IDs for local assets that don't have one yet
await _populateCloudIds(db);
// Wait for remote sync to complete, so we have up-to-date asset metadata entries
await ref.read(syncStreamServiceProvider).sync();
// Fetch the mapping for backed up assets that have a cloud ID locally but do not have a cloud ID on the server
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
Logger('migrateCloudIds').warning('Current user is null. Aborting cloudId migration.');
return;
}
final mappingsToUpdate = await _fetchCloudIdMappings(db, currentUser.id);
final assetApi = ref.read(apiServiceProvider).assetsApi;
for (final mapping in mappingsToUpdate) {
final mobileMeta = AssetMetadataUpsertItemDto(
key: AssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: mapping.localAsset.cloudId,
createdAt: mapping.localAsset.createdAt.toIso8601String(),
adjustmentTime: mapping.localAsset.adjustmentTime?.toIso8601String(),
latitude: mapping.localAsset.latitude?.toString(),
longitude: mapping.localAsset.longitude?.toString(),
),
);
try {
await assetApi.updateAssetMetadata(mapping.remoteAssetId, AssetMetadataUpsertDto(items: [mobileMeta]));
} catch (error, stack) {
Logger('migrateCloudIds').warning('Failed to update metadata for asset ${mapping.remoteAssetId}', error, stack);
}
}
}
Future<void> _populateCloudIds(Drift drift) async {
final query = drift.localAssetEntity.selectOnly()
..addColumns([drift.localAssetEntity.id])
..where(drift.localAssetEntity.iCloudId.isNull());
final ids = await query.map((row) => row.read(drift.localAssetEntity.id)!).get();
final cloudMapping = await NativeSyncApi().getCloudIdForAssetIds(ids);
await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping);
}
typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset});
Future<List<_CloudIdMapping>> _fetchCloudIdMappings(Drift drift, String userId) async {
final query =
drift.remoteAssetEntity.select().join([
leftOuterJoin(
drift.localAssetEntity,
drift.localAssetEntity.checksum.equalsExp(drift.remoteAssetEntity.checksum),
),
leftOuterJoin(
drift.remoteAssetCloudIdEntity,
drift.remoteAssetEntity.id.equalsExp(drift.remoteAssetCloudIdEntity.assetId),
useColumns: false,
),
])..where(
// Only select assets that have a local cloud ID but either no remote cloud ID or a mismatched eTag
drift.localAssetEntity.id.isNotNull() &
drift.localAssetEntity.iCloudId.isNotNull() &
drift.remoteAssetEntity.ownerId.equals(userId) &
(drift.remoteAssetCloudIdEntity.cloudId.isNull() |
((drift.remoteAssetCloudIdEntity.adjustmentTime.isNotExp(drift.localAssetEntity.adjustmentTime)) &
(drift.remoteAssetCloudIdEntity.latitude.isNotExp(drift.localAssetEntity.latitude)) &
(drift.remoteAssetCloudIdEntity.longitude.isNotExp(drift.localAssetEntity.longitude)) &
(drift.remoteAssetCloudIdEntity.createdAt.isNotExp(drift.localAssetEntity.createdAt)))),
);
return query.map((row) {
return (
remoteAssetId: row.read(drift.remoteAssetEntity.id)!,
localAsset: row.readTable(drift.localAssetEntity).toDto(),
);
}).get();
}

View File

@@ -0,0 +1,8 @@
import 'dart:math' as math;
extension DoubleTruncate on double {
double truncateTo(int fractionDigits) {
final mod = math.pow(10.0, fractionDigits);
return ((this * mod).truncate() / mod);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity(); const LocalAssetEntity();
@@ -16,6 +17,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get orientation => integer().withDefault(const Constant(0))(); IntColumn get orientation => integer().withDefault(const Constant(0))();
TextColumn get iCloudId => text().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()(); DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()(); RealColumn get latitude => real().nullable()();
@@ -43,5 +46,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
adjustmentTime: adjustmentTime, adjustmentTime: adjustmentTime,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
cloudId: iCloudId,
); );
} }

View File

@@ -21,6 +21,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
i0.Value<String?> iCloudId,
i0.Value<DateTime?> adjustmentTime, i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude, i0.Value<double?> latitude,
i0.Value<double?> longitude, i0.Value<double?> longitude,
@@ -38,6 +39,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
i0.Value<String?> iCloudId,
i0.Value<DateTime?> adjustmentTime, i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude, i0.Value<double?> latitude,
i0.Value<double?> longitude, i0.Value<double?> longitude,
@@ -108,6 +110,11 @@ class $$LocalAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i0.ColumnFilters<String> get iCloudId => $composableBuilder(
column: $table.iCloudId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder( i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime, column: $table.adjustmentTime,
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
@@ -188,6 +195,11 @@ class $$LocalAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i0.ColumnOrderings<String> get iCloudId => $composableBuilder(
column: $table.iCloudId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder( i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime, column: $table.adjustmentTime,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
@@ -252,6 +264,9 @@ class $$LocalAssetEntityTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
i0.GeneratedColumn<String> get iCloudId =>
$composableBuilder(column: $table.iCloudId, builder: (column) => column);
i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder( i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime, column: $table.adjustmentTime,
builder: (column) => column, builder: (column) => column,
@@ -315,6 +330,7 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(), i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(), i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(), i0.Value<double?> longitude = const i0.Value.absent(),
@@ -330,6 +346,7 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
iCloudId: iCloudId,
adjustmentTime: adjustmentTime, adjustmentTime: adjustmentTime,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
@@ -347,6 +364,7 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(), i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(), i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(), i0.Value<double?> longitude = const i0.Value.absent(),
@@ -362,6 +380,7 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
iCloudId: iCloudId,
adjustmentTime: adjustmentTime, adjustmentTime: adjustmentTime,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
@@ -532,6 +551,17 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false, requiredDuringInsert: false,
defaultValue: const i4.Constant(0), defaultValue: const i4.Constant(0),
); );
static const i0.VerificationMeta _iCloudIdMeta = const i0.VerificationMeta(
'iCloudId',
);
@override
late final i0.GeneratedColumn<String> iCloudId = i0.GeneratedColumn<String>(
'i_cloud_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _adjustmentTimeMeta = static const i0.VerificationMeta _adjustmentTimeMeta =
const i0.VerificationMeta('adjustmentTime'); const i0.VerificationMeta('adjustmentTime');
@override @override
@@ -578,6 +608,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
iCloudId,
adjustmentTime, adjustmentTime,
latitude, latitude,
longitude, longitude,
@@ -661,6 +692,12 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
), ),
); );
} }
if (data.containsKey('i_cloud_id')) {
context.handle(
_iCloudIdMeta,
iCloudId.isAcceptableOrUnknown(data['i_cloud_id']!, _iCloudIdMeta),
);
}
if (data.containsKey('adjustment_time')) { if (data.containsKey('adjustment_time')) {
context.handle( context.handle(
_adjustmentTimeMeta, _adjustmentTimeMeta,
@@ -740,6 +777,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.int, i0.DriftSqlType.int,
data['${effectivePrefix}orientation'], data['${effectivePrefix}orientation'],
)!, )!,
iCloudId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}i_cloud_id'],
),
adjustmentTime: attachedDatabase.typeMapping.read( adjustmentTime: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime, i0.DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'], data['${effectivePrefix}adjustment_time'],
@@ -781,6 +822,7 @@ class LocalAssetEntityData extends i0.DataClass
final String? checksum; final String? checksum;
final bool isFavorite; final bool isFavorite;
final int orientation; final int orientation;
final String? iCloudId;
final DateTime? adjustmentTime; final DateTime? adjustmentTime;
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
@@ -796,6 +838,7 @@ class LocalAssetEntityData extends i0.DataClass
this.checksum, this.checksum,
required this.isFavorite, required this.isFavorite,
required this.orientation, required this.orientation,
this.iCloudId,
this.adjustmentTime, this.adjustmentTime,
this.latitude, this.latitude,
this.longitude, this.longitude,
@@ -826,6 +869,9 @@ class LocalAssetEntityData extends i0.DataClass
} }
map['is_favorite'] = i0.Variable<bool>(isFavorite); map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['orientation'] = i0.Variable<int>(orientation); map['orientation'] = i0.Variable<int>(orientation);
if (!nullToAbsent || iCloudId != null) {
map['i_cloud_id'] = i0.Variable<String>(iCloudId);
}
if (!nullToAbsent || adjustmentTime != null) { if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime); map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime);
} }
@@ -857,6 +903,7 @@ class LocalAssetEntityData extends i0.DataClass
checksum: serializer.fromJson<String?>(json['checksum']), checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']), isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']), orientation: serializer.fromJson<int>(json['orientation']),
iCloudId: serializer.fromJson<String?>(json['iCloudId']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']), adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']), latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']), longitude: serializer.fromJson<double?>(json['longitude']),
@@ -879,6 +926,7 @@ class LocalAssetEntityData extends i0.DataClass
'checksum': serializer.toJson<String?>(checksum), 'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite), 'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation), 'orientation': serializer.toJson<int>(orientation),
'iCloudId': serializer.toJson<String?>(iCloudId),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime), 'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude), 'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude), 'longitude': serializer.toJson<double?>(longitude),
@@ -897,6 +945,7 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite, bool? isFavorite,
int? orientation, int? orientation,
i0.Value<String?> iCloudId = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(), i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(), i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(), i0.Value<double?> longitude = const i0.Value.absent(),
@@ -914,6 +963,7 @@ class LocalAssetEntityData extends i0.DataClass
checksum: checksum.present ? checksum.value : this.checksum, checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
iCloudId: iCloudId.present ? iCloudId.value : this.iCloudId,
adjustmentTime: adjustmentTime.present adjustmentTime: adjustmentTime.present
? adjustmentTime.value ? adjustmentTime.value
: this.adjustmentTime, : this.adjustmentTime,
@@ -939,6 +989,7 @@ class LocalAssetEntityData extends i0.DataClass
orientation: data.orientation.present orientation: data.orientation.present
? data.orientation.value ? data.orientation.value
: this.orientation, : this.orientation,
iCloudId: data.iCloudId.present ? data.iCloudId.value : this.iCloudId,
adjustmentTime: data.adjustmentTime.present adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value ? data.adjustmentTime.value
: this.adjustmentTime, : this.adjustmentTime,
@@ -961,6 +1012,7 @@ class LocalAssetEntityData extends i0.DataClass
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ') ..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ') ..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ') ..write('latitude: $latitude, ')
..write('longitude: $longitude') ..write('longitude: $longitude')
@@ -981,6 +1033,7 @@ class LocalAssetEntityData extends i0.DataClass
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
iCloudId,
adjustmentTime, adjustmentTime,
latitude, latitude,
longitude, longitude,
@@ -1000,6 +1053,7 @@ class LocalAssetEntityData extends i0.DataClass
other.checksum == this.checksum && other.checksum == this.checksum &&
other.isFavorite == this.isFavorite && other.isFavorite == this.isFavorite &&
other.orientation == this.orientation && other.orientation == this.orientation &&
other.iCloudId == this.iCloudId &&
other.adjustmentTime == this.adjustmentTime && other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude && other.latitude == this.latitude &&
other.longitude == this.longitude); other.longitude == this.longitude);
@@ -1018,6 +1072,7 @@ class LocalAssetEntityCompanion
final i0.Value<String?> checksum; final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite; final i0.Value<bool> isFavorite;
final i0.Value<int> orientation; final i0.Value<int> orientation;
final i0.Value<String?> iCloudId;
final i0.Value<DateTime?> adjustmentTime; final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude; final i0.Value<double?> latitude;
final i0.Value<double?> longitude; final i0.Value<double?> longitude;
@@ -1033,6 +1088,7 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
this.iCloudId = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(), this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(), this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(), this.longitude = const i0.Value.absent(),
@@ -1049,6 +1105,7 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
this.iCloudId = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(), this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(), this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(), this.longitude = const i0.Value.absent(),
@@ -1067,6 +1124,7 @@ class LocalAssetEntityCompanion
i0.Expression<String>? checksum, i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite, i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation, i0.Expression<int>? orientation,
i0.Expression<String>? iCloudId,
i0.Expression<DateTime>? adjustmentTime, i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude, i0.Expression<double>? latitude,
i0.Expression<double>? longitude, i0.Expression<double>? longitude,
@@ -1083,6 +1141,7 @@ class LocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum, if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite, if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation, if (orientation != null) 'orientation': orientation,
if (iCloudId != null) 'i_cloud_id': iCloudId,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime, if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude, if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude, if (longitude != null) 'longitude': longitude,
@@ -1101,6 +1160,7 @@ class LocalAssetEntityCompanion
i0.Value<String?>? checksum, i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite, i0.Value<bool>? isFavorite,
i0.Value<int>? orientation, i0.Value<int>? orientation,
i0.Value<String?>? iCloudId,
i0.Value<DateTime?>? adjustmentTime, i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude, i0.Value<double?>? latitude,
i0.Value<double?>? longitude, i0.Value<double?>? longitude,
@@ -1117,6 +1177,7 @@ class LocalAssetEntityCompanion
checksum: checksum ?? this.checksum, checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
iCloudId: iCloudId ?? this.iCloudId,
adjustmentTime: adjustmentTime ?? this.adjustmentTime, adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
@@ -1161,6 +1222,9 @@ class LocalAssetEntityCompanion
if (orientation.present) { if (orientation.present) {
map['orientation'] = i0.Variable<int>(orientation.value); map['orientation'] = i0.Variable<int>(orientation.value);
} }
if (iCloudId.present) {
map['i_cloud_id'] = i0.Variable<String>(iCloudId.value);
}
if (adjustmentTime.present) { if (adjustmentTime.present) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value); map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value);
} }
@@ -1187,6 +1251,7 @@ class LocalAssetEntityCompanion
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation, ') ..write('orientation: $orientation, ')
..write('iCloudId: $iCloudId, ')
..write('adjustmentTime: $adjustmentTime, ') ..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ') ..write('latitude: $latitude, ')
..write('longitude: $longitude') ..write('longitude: $longitude')
@@ -1194,3 +1259,8 @@ class LocalAssetEntityCompanion
.toString(); .toString();
} }
} }
i0.Index get idxLocalAssetCloudId => i0.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);

View File

@@ -21,7 +21,11 @@ SELECT
rae.owner_id, rae.owner_id,
rae.live_photo_video_id, rae.live_photo_video_id,
0 as orientation, 0 as orientation,
rae.stack_id rae.stack_id,
NULL as i_cloud_id,
NULL as latitude,
NULL as longitude,
NULL as adjustmentTime
FROM FROM
remote_asset_entity rae remote_asset_entity rae
LEFT JOIN LEFT JOIN
@@ -53,7 +57,11 @@ SELECT
NULL as owner_id, NULL as owner_id,
NULL as live_photo_video_id, NULL as live_photo_video_id,
lae.orientation, lae.orientation,
NULL as stack_id NULL as stack_id,
lae.i_cloud_id,
lae.latitude,
lae.longitude,
lae.adjustment_time
FROM FROM
local_asset_entity lae local_asset_entity lae
WHERE NOT EXISTS ( WHERE NOT EXISTS (

View File

@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
); );
$arrayStartIndex += generatedlimit.amountOfVariables; $arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect( 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_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id 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_in_seconds, 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 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_in_seconds, 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 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_in_seconds, 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 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}',
variables: [ variables: [
for (var $ in userIds) i0.Variable<String>($), for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables, ...generatedlimit.introducedVariables,
@@ -62,6 +62,10 @@ class MergedAssetDrift extends i1.ModularAccessor {
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'), livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
orientation: row.read<int>('orientation'), orientation: row.read<int>('orientation'),
stackId: row.readNullable<String>('stack_id'), stackId: row.readNullable<String>('stack_id'),
iCloudId: row.readNullable<String>('i_cloud_id'),
latitude: row.readNullable<double>('latitude'),
longitude: row.readNullable<double>('longitude'),
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
), ),
); );
} }
@@ -129,6 +133,10 @@ class MergedAssetResult {
final String? livePhotoVideoId; final String? livePhotoVideoId;
final int orientation; final int orientation;
final String? stackId; final String? stackId;
final String? iCloudId;
final double? latitude;
final double? longitude;
final DateTime? adjustmentTime;
MergedAssetResult({ MergedAssetResult({
this.remoteId, this.remoteId,
this.localId, this.localId,
@@ -146,6 +154,10 @@ class MergedAssetResult {
this.livePhotoVideoId, this.livePhotoVideoId,
required this.orientation, required this.orientation,
this.stackId, this.stackId,
this.iCloudId,
this.latitude,
this.longitude,
this.adjustmentTime,
}); });
} }

View File

@@ -0,0 +1,20 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class RemoteAssetCloudIdEntity extends Table with DriftDefaultsMixin {
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
TextColumn get cloudId => text().unique().nullable()();
DateTimeColumn get createdAt => dateTime().nullable()();
DateTimeColumn get adjustmentTime => dateTime().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();
@override
Set<Column> get primaryKey => {assetId};
}

View File

@@ -0,0 +1,827 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart'
as i2;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i3;
import 'package:drift/internal/modular.dart' as i4;
typedef $$RemoteAssetCloudIdEntityTableCreateCompanionBuilder =
i1.RemoteAssetCloudIdEntityCompanion Function({
required String assetId,
i0.Value<String?> cloudId,
i0.Value<DateTime?> createdAt,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
typedef $$RemoteAssetCloudIdEntityTableUpdateCompanionBuilder =
i1.RemoteAssetCloudIdEntityCompanion Function({
i0.Value<String> assetId,
i0.Value<String?> cloudId,
i0.Value<DateTime?> createdAt,
i0.Value<DateTime?> adjustmentTime,
i0.Value<double?> latitude,
i0.Value<double?> longitude,
});
final class $$RemoteAssetCloudIdEntityTableReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData
> {
$$RemoteAssetCloudIdEntityTableReferences(
super.$_db,
super.$_table,
super.$_typedResult,
);
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAssetCloudIdEntityTable>(
'remote_asset_cloud_id_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i3
.$$RemoteAssetEntityTableTableManager(
$_db,
i4.ReadDatabaseContainer(
$_db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $$RemoteAssetCloudIdEntityTableFilterComposer
extends
i0.Composer<i0.GeneratedDatabase, i1.$RemoteAssetCloudIdEntityTable> {
$$RemoteAssetCloudIdEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnFilters(column),
);
i3.$$RemoteAssetEntityTableFilterComposer get assetId {
final i3.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$RemoteAssetCloudIdEntityTableOrderingComposer
extends
i0.Composer<i0.GeneratedDatabase, i1.$RemoteAssetCloudIdEntityTable> {
$$RemoteAssetCloudIdEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get cloudId => $composableBuilder(
column: $table.cloudId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get latitude => $composableBuilder(
column: $table.latitude,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<double> get longitude => $composableBuilder(
column: $table.longitude,
builder: (column) => i0.ColumnOrderings(column),
);
i3.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i3.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$RemoteAssetCloudIdEntityTableAnnotationComposer
extends
i0.Composer<i0.GeneratedDatabase, i1.$RemoteAssetCloudIdEntityTable> {
$$RemoteAssetCloudIdEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get cloudId =>
$composableBuilder(column: $table.cloudId, builder: (column) => column);
i0.GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
i0.GeneratedColumn<DateTime> get adjustmentTime => $composableBuilder(
column: $table.adjustmentTime,
builder: (column) => column,
);
i0.GeneratedColumn<double> get latitude =>
$composableBuilder(column: $table.latitude, builder: (column) => column);
i0.GeneratedColumn<double> get longitude =>
$composableBuilder(column: $table.longitude, builder: (column) => column);
i3.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i3.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i3.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i4.ReadDatabaseContainer(
$db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$RemoteAssetCloudIdEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableFilterComposer,
i1.$$RemoteAssetCloudIdEntityTableOrderingComposer,
i1.$$RemoteAssetCloudIdEntityTableAnnotationComposer,
$$RemoteAssetCloudIdEntityTableCreateCompanionBuilder,
$$RemoteAssetCloudIdEntityTableUpdateCompanionBuilder,
(
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableReferences,
),
i1.RemoteAssetCloudIdEntityData,
i0.PrefetchHooks Function({bool assetId})
> {
$$RemoteAssetCloudIdEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$RemoteAssetCloudIdEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$RemoteAssetCloudIdEntityTableFilterComposer(
$db: db,
$table: table,
),
createOrderingComposer: () =>
i1.$$RemoteAssetCloudIdEntityTableOrderingComposer(
$db: db,
$table: table,
),
createComputedFieldComposer: () =>
i1.$$RemoteAssetCloudIdEntityTableAnnotationComposer(
$db: db,
$table: table,
),
updateCompanionCallback:
({
i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<String?> cloudId = const i0.Value.absent(),
i0.Value<DateTime?> createdAt = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.RemoteAssetCloudIdEntityCompanion(
assetId: assetId,
cloudId: cloudId,
createdAt: createdAt,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
createCompanionCallback:
({
required String assetId,
i0.Value<String?> cloudId = const i0.Value.absent(),
i0.Value<DateTime?> createdAt = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.RemoteAssetCloudIdEntityCompanion.insert(
assetId: assetId,
cloudId: cloudId,
createdAt: createdAt,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
withReferenceMapper: (p0) => p0
.map(
(e) => (
e.readTable(table),
i1.$$RemoteAssetCloudIdEntityTableReferences(db, table, e),
),
)
.toList(),
prefetchHooksCallback: ({assetId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (assetId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.assetId,
referencedTable: i1
.$$RemoteAssetCloudIdEntityTableReferences
._assetIdTable(db),
referencedColumn: i1
.$$RemoteAssetCloudIdEntityTableReferences
._assetIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
),
);
}
typedef $$RemoteAssetCloudIdEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableFilterComposer,
i1.$$RemoteAssetCloudIdEntityTableOrderingComposer,
i1.$$RemoteAssetCloudIdEntityTableAnnotationComposer,
$$RemoteAssetCloudIdEntityTableCreateCompanionBuilder,
$$RemoteAssetCloudIdEntityTableUpdateCompanionBuilder,
(
i1.RemoteAssetCloudIdEntityData,
i1.$$RemoteAssetCloudIdEntityTableReferences,
),
i1.RemoteAssetCloudIdEntityData,
i0.PrefetchHooks Function({bool assetId})
>;
class $RemoteAssetCloudIdEntityTable extends i2.RemoteAssetCloudIdEntity
with
i0.TableInfo<
$RemoteAssetCloudIdEntityTable,
i1.RemoteAssetCloudIdEntityData
> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$RemoteAssetCloudIdEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
'assetId',
);
@override
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
),
);
static const i0.VerificationMeta _cloudIdMeta = const i0.VerificationMeta(
'cloudId',
);
@override
late final i0.GeneratedColumn<String> cloudId = i0.GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways('UNIQUE'),
);
static const i0.VerificationMeta _createdAtMeta = const i0.VerificationMeta(
'createdAt',
);
@override
late final i0.GeneratedColumn<DateTime> createdAt =
i0.GeneratedColumn<DateTime>(
'created_at',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _adjustmentTimeMeta =
const i0.VerificationMeta('adjustmentTime');
@override
late final i0.GeneratedColumn<DateTime> adjustmentTime =
i0.GeneratedColumn<DateTime>(
'adjustment_time',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _latitudeMeta = const i0.VerificationMeta(
'latitude',
);
@override
late final i0.GeneratedColumn<double> latitude = i0.GeneratedColumn<double>(
'latitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _longitudeMeta = const i0.VerificationMeta(
'longitude',
);
@override
late final i0.GeneratedColumn<double> longitude = i0.GeneratedColumn<double>(
'longitude',
aliasedName,
true,
type: i0.DriftSqlType.double,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'remote_asset_cloud_id_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.RemoteAssetCloudIdEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('asset_id')) {
context.handle(
_assetIdMeta,
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
);
} else if (isInserting) {
context.missing(_assetIdMeta);
}
if (data.containsKey('cloud_id')) {
context.handle(
_cloudIdMeta,
cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
}
if (data.containsKey('adjustment_time')) {
context.handle(
_adjustmentTimeMeta,
adjustmentTime.isAcceptableOrUnknown(
data['adjustment_time']!,
_adjustmentTimeMeta,
),
);
}
if (data.containsKey('latitude')) {
context.handle(
_latitudeMeta,
latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta),
);
}
if (data.containsKey('longitude')) {
context.handle(
_longitudeMeta,
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {assetId};
@override
i1.RemoteAssetCloudIdEntityData map(
Map<String, dynamic> data, {
String? tablePrefix,
}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.RemoteAssetCloudIdEntityData(
assetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}asset_id'],
)!,
cloudId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}cloud_id'],
),
createdAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
),
adjustmentTime: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}adjustment_time'],
),
latitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}latitude'],
),
longitude: attachedDatabase.typeMapping.read(
i0.DriftSqlType.double,
data['${effectivePrefix}longitude'],
),
);
}
@override
$RemoteAssetCloudIdEntityTable createAlias(String alias) {
return $RemoteAssetCloudIdEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class RemoteAssetCloudIdEntityData extends i0.DataClass
implements i0.Insertable<i1.RemoteAssetCloudIdEntityData> {
final String assetId;
final String? cloudId;
final DateTime? createdAt;
final DateTime? adjustmentTime;
final double? latitude;
final double? longitude;
const RemoteAssetCloudIdEntityData({
required this.assetId,
this.cloudId,
this.createdAt,
this.adjustmentTime,
this.latitude,
this.longitude,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['asset_id'] = i0.Variable<String>(assetId);
if (!nullToAbsent || cloudId != null) {
map['cloud_id'] = i0.Variable<String>(cloudId);
}
if (!nullToAbsent || createdAt != null) {
map['created_at'] = i0.Variable<DateTime>(createdAt);
}
if (!nullToAbsent || adjustmentTime != null) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime);
}
if (!nullToAbsent || latitude != null) {
map['latitude'] = i0.Variable<double>(latitude);
}
if (!nullToAbsent || longitude != null) {
map['longitude'] = i0.Variable<double>(longitude);
}
return map;
}
factory RemoteAssetCloudIdEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return RemoteAssetCloudIdEntityData(
assetId: serializer.fromJson<String>(json['assetId']),
cloudId: serializer.fromJson<String?>(json['cloudId']),
createdAt: serializer.fromJson<DateTime?>(json['createdAt']),
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
latitude: serializer.fromJson<double?>(json['latitude']),
longitude: serializer.fromJson<double?>(json['longitude']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId),
'cloudId': serializer.toJson<String?>(cloudId),
'createdAt': serializer.toJson<DateTime?>(createdAt),
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
'latitude': serializer.toJson<double?>(latitude),
'longitude': serializer.toJson<double?>(longitude),
};
}
i1.RemoteAssetCloudIdEntityData copyWith({
String? assetId,
i0.Value<String?> cloudId = const i0.Value.absent(),
i0.Value<DateTime?> createdAt = const i0.Value.absent(),
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
}) => i1.RemoteAssetCloudIdEntityData(
assetId: assetId ?? this.assetId,
cloudId: cloudId.present ? cloudId.value : this.cloudId,
createdAt: createdAt.present ? createdAt.value : this.createdAt,
adjustmentTime: adjustmentTime.present
? adjustmentTime.value
: this.adjustmentTime,
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
);
RemoteAssetCloudIdEntityData copyWithCompanion(
i1.RemoteAssetCloudIdEntityCompanion data,
) {
return RemoteAssetCloudIdEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId,
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
adjustmentTime: data.adjustmentTime.present
? data.adjustmentTime.value
: this.adjustmentTime,
latitude: data.latitude.present ? data.latitude.value : this.latitude,
longitude: data.longitude.present ? data.longitude.value : this.longitude,
);
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityData(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
assetId,
cloudId,
createdAt,
adjustmentTime,
latitude,
longitude,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.RemoteAssetCloudIdEntityData &&
other.assetId == this.assetId &&
other.cloudId == this.cloudId &&
other.createdAt == this.createdAt &&
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude);
}
class RemoteAssetCloudIdEntityCompanion
extends i0.UpdateCompanion<i1.RemoteAssetCloudIdEntityData> {
final i0.Value<String> assetId;
final i0.Value<String?> cloudId;
final i0.Value<DateTime?> createdAt;
final i0.Value<DateTime?> adjustmentTime;
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
const RemoteAssetCloudIdEntityCompanion({
this.assetId = const i0.Value.absent(),
this.cloudId = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
});
RemoteAssetCloudIdEntityCompanion.insert({
required String assetId,
this.cloudId = const i0.Value.absent(),
this.createdAt = const i0.Value.absent(),
this.adjustmentTime = const i0.Value.absent(),
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
}) : assetId = i0.Value(assetId);
static i0.Insertable<i1.RemoteAssetCloudIdEntityData> custom({
i0.Expression<String>? assetId,
i0.Expression<String>? cloudId,
i0.Expression<DateTime>? createdAt,
i0.Expression<DateTime>? adjustmentTime,
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
}) {
return i0.RawValuesInsertable({
if (assetId != null) 'asset_id': assetId,
if (cloudId != null) 'cloud_id': cloudId,
if (createdAt != null) 'created_at': createdAt,
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
});
}
i1.RemoteAssetCloudIdEntityCompanion copyWith({
i0.Value<String>? assetId,
i0.Value<String?>? cloudId,
i0.Value<DateTime?>? createdAt,
i0.Value<DateTime?>? adjustmentTime,
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
}) {
return i1.RemoteAssetCloudIdEntityCompanion(
assetId: assetId ?? this.assetId,
cloudId: cloudId ?? this.cloudId,
createdAt: createdAt ?? this.createdAt,
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (assetId.present) {
map['asset_id'] = i0.Variable<String>(assetId.value);
}
if (cloudId.present) {
map['cloud_id'] = i0.Variable<String>(cloudId.value);
}
if (createdAt.present) {
map['created_at'] = i0.Variable<DateTime>(createdAt.value);
}
if (adjustmentTime.present) {
map['adjustment_time'] = i0.Variable<DateTime>(adjustmentTime.value);
}
if (latitude.present) {
map['latitude'] = i0.Variable<double>(latitude.value);
}
if (longitude.present) {
map['longitude'] = i0.Variable<double>(longitude.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('RemoteAssetCloudIdEntityCompanion(')
..write('assetId: $assetId, ')
..write('cloudId: $cloudId, ')
..write('createdAt: $createdAt, ')
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude')
..write(')'))
.toString();
}
}

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart';
@@ -57,6 +58,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
RemoteAlbumEntity, RemoteAlbumEntity,
RemoteAlbumAssetEntity, RemoteAlbumAssetEntity,
RemoteAlbumUserEntity, RemoteAlbumUserEntity,
RemoteAssetCloudIdEntity,
MemoryEntity, MemoryEntity,
MemoryAssetEntity, MemoryAssetEntity,
StackEntity, StackEntity,
@@ -95,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 14; int get schemaVersion => 15;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -190,6 +192,12 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude); await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
}, },
from14To15: (m, v15) async {
// Add i_cloud_id to local and remote asset tables
await m.addColumn(v15.localAssetEntity, v15.localAssetEntity.iCloudId);
await m.createIndex(v15.idxLocalAssetCloudId);
await m.createTable(v15.remoteAssetCloudIdEntity);
},
), ),
); );

View File

@@ -27,21 +27,23 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
as i12; as i12;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i13; as i13;
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart'
as i14; as i14;
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
as i15; as i15;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
as i16; as i16;
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i17; as i17;
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'
as i18; as i18;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19; as i19;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20; as i20;
import 'package:drift/internal/modular.dart' as i21; import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i21;
import 'package:drift/internal/modular.dart' as i22;
abstract class $Drift extends i0.GeneratedDatabase { abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e); $Drift(i0.QueryExecutor e) : super(e);
@@ -72,18 +74,20 @@ abstract class $Drift extends i0.GeneratedDatabase {
.$RemoteAlbumAssetEntityTable(this); .$RemoteAlbumAssetEntityTable(this);
late final i13.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i13 late final i13.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i13
.$RemoteAlbumUserEntityTable(this); .$RemoteAlbumUserEntityTable(this);
late final i14.$MemoryEntityTable memoryEntity = i14.$MemoryEntityTable(this); late final i14.$RemoteAssetCloudIdEntityTable remoteAssetCloudIdEntity = i14
late final i15.$MemoryAssetEntityTable memoryAssetEntity = i15 .$RemoteAssetCloudIdEntityTable(this);
late final i15.$MemoryEntityTable memoryEntity = i15.$MemoryEntityTable(this);
late final i16.$MemoryAssetEntityTable memoryAssetEntity = i16
.$MemoryAssetEntityTable(this); .$MemoryAssetEntityTable(this);
late final i16.$PersonEntityTable personEntity = i16.$PersonEntityTable(this); late final i17.$PersonEntityTable personEntity = i17.$PersonEntityTable(this);
late final i17.$AssetFaceEntityTable assetFaceEntity = i17 late final i18.$AssetFaceEntityTable assetFaceEntity = i18
.$AssetFaceEntityTable(this); .$AssetFaceEntityTable(this);
late final i18.$StoreEntityTable storeEntity = i18.$StoreEntityTable(this); late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i19.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i19 late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this); .$TrashedLocalAssetEntityTable(this);
i20.MergedAssetDrift get mergedAssetDrift => i21.ReadDatabaseContainer( i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
this, this,
).accessor<i20.MergedAssetDrift>(i20.MergedAssetDrift.new); ).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
@override @override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables => Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>(); allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -97,6 +101,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
localAlbumEntity, localAlbumEntity,
localAlbumAssetEntity, localAlbumAssetEntity,
i4.idxLocalAssetChecksum, i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i2.idxRemoteAssetOwnerChecksum, i2.idxRemoteAssetOwnerChecksum,
i2.uQRemoteAssetsOwnerChecksum, i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum, i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -107,6 +112,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteExifEntity, remoteExifEntity,
remoteAlbumAssetEntity, remoteAlbumAssetEntity,
remoteAlbumUserEntity, remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity, memoryEntity,
memoryAssetEntity, memoryAssetEntity,
personEntity, personEntity,
@@ -114,8 +120,8 @@ abstract class $Drift extends i0.GeneratedDatabase {
storeEntity, storeEntity,
trashedLocalAssetEntity, trashedLocalAssetEntity,
i11.idxLatLng, i11.idxLatLng,
i19.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetChecksum,
i19.idxTrashedLocalAssetAlbum, i20.idxTrashedLocalAssetAlbum,
]; ];
@override @override
i0.StreamQueryUpdateRules i0.StreamQueryUpdateRules
@@ -249,6 +255,18 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('remote_album_user_entity', kind: i0.UpdateKind.delete), i0.TableUpdate('remote_album_user_entity', kind: i0.UpdateKind.delete),
], ],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate(
'remote_asset_cloud_id_entity',
kind: i0.UpdateKind.delete,
),
],
),
i0.WritePropagation( i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName( on: i0.TableUpdateQuery.onTableName(
'user_entity', 'user_entity',
@@ -333,18 +351,24 @@ class $DriftManager {
); );
i13.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i13 i13.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i13
.$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity); .$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity);
i14.$$MemoryEntityTableTableManager get memoryEntity => i14.$$RemoteAssetCloudIdEntityTableTableManager
i14.$$MemoryEntityTableTableManager(_db, _db.memoryEntity); get remoteAssetCloudIdEntity =>
i15.$$MemoryAssetEntityTableTableManager get memoryAssetEntity => i14.$$RemoteAssetCloudIdEntityTableTableManager(
i15.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity); _db,
i16.$$PersonEntityTableTableManager get personEntity => _db.remoteAssetCloudIdEntity,
i16.$$PersonEntityTableTableManager(_db, _db.personEntity); );
i17.$$AssetFaceEntityTableTableManager get assetFaceEntity => i15.$$MemoryEntityTableTableManager get memoryEntity =>
i17.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity); i15.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i18.$$StoreEntityTableTableManager get storeEntity => i16.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i18.$$StoreEntityTableTableManager(_db, _db.storeEntity); i16.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i19.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity => i17.$$PersonEntityTableTableManager get personEntity =>
i19.$$TrashedLocalAssetEntityTableTableManager( i17.$$PersonEntityTableTableManager(_db, _db.personEntity);
i18.$$AssetFaceEntityTableTableManager get assetFaceEntity =>
i18.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i19.$$StoreEntityTableTableManager get storeEntity =>
i19.$$StoreEntityTableTableManager(_db, _db.storeEntity);
i20.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
i20.$$TrashedLocalAssetEntityTableTableManager(
_db, _db,
_db.trashedLocalAssetEntity, _db.trashedLocalAssetEntity,
); );

View File

@@ -5941,6 +5941,521 @@ i1.GeneratedColumn<DateTime> _column_96(String aliasedName) =>
true, true,
type: i1.DriftSqlType.dateTime, type: i1.DriftSqlType.dateTime,
); );
final class Schema15 extends i0.VersionedSchema {
Schema15({required super.database}) : super(version: 15);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 localAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_97,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 remoteAssetCloudIdEntity = Shape26(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_98,
_column_99,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 trashedLocalAssetEntity = Shape23(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape25 extends i0.VersionedTable {
Shape25({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get iCloudId =>
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<String> _column_97(String aliasedName) =>
i1.GeneratedColumn<String>(
'i_cloud_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
);
class Shape26 extends i0.VersionedTable {
Shape26({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get cloudId =>
columnsByName['cloud_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get adjustmentTime =>
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<double> get latitude =>
columnsByName['latitude']! as i1.GeneratedColumn<double>;
i1.GeneratedColumn<double> get longitude =>
columnsByName['longitude']! as i1.GeneratedColumn<double>;
}
i1.GeneratedColumn<String> _column_98(String aliasedName) =>
i1.GeneratedColumn<String>(
'cloud_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'),
);
i1.GeneratedColumn<DateTime> _column_99(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'created_at',
aliasedName,
true,
type: i1.DriftSqlType.dateTime,
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -5955,6 +6470,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12, required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -6023,6 +6539,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from13To14(migrator, schema); await from13To14(migrator, schema);
return 14; return 14;
case 14:
final schema = Schema15(database: database);
final migrator = i1.Migrator(database, schema);
await from14To15(migrator, schema);
return 15;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -6043,6 +6564,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12, required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -6058,5 +6580,6 @@ i1.OnUpgrade stepByStep({
from11To12: from11To12, from11To12: from11To12,
from12To13: from12To13, from12To13: from12To13,
from13To14: from13To14, from13To14: from13To14,
from14To15: from14To15,
), ),
); );

View File

@@ -246,6 +246,25 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get(); return query.map((row) => row.readTable(_db.localAssetEntity).toDto()).get();
} }
Future<void> updateCloudMapping(Map<String, String?> cloudMapping) {
if (cloudMapping.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
for (final entry in cloudMapping.entries) {
final assetId = entry.key;
final cloudId = entry.value;
batch.update(
_db.localAssetEntity,
LocalAssetEntityCompanion(iCloudId: Value(cloudId)),
where: (f) => f.id.equals(assetId),
);
}
});
}
Future<void> Function(Iterable<LocalAsset>) get _upsertAssets => Future<void> Function(Iterable<LocalAsset>) get _upsertAssets =>
CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid; CurrentPlatform.isIOS ? _upsertAssetsDarwin : _upsertAssetsAndroid;

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
@@ -126,4 +128,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
} }
return result; return result;
} }
Future<List<LocalAsset>> getEmptyCloudIdAssets() {
final query = _db.localAssetEntity.select()..where((row) => row.iCloudId.isNull());
return query.map((row) => row.toDto()).get();
}
Future<Map<String, String>> getHashMappingFromCloudId() async {
final query =
_db.localAssetEntity.selectOnly().join([
leftOuterJoin(
_db.remoteAssetCloudIdEntity,
_db.localAssetEntity.iCloudId.equalsExp(_db.remoteAssetCloudIdEntity.cloudId),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.remoteAssetCloudIdEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..addColumns([_db.localAssetEntity.id, _db.remoteAssetEntity.checksum])
..where(
_db.remoteAssetCloudIdEntity.cloudId.isNotNull() &
_db.localAssetEntity.checksum.isNull() &
((_db.remoteAssetCloudIdEntity.adjustmentTime.isExp(_db.localAssetEntity.adjustmentTime)) &
(_db.remoteAssetCloudIdEntity.latitude.isExp(_db.localAssetEntity.latitude)) &
(_db.remoteAssetCloudIdEntity.longitude.isExp(_db.localAssetEntity.longitude)) &
(_db.remoteAssetCloudIdEntity.createdAt.isExp(_db.localAssetEntity.createdAt))),
);
final mapping = await query
.map(
(row) => (assetId: row.read(_db.localAssetEntity.id)!, checksum: row.read(_db.remoteAssetEntity.checksum)!),
)
.get();
return {for (final entry in mapping) entry.assetId: entry.checksum};
}
} }

View File

@@ -45,6 +45,7 @@ class SyncApiRepository {
SyncRequestType.usersV1, SyncRequestType.usersV1,
SyncRequestType.assetsV1, SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1, SyncRequestType.assetExifsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1, SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetsV1,
SyncRequestType.partnerAssetExifsV1, SyncRequestType.partnerAssetExifsV1,
@@ -148,6 +149,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson, SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson,
SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson,

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
@@ -54,6 +55,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.authUserEntity.deleteAll(); await _db.authUserEntity.deleteAll();
await _db.userEntity.deleteAll(); await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll(); await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
}); });
await _db.customStatement('PRAGMA foreign_keys = ON'); await _db.customStatement('PRAGMA foreign_keys = ON');
}); });
@@ -258,6 +260,50 @@ class SyncStreamRepository extends DriftDatabaseRepository {
} }
} }
Future<void> deleteAssetsMetadataV1(Iterable<SyncAssetMetadataDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final metadata in data) {
if (metadata.key == AssetMetadataKey.mobileApp) {
batch.deleteWhere(_db.remoteAssetCloudIdEntity, (row) => row.assetId.equals(metadata.assetId));
}
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetsMetadataV1', error, stack);
rethrow;
}
}
Future<void> updateAssetsMetadataV1(Iterable<SyncAssetMetadataV1> data) async {
try {
await _db.batch((batch) {
for (final metadata in data) {
if (metadata.key == AssetMetadataKey.mobileApp) {
final map = metadata.value as Map<String, Object?>;
final companion = RemoteAssetCloudIdEntityCompanion(
cloudId: Value(map['iCloudId']?.toString()),
createdAt: Value(map['createdAt'] != null ? DateTime.parse(map['createdAt'] as String) : null),
adjustmentTime: Value(
map['adjustmentTime'] != null ? DateTime.parse(map['adjustmentTime'] as String) : null,
),
latitude: Value(map['latitude'] != null ? (double.tryParse(map['latitude'] as String)) : null),
longitude: Value(map['longitude'] != null ? (double.tryParse(map['longitude'] as String)) : null),
);
batch.insert(
_db.remoteAssetCloudIdEntity,
companion.copyWith(assetId: Value(metadata.assetId)),
onConflict: DoUpdate((_) => companion),
);
}
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetsMetadataV1', error, stack);
rethrow;
}
}
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async { Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.batch((batch) {

View File

@@ -84,6 +84,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
isFavorite: row.isFavorite, isFavorite: row.isFavorite,
durationInSeconds: row.durationInSeconds, durationInSeconds: row.durationInSeconds,
orientation: row.orientation, orientation: row.orientation,
cloudId: row.iCloudId,
latitude: row.latitude,
longitude: row.longitude,
adjustmentTime: row.adjustmentTime,
), ),
) )
.get(); .get();

View File

@@ -10,4 +10,8 @@ class ServerVersion extends SemVer {
} }
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_); ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
}
} }

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -60,7 +61,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
(_) async { (_) async {
try { try {
wsProvider.connect(); wsProvider.connect();
unawaited(infoProvider.getServerInfo()); await infoProvider.getServerInfo();
final serverInfo = ref.read(serverInfoProvider);
if (Store.isBetaTimelineEnabled) { if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false; bool syncSuccess = false;
@@ -75,6 +77,9 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
_resumeBackup(backupProvider); _resumeBackup(backupProvider);
}), }),
_resumeBackup(backupProvider), _resumeBackup(backupProvider),
// Sync cloud IDs if server version is compatible
if (CurrentPlatform.isIOS && serverInfo.serverVersion.isAtLeast(major: 2, minor: 2))
backgroundManager.syncCloudIds(),
]); ]);
} else { } else {
await backgroundManager.hashAssets(); await backgroundManager.hashAssets();

View File

@@ -616,4 +616,32 @@ class NativeSyncApi {
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, List<PlatformAsset>>(); return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, List<PlatformAsset>>();
} }
} }
Future<Map<String, String?>> getCloudIdForAssetIds(List<String> assetIds) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetIds]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, String?>();
}
}
} }

View File

@@ -131,6 +131,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id); final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', '))); properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
if (CurrentPlatform.isIOS) { if (CurrentPlatform.isIOS) {
properties.add(_PropertyItem(label: 'Cloud ID', value: asset.cloudId));
properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString())); properties.add(_PropertyItem(label: 'Adjustment Time', value: asset.adjustmentTime?.toString()));
} }
properties.add( properties.add(

View File

@@ -109,7 +109,10 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return; return;
} }
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))]; final List<Widget> slivers = [
const CreateAlbumButton(),
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album)),
];
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -153,6 +156,9 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
context: context, context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
); );
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
} }
if (!context.mounted) { if (!context.mounted) {

View File

@@ -12,8 +12,10 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
@@ -766,3 +768,68 @@ class AddToAlbumHeader extends ConsumerWidget {
); );
} }
} }
class CreateAlbumButton extends ConsumerWidget {
const CreateAlbumButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> onCreateAlbum() async {
var albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
return;
}
final asset = ref.read(currentAssetNotifier);
if (asset == null) {
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
return;
}
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbum(title: albumName, assetIds: [asset.remoteId!]);
if (album == null) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());
return;
}
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
// Invalidate using the asset's remote ID to refresh the "Appears in" list
ref.invalidate(albumsContainingAssetProvider(asset.remoteId!));
context.pop();
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("add_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: onCreateAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class NewAlbumNameModal extends StatefulWidget {
const NewAlbumNameModal({super.key});
@override
State<NewAlbumNameModal> createState() => _NewAlbumNameModalState();
}
class _NewAlbumNameModalState extends State<NewAlbumNameModal> {
TextEditingController nameController = TextEditingController();
@override
void dispose() {
nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("album_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: nameController,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () {
context.pop(nameController.text.trim());
},
child: Text(
"create_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
@@ -104,7 +105,12 @@ class NativeVideoViewer extends HookConsumerWidget {
throw Exception('No file found for the video'); throw Exception('No file found for the video');
} }
final source = await VideoSource.init(path: file.path, type: VideoSourceType.file); // Pass a file:// URI so Android's Uri.parse doesn't
// interpret characters like '#' as fragment identifiers.
final source = await VideoSource.init(
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
type: VideoSourceType.file,
);
return source; return source;
} }

View File

@@ -147,6 +147,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
final backgroundManager = _ref.read(backgroundSyncProvider); final backgroundManager = _ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums); final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final serverInfo = _ref.read(serverInfoProvider);
try { try {
bool syncSuccess = false; bool syncSuccess = false;
@@ -160,6 +161,9 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_resumeBackup(); _resumeBackup();
}), }),
_resumeBackup(), _resumeBackup(),
// Sync cloud IDs if server version is compatible
if (CurrentPlatform.isIOS && serverInfo.serverVersion.isAtLeast(major: 2, minor: 2))
backgroundManager.syncCloudIds(),
]); ]);
} else { } else {
await _safeRun(backgroundManager.hashAssets(), "hashAssets"); await _safeRun(backgroundManager.hashAssets(), "hashAssets");

View File

@@ -23,6 +23,9 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
onHashingStart: syncStatusNotifier.startHashJob, onHashingStart: syncStatusNotifier.startHashJob,
onHashingComplete: syncStatusNotifier.completeHashJob, onHashingComplete: syncStatusNotifier.completeHashJob,
onHashingError: syncStatusNotifier.errorHashJob, onHashingError: syncStatusNotifier.errorHashJob,
onCloudIdSyncStart: syncStatusNotifier.startCloudIdSync,
onCloudIdSyncComplete: syncStatusNotifier.completeCloudIdSync,
onCloudIdSyncError: syncStatusNotifier.errorCloudIdSync,
); );
ref.onDispose(manager.cancel); ref.onDispose(manager.cancel);
return manager; return manager;

View File

@@ -32,6 +32,7 @@ final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.
final localSyncServiceProvider = Provider( final localSyncServiceProvider = Provider(
(ref) => LocalSyncService( (ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository), localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider), localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider), storageRepository: ref.watch(storageRepositoryProvider),

View File

@@ -21,6 +21,7 @@ class SyncStatusState {
final SyncStatus remoteSyncStatus; final SyncStatus remoteSyncStatus;
final SyncStatus localSyncStatus; final SyncStatus localSyncStatus;
final SyncStatus hashJobStatus; final SyncStatus hashJobStatus;
final SyncStatus cloudIdSyncStatus;
final String? errorMessage; final String? errorMessage;
@@ -28,6 +29,7 @@ class SyncStatusState {
this.remoteSyncStatus = SyncStatus.idle, this.remoteSyncStatus = SyncStatus.idle,
this.localSyncStatus = SyncStatus.idle, this.localSyncStatus = SyncStatus.idle,
this.hashJobStatus = SyncStatus.idle, this.hashJobStatus = SyncStatus.idle,
this.cloudIdSyncStatus = SyncStatus.idle,
this.errorMessage, this.errorMessage,
}); });
@@ -35,12 +37,14 @@ class SyncStatusState {
SyncStatus? remoteSyncStatus, SyncStatus? remoteSyncStatus,
SyncStatus? localSyncStatus, SyncStatus? localSyncStatus,
SyncStatus? hashJobStatus, SyncStatus? hashJobStatus,
SyncStatus? cloudIdSyncStatus,
String? errorMessage, String? errorMessage,
}) { }) {
return SyncStatusState( return SyncStatusState(
remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus,
localSyncStatus: localSyncStatus ?? this.localSyncStatus, localSyncStatus: localSyncStatus ?? this.localSyncStatus,
hashJobStatus: hashJobStatus ?? this.hashJobStatus, hashJobStatus: hashJobStatus ?? this.hashJobStatus,
cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
} }
@@ -48,6 +52,7 @@ class SyncStatusState {
bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing;
bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing; bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing;
bool get isHashing => hashJobStatus == SyncStatus.syncing; bool get isHashing => hashJobStatus == SyncStatus.syncing;
bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -56,11 +61,12 @@ class SyncStatusState {
other.remoteSyncStatus == remoteSyncStatus && other.remoteSyncStatus == remoteSyncStatus &&
other.localSyncStatus == localSyncStatus && other.localSyncStatus == localSyncStatus &&
other.hashJobStatus == hashJobStatus && other.hashJobStatus == hashJobStatus &&
other.cloudIdSyncStatus == cloudIdSyncStatus &&
other.errorMessage == errorMessage; other.errorMessage == errorMessage;
} }
@override @override
int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, errorMessage); int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage);
} }
class SyncStatusNotifier extends Notifier<SyncStatusState> { class SyncStatusNotifier extends Notifier<SyncStatusState> {
@@ -71,6 +77,7 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
remoteSyncStatus: SyncStatus.idle, remoteSyncStatus: SyncStatus.idle,
localSyncStatus: SyncStatus.idle, localSyncStatus: SyncStatus.idle,
hashJobStatus: SyncStatus.idle, hashJobStatus: SyncStatus.idle,
cloudIdSyncStatus: SyncStatus.idle,
); );
} }
@@ -109,6 +116,18 @@ class SyncStatusNotifier extends Notifier<SyncStatusState> {
void startHashJob() => setHashJobStatus(SyncStatus.syncing); void startHashJob() => setHashJobStatus(SyncStatus.syncing);
void completeHashJob() => setHashJobStatus(SyncStatus.success); void completeHashJob() => setHashJobStatus(SyncStatus.success);
void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error); void errorHashJob(String error) => setHashJobStatus(SyncStatus.error, error);
///
/// Cloud ID Sync Job
///
void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) {
state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null);
}
void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing);
void completeCloudIdSync() => setCloudIdSyncStatus(SyncStatus.success);
void errorCloudIdSync(String error) => setCloudIdSyncStatus(SyncStatus.error, error);
} }
final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new); final syncStatusProvider = NotifierProvider<SyncStatusNotifier, SyncStatusState>(SyncStatusNotifier.new);

View File

@@ -7,6 +7,7 @@ import 'package:cancellation_token_http/http.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; 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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
@@ -14,10 +15,12 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.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/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@@ -34,6 +37,7 @@ final uploadServiceProvider = Provider((ref) {
ref.watch(localAssetRepository), ref.watch(localAssetRepository),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(assetMediaRepositoryProvider), ref.watch(assetMediaRepositoryProvider),
ref.watch(serverInfoProvider),
); );
ref.onDispose(service.dispose); ref.onDispose(service.dispose);
@@ -48,6 +52,7 @@ class UploadService {
this._localAssetRepository, this._localAssetRepository,
this._appSettingsService, this._appSettingsService,
this._assetMediaRepository, this._assetMediaRepository,
this._serverInfo,
) { ) {
_uploadRepository.onUploadStatus = _onUploadCallback; _uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback; _uploadRepository.onTaskProgress = _onTaskProgressCallback;
@@ -59,6 +64,7 @@ class UploadService {
final DriftLocalAssetRepository _localAssetRepository; final DriftLocalAssetRepository _localAssetRepository;
final AppSettingsService _appSettingsService; final AppSettingsService _appSettingsService;
final AssetMediaRepository _assetMediaRepository; final AssetMediaRepository _assetMediaRepository;
final ServerInfo _serverInfo;
final Logger _logger = Logger('UploadService'); final Logger _logger = Logger('UploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast(); final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
@@ -352,6 +358,10 @@ class UploadService {
priority: priority, priority: priority,
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi, requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
); );
} }
@@ -383,6 +393,10 @@ class UploadService {
priority: 0, // Highest priority to get upload immediately priority: 0, // Highest priority to get upload immediately
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
requiresWiFi: requiresWiFi, requiresWiFi: requiresWiFi,
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
); );
} }
@@ -410,6 +424,10 @@ class UploadService {
int? priority, int? priority,
bool? isFavorite, bool? isFavorite,
bool requiresWiFi = true, bool requiresWiFi = true,
String? cloudId,
String? adjustmentTime,
String? latitude,
String? longitude,
}) async { }) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint); final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString(); final url = Uri.parse('$serverEndpoint/assets').toString();
@@ -425,6 +443,20 @@ class UploadService {
'isFavorite': isFavorite?.toString() ?? 'false', 'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0', 'duration': '0',
if (fields != null) ...fields, if (fields != null) ...fields,
// Include cloudId and eTag in metadata if available and server version supports it
if (CurrentPlatform.isIOS && cloudId != null && _serverInfo.serverVersion.isAtLeast(major: 2, minor: 2))
'metadata': jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: cloudId,
createdAt: createdAt.toIso8601String(),
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
),
),
]),
}; };
return UploadTask( return UploadTask(

View File

@@ -194,7 +194,7 @@ class _SyncStatusIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return switch (status) { return switch (status) {
SyncStatus.idle => const Icon(Icons.pause_circle_outline_rounded), SyncStatus.idle => const SizedBox.shrink(),
SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)), SyncStatus.syncing => const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2)),
SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green), SyncStatus.success => const Icon(Icons.check_circle_outline, color: Colors.green),
SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error), SyncStatus.error => Icon(Icons.error_outline, color: context.colorScheme.error),

View File

@@ -121,4 +121,7 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread) @TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets(); Map<String, List<PlatformAsset>> getTrashedAssets();
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, String?> getCloudIdForAssetIds(List<String> assetIds);
} }

View File

@@ -33,6 +33,7 @@ void main() {
registerFallbackValue(LocalAssetStub.image1); registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<String, String>{}); registerFallbackValue(<String, String>{});
when(() => mockAssetRepo.getHashMappingFromCloudId()).thenAnswer((_) async => {});
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {}); when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
}); });

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.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/storage.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/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
@@ -25,6 +26,7 @@ import '../../repository.mocks.dart';
void main() { void main() {
late LocalSyncService sut; late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository; late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late LocalFilesManagerRepository mockLocalFilesManager; late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository; late StorageRepository mockStorageRepository;
@@ -47,6 +49,7 @@ void main() {
setUp(() async { setUp(() async {
mockLocalAlbumRepository = MockLocalAlbumRepository(); mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository(); mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository(); mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository(); mockStorageRepository = MockStorageRepository();
@@ -66,6 +69,7 @@ void main() {
sut = LocalSyncService( sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository, localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository, trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
localFilesManager: mockLocalFilesManager, localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository, storageRepository: mockStorageRepository,

View File

@@ -17,6 +17,7 @@ import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12; import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13; import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14; import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -50,10 +51,28 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v13.DatabaseAtV13(db); return v13.DatabaseAtV13(db);
case 14: case 14:
return v14.DatabaseAtV14(db); return v14.DatabaseAtV14(db);
case 15:
return v15.DatabaseAtV15(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; static const versions = const [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
];
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,22 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/models/server_info/server_features.model.dart';
import 'package:immich_mobile/models/server_info/server_info.model.dart';
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/upload.service.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@@ -16,8 +24,29 @@ import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart'; import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart'; import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart'; import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
import '../mocks/asset_entity.mock.dart'; import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
// Test ServerInfo stub
const _serverInfo = ServerInfo(
serverVersion: ServerVersion(major: 2, minor: 4, patch: 0),
latestVersion: ServerVersion(major: 2, minor: 4, patch: 0),
serverFeatures: ServerFeatures(trash: true, map: true, oauthEnabled: false, passwordLogin: true, ocr: false),
serverConfig: ServerConfig(
trashDays: 30,
oauthButtonText: 'Login with OAuth',
externalDomain: '',
mapDarkStyleUrl: '',
mapLightStyleUrl: '',
),
serverDiskInfo: ServerDiskInfo(
diskAvailable: '100GB',
diskSize: '500GB',
diskUse: '400GB',
diskUsagePercentage: 80.0,
),
versionStatus: VersionStatus.upToDate,
);
void main() { void main() {
late UploadService sut; late UploadService sut;
@@ -62,6 +91,7 @@ void main() {
mockLocalAssetRepository, mockLocalAssetRepository,
mockAppSettingsService, mockAppSettingsService,
mockAssetMediaRepository, mockAssetMediaRepository,
_serverInfo,
); );
mockUploadRepository.onUploadStatus = (_) {}; mockUploadRepository.onUploadStatus = (_) {};
@@ -165,4 +195,223 @@ void main() {
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1); verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
}); });
}); });
group('Server Info - cloudId and eTag metadata', () {
test('should include cloudId and eTag metadata on iOS when server version is 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
expect(metadata[0]['key'], equals('mobile-app'));
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-123'));
expect(metadata[0]['value']['eTag'], isNotNull);
});
test('should NOT include metadata on iOS when server version is below 2.4', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV23 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo.copyWith(
serverVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
latestVersion: const ServerVersion(major: 2, minor: 3, patch: 0),
),
);
addTearDown(() => sutWithV23.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV23.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should NOT include metadata on Android regardless of server version', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutAndroid = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutAndroid.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-123',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should NOT include metadata when cloudId is null even on iOS with server 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithoutCloudId = LocalAsset(
id: 'test-asset-id',
name: 'test.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: null, // No cloudId
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/test.jpg');
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithoutCloudId);
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isFalse);
});
test('should include metadata for live photos with cloudId on iOS 2.4+', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final sutWithV24 = UploadService(
mockUploadRepository,
mockBackupRepository,
mockStorageRepository,
mockLocalAssetRepository,
mockAppSettingsService,
mockAssetMediaRepository,
_serverInfo,
);
addTearDown(() => sutWithV24.dispose());
final assetWithCloudId = LocalAsset(
id: 'test-livephoto-id',
name: 'livephoto.heic',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 2),
cloudId: 'cloud-id-livephoto',
latitude: 37.7749,
longitude: -122.4194,
);
final mockEntity = MockAssetEntity();
final mockFile = File('/path/to/livephoto.heic');
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic');
final task = await sutWithV24.getLivePhotoUploadTask(assetWithCloudId, 'video-123');
expect(task, isNotNull);
expect(task!.fields.containsKey('metadata'), isTrue);
expect(task.fields['livePhotoVideoId'], equals('video-123'));
final metadata = jsonDecode(task.fields['metadata']!) as List;
expect(metadata, hasLength(1));
expect(metadata[0]['key'], equals('mobile-app'));
expect(metadata[0]['value']['iCloudId'], equals('cloud-id-livephoto'));
});
});
} }

View File

@@ -15,9 +15,9 @@
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
"integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -32,9 +32,9 @@
} }
}, },
"node_modules/@esbuild/android-arm": { "node_modules/@esbuild/android-arm": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
"integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -49,9 +49,9 @@
} }
}, },
"node_modules/@esbuild/android-arm64": { "node_modules/@esbuild/android-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
"integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -66,9 +66,9 @@
} }
}, },
"node_modules/@esbuild/android-x64": { "node_modules/@esbuild/android-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
"integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -83,9 +83,9 @@
} }
}, },
"node_modules/@esbuild/darwin-arm64": { "node_modules/@esbuild/darwin-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
"integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -100,9 +100,9 @@
} }
}, },
"node_modules/@esbuild/darwin-x64": { "node_modules/@esbuild/darwin-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
"integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -117,9 +117,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-arm64": { "node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
"integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -134,9 +134,9 @@
} }
}, },
"node_modules/@esbuild/freebsd-x64": { "node_modules/@esbuild/freebsd-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
"integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -151,9 +151,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm": { "node_modules/@esbuild/linux-arm": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
"integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -168,9 +168,9 @@
} }
}, },
"node_modules/@esbuild/linux-arm64": { "node_modules/@esbuild/linux-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
"integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -185,9 +185,9 @@
} }
}, },
"node_modules/@esbuild/linux-ia32": { "node_modules/@esbuild/linux-ia32": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
"integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -202,9 +202,9 @@
} }
}, },
"node_modules/@esbuild/linux-loong64": { "node_modules/@esbuild/linux-loong64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
"integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -219,9 +219,9 @@
} }
}, },
"node_modules/@esbuild/linux-mips64el": { "node_modules/@esbuild/linux-mips64el": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
"integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
@@ -236,9 +236,9 @@
} }
}, },
"node_modules/@esbuild/linux-ppc64": { "node_modules/@esbuild/linux-ppc64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
"integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -253,9 +253,9 @@
} }
}, },
"node_modules/@esbuild/linux-riscv64": { "node_modules/@esbuild/linux-riscv64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
"integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -270,9 +270,9 @@
} }
}, },
"node_modules/@esbuild/linux-s390x": { "node_modules/@esbuild/linux-s390x": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
"integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -287,9 +287,9 @@
} }
}, },
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
"integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -304,9 +304,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-arm64": { "node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
"integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -321,9 +321,9 @@
} }
}, },
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
"integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -338,9 +338,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-arm64": { "node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
"integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -355,9 +355,9 @@
} }
}, },
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
"integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -372,9 +372,9 @@
} }
}, },
"node_modules/@esbuild/openharmony-arm64": { "node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
"integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -389,9 +389,9 @@
} }
}, },
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
"integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -406,9 +406,9 @@
} }
}, },
"node_modules/@esbuild/win32-arm64": { "node_modules/@esbuild/win32-arm64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
"integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -423,9 +423,9 @@
} }
}, },
"node_modules/@esbuild/win32-ia32": { "node_modules/@esbuild/win32-ia32": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
"integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -440,9 +440,9 @@
} }
}, },
"node_modules/@esbuild/win32-x64": { "node_modules/@esbuild/win32-x64": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
"integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -467,9 +467,9 @@
} }
}, },
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.0", "version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
@@ -480,32 +480,32 @@
"node": ">=18" "node": ">=18"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.0", "@esbuild/aix-ppc64": "0.27.1",
"@esbuild/android-arm": "0.27.0", "@esbuild/android-arm": "0.27.1",
"@esbuild/android-arm64": "0.27.0", "@esbuild/android-arm64": "0.27.1",
"@esbuild/android-x64": "0.27.0", "@esbuild/android-x64": "0.27.1",
"@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-arm64": "0.27.1",
"@esbuild/darwin-x64": "0.27.0", "@esbuild/darwin-x64": "0.27.1",
"@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.1",
"@esbuild/freebsd-x64": "0.27.0", "@esbuild/freebsd-x64": "0.27.1",
"@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm": "0.27.1",
"@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-arm64": "0.27.1",
"@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-ia32": "0.27.1",
"@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-loong64": "0.27.1",
"@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-mips64el": "0.27.1",
"@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-ppc64": "0.27.1",
"@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-riscv64": "0.27.1",
"@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-s390x": "0.27.1",
"@esbuild/linux-x64": "0.27.0", "@esbuild/linux-x64": "0.27.1",
"@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.1",
"@esbuild/netbsd-x64": "0.27.0", "@esbuild/netbsd-x64": "0.27.1",
"@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.1",
"@esbuild/openbsd-x64": "0.27.0", "@esbuild/openbsd-x64": "0.27.1",
"@esbuild/openharmony-arm64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.1",
"@esbuild/sunos-x64": "0.27.0", "@esbuild/sunos-x64": "0.27.1",
"@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-arm64": "0.27.1",
"@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-ia32": "0.27.1",
"@esbuild/win32-x64": "0.27.0" "@esbuild/win32-x64": "0.27.1"
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {

1233
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,7 @@ export class MetadataRepository {
// Enable exiftool LFS to parse metadata for files larger than 2GB. // Enable exiftool LFS to parse metadata for files larger than 2GB.
readArgs: ['-api', 'largefilesupport=1'], readArgs: ['-api', 'largefilesupport=1'],
writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
taskTimeoutMillis: 2 * 60 * 1000,
}); });
constructor(private logger: LoggingRepository) { constructor(private logger: LoggingRepository) {

View File

@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build", "build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'state_referenced_locally:ignore'",
"check:typescript": "tsc --noEmit", "check:typescript": "tsc --noEmit",
"check:watch": "pnpm run check:svelte --watch", "check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript", "check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
@@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0", "rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.43.3", "svelte": "5.45.5",
"svelte-check": "^4.1.5", "svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3", "svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",

View File

@@ -114,7 +114,11 @@
return; return;
} }
await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime }); await modalManager.show(AssetChangeDateModal, {
asset: toTimelineAsset(asset),
initialDate: dateTime,
initialTimeZone: timeZone,
});
}; };
</script> </script>

View File

@@ -188,7 +188,7 @@
// the performance benefits of deferred layouts while still supporting deep linking // the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline. // to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true; timelineManager.isScrollingOnLoad = true;
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId); const monthGroup = await timelineManager.findMonthGroupForAsset({ id: assetId });
if (!monthGroup) { if (!monthGroup) {
return false; return false;
} }

View File

@@ -1,5 +1,5 @@
import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk'; import { AssetOrder, type AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import type { MonthGroup } from '../month-group.svelte'; import type { MonthGroup } from '../month-group.svelte';
import { TimelineManager } from '../timeline-manager.svelte'; import { TimelineManager } from '../timeline-manager.svelte';
@@ -7,12 +7,16 @@ import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
export async function getAssetWithOffset( export async function getAssetWithOffset(
timelineManager: TimelineManager, timelineManager: TimelineManager,
assetDescriptor: AssetDescriptor, assetDescriptor: AssetDescriptor | AssetResponseDto,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset', interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
direction: Direction, direction: Direction,
): Promise<TimelineAsset | undefined> { ): Promise<TimelineAsset | undefined> {
const { asset, monthGroup } = findMonthGroupForAsset(timelineManager, assetDescriptor.id) ?? {}; const monthGroup = await timelineManager.findMonthGroupForAsset(assetDescriptor);
if (!monthGroup || !asset) { if (!monthGroup) {
return;
}
const asset = monthGroup.findAssetById(assetDescriptor);
if (!asset) {
return; return;
} }

View File

@@ -524,6 +524,7 @@ describe('TimelineManager', () => {
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
]); ]);
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
sdkMock.getAssetInfo.mockRejectedValue(new Error('Asset not found'));
await timelineManager.updateViewport({ width: 1588, height: 1000 }); await timelineManager.updateViewport({ width: 1588, height: 1000 });
}); });

View File

@@ -16,12 +16,13 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted'; import { PersistedLocalStorage } from '$lib/utils/persisted';
import { import {
isAssetResponseDto,
setDifference, setDifference,
toTimelineAsset, toTimelineAsset,
type TimelineDateTime, type TimelineDateTime,
type TimelineYearMonth, type TimelineYearMonth,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
@@ -343,27 +344,30 @@ export class TimelineManager extends VirtualScrollManager {
this.addAssetsUpsertSegments([...notExcluded]); this.addAssetsUpsertSegments([...notExcluded]);
} }
async findMonthGroupForAsset(id: string) { async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) { if (!this.isInitialized) {
await this.initTask.waitUntilCompletion(); await this.initTask.waitUntilCompletion();
} }
const { id } = asset;
let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {}; let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
if (monthGroup) { if (monthGroup) {
return monthGroup; return monthGroup;
} }
const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null); const response = isAssetResponseDto(asset)
? asset
: await getAssetInfo({ ...authManager.params, id }).catch(() => null);
if (!response) { if (!response) {
return; return;
} }
const asset = toTimelineAsset(response); const timelineAsset = toTimelineAsset(response);
if (!asset || this.isExcluded(asset)) { if (this.isExcluded(timelineAsset)) {
return; return;
} }
monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false }); monthGroup = await this.#loadMonthGroupAtTime(timelineAsset.localDateTime, { cancelable: false });
if (monthGroup?.findAssetById({ id })) { if (monthGroup?.findAssetById({ id })) {
return monthGroup; return monthGroup;
} }
@@ -532,14 +536,14 @@ export class TimelineManager extends VirtualScrollManager {
} }
async getLaterAsset( async getLaterAsset(
assetDescriptor: AssetDescriptor, assetDescriptor: AssetDescriptor | AssetResponseDto,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset', interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> { ): Promise<TimelineAsset | undefined> {
return await getAssetWithOffset(this, assetDescriptor, interval, 'later'); return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
} }
async getEarlierAsset( async getEarlierAsset(
assetDescriptor: AssetDescriptor, assetDescriptor: AssetDescriptor | AssetResponseDto,
interval: 'asset' | 'day' | 'month' | 'year' = 'asset', interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<TimelineAsset | undefined> { ): Promise<TimelineAsset | undefined> {
return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier'); return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');

View File

@@ -1,4 +1,4 @@
import type { TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; import type { AssetDescriptor, TimelineAsset, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils'; import { getAssetRatio } from '$lib/utils/asset-utils';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
@@ -192,8 +192,13 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
}; };
}; };
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset => export const isTimelineAsset = (
(unknownAsset as TimelineAsset).ratio !== undefined; unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
): unknownAsset is TimelineAsset => (unknownAsset as TimelineAsset).ratio !== undefined;
export const isAssetResponseDto = (
unknownAsset: AssetDescriptor | AssetResponseDto | TimelineAsset,
): unknownAsset is AssetResponseDto => (unknownAsset as AssetResponseDto).type !== undefined;
export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] => export const isTimelineAssets = (assets: AssetResponseDto[] | TimelineAsset[]): assets is TimelineAsset[] =>
assets.length === 0 || 'ratio' in assets[0]; assets.length === 0 || 'ratio' in assets[0];

View File

@@ -15,9 +15,24 @@
import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, type TagResponseDto } from '@immich/sdk'; import { deleteTag, getAllTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui'; import { Button, HStack, modalManager, Text } from '@immich/ui';
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; import { mdiDotsVertical, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { preferences, user } from '$lib/stores/user.store';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -79,6 +94,11 @@
// navigate to parent // navigate to parent
await navigateToView(tag.parent ? tag.parent.path : ''); await navigateToView(tag.parent ? tag.parent.path : '');
}; };
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
};
</script> </script>
<UserPageLayout title={data.meta.title}> <UserPageLayout title={data.meta.title}>
@@ -131,3 +151,45 @@
{/if} {/if}
</section> </section>
</UserPageLayout> </UserPageLayout>
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{/if}
</section>