mirror of
https://github.com/immich-app/immich.git
synced 2026-06-16 20:02:15 -07:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11ee153c84 | |||
| 43f2f56530 | |||
| e580bb5d0a | |||
| d3680871ef | |||
| b9b1cc2f65 | |||
| 7d198956a6 | |||
| a7b5f81701 | |||
| 5c38373808 | |||
| 1ce961fbb3 | |||
| 4bc411b7c7 | |||
| 11c1025271 | |||
| 8b5385f94b | |||
| d3438cf4a7 | |||
| 6c5c6a1035 | |||
| c928787b3e | |||
| fe9ca4f40a | |||
| a665cec920 | |||
| 568283a8eb | |||
| f382624e68 | |||
| 24dad15636 | |||
| 7ab533b57b | |||
| d10153bbc7 | |||
| b846afeb08 | |||
| e222b19576 | |||
| 1fee99cd2a | |||
| 70bb7e4b7e | |||
| f973927c68 | |||
| e29267359e | |||
| 164cda87a3 | |||
| 12d344efe0 | |||
| 474efd39f8 | |||
| 9e453440e6 | |||
| 8860817c76 | |||
| 5baf71c008 | |||
| 23455cbd07 | |||
| 3c108a8d22 | |||
| 9d5fe5f1a4 | |||
| 2c7a24d81f | |||
| 8e9bec75ac |
@@ -15,7 +15,7 @@ jobs:
|
||||
outputs:
|
||||
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
sparse-checkout: .github/pull_request_template.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -20,12 +20,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # v0.0.48
|
||||
uses: oasdiff/oasdiff-action/breaking@a8c7f0e5649d20d623edb5b38446d3ab3d82d43c # v0.0.53
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -50,14 +50,14 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
persist-credentials: true
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
if: always()
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
script: |
|
||||
github.rest.issues.removeLabel({
|
||||
issue_number: context.payload.pull_request.number,
|
||||
|
||||
@@ -10,9 +10,13 @@ on:
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
- premajor
|
||||
- preminor
|
||||
- prepatch
|
||||
- prerelease
|
||||
- release
|
||||
mobileBump:
|
||||
description: 'Bump mobile build number'
|
||||
required: false
|
||||
@@ -55,7 +59,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
persist-credentials: true
|
||||
@@ -68,13 +72,13 @@ jobs:
|
||||
|
||||
# TODO move to mise
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Bump version
|
||||
env:
|
||||
SERVER_BUMP: ${{ inputs.serverBump }}
|
||||
MOBILE_BUMP: ${{ inputs.mobileBump }}
|
||||
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||
|
||||
- id: output
|
||||
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -125,7 +129,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -90,6 +90,8 @@ jobs:
|
||||
mobile/**/*.g.dart
|
||||
mobile/**/*.gr.dart
|
||||
mobile/**/*.drift.dart
|
||||
mobile/**/*.g.swift
|
||||
mobile/**/*.g.kt
|
||||
|
||||
- name: Verify files have not changed
|
||||
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||
|
||||
+48
-16
@@ -28,6 +28,10 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.token.outputs.token }}
|
||||
filters: |
|
||||
root:
|
||||
- 'misc/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'mise.toml'
|
||||
i18n:
|
||||
- 'i18n/**'
|
||||
- 'mise.toml'
|
||||
@@ -62,6 +66,34 @@ jobs:
|
||||
- '.github/workflows/test.yml'
|
||||
force-events: 'workflow_dispatch'
|
||||
|
||||
root-unit-tests:
|
||||
name: Test the root workspace
|
||||
needs: pre-job
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).root == true }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- id: token
|
||||
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
|
||||
with:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
|
||||
with:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
server-unit-tests:
|
||||
name: Test & Lint Server
|
||||
needs: pre-job
|
||||
@@ -77,7 +109,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -108,7 +140,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -139,7 +171,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -183,7 +215,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -221,7 +253,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -249,7 +281,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -299,7 +331,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -331,7 +363,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -367,7 +399,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -444,7 +476,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: 'recursive'
|
||||
@@ -551,7 +583,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -589,7 +621,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -620,7 +652,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -649,7 +681,7 @@ jobs:
|
||||
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -671,7 +703,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
@@ -729,7 +761,7 @@ jobs:
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
Vendored
+1
@@ -60,6 +60,7 @@
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
|
||||
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
|
||||
"*.js": "${capture}.spec.js,${capture}.mock.js",
|
||||
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
|
||||
},
|
||||
"search.exclude": {
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:12.4.3-ubuntu@sha256:ca3f764fdc48cebdf22dd206f33ecb0795a9a7210eacd1b5c02204aebd78b223
|
||||
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generat
|
||||
OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generator-cli` can be installed [here](https://openapi-generator.tech/docs/installation/). The generated SDK is based on the `immich-openapi-specs.json` file, which is autogenerated by the server **when running in development mode**. The `immich-openapi-specs.json` file can be modified with `@nestjs/swagger` decorators used or referenced by controller endpoints. See the [NestJS OpenAPI docs](https://docs.nestjs.com/openapi/types-and-parameters) for more info. When you add a new endpoint or modify an existing one, you must run the server in development mode and run the command below to update the client SDK.
|
||||
|
||||
```bash
|
||||
make open-api
|
||||
mise open-api
|
||||
```
|
||||
|
||||
You can find the generated client SDK in the `packages/sdk/client` for Typescript SDK and `mobile/openapi` for Dart SDK.
|
||||
|
||||
@@ -218,7 +218,7 @@ When the Dev Container starts, it automatically:
|
||||
- Debug ports: 9230 (workers), 9231 (API)
|
||||
|
||||
:::info
|
||||
The Dev Container setup replaces the `make dev` command from the traditional setup. All services start automatically when you open the container.
|
||||
The Dev Container setup replaces the `mise dev` command from the traditional setup. All services start automatically when you open the container.
|
||||
:::
|
||||
|
||||
### Accessing Services
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
|
||||
:::warning
|
||||
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
|
||||
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`mise dev`, ....). Feel free to contribute!
|
||||
:::
|
||||
When contributing code through a pull request, please check the following:
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ All the services are packaged to run as with single Docker Compose command.
|
||||
5. From the root directory, run:
|
||||
|
||||
```bash title="Start development server"
|
||||
make dev # required Makefile installed on the system.
|
||||
mise dev
|
||||
```
|
||||
|
||||
5. Access the dev instance in your browser at http://localhost:3000, or connect via the mobile app.
|
||||
@@ -88,7 +88,7 @@ To see local changes to `@immich/ui` in Immich, do the following:
|
||||
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yml` file (`../../ui:/usr/src/ui`)
|
||||
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
|
||||
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
|
||||
6. Start up the stack via `make dev`
|
||||
6. Start up the stack via `mise dev`
|
||||
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)
|
||||
|
||||
### Mobile app
|
||||
|
||||
@@ -12,7 +12,7 @@ You need to run `mise //server:install` before _once_.
|
||||
The e2e tests can be run by first starting up a test production environment via:
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
mise e2e
|
||||
```
|
||||
|
||||
Before you can run the tests, you need to run the following commands _once_:
|
||||
|
||||
@@ -4,7 +4,8 @@ services:
|
||||
e2e-auth-server:
|
||||
container_name: immich-e2e-auth-server
|
||||
build:
|
||||
context: ../packages/e2e-auth-server
|
||||
context: ../
|
||||
dockerfile: packages/e2e-auth-server/Dockerfile
|
||||
ports:
|
||||
- 2286:2286
|
||||
|
||||
|
||||
@@ -504,13 +504,14 @@ describe('/albums', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not be able to share album with owner', async () => {
|
||||
it('should deduplicate owner from albumUsers on create', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/albums')
|
||||
.send({ albumName: 'New album', albumUsers: [{ role: AlbumUserRole.Editor, userId: user1.userId }] })
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Cannot share album with owner'));
|
||||
expect(status).toBe(201);
|
||||
expect(body.albumUsers).toHaveLength(1);
|
||||
expect(body.albumUsers[0]).toMatchObject({ role: AlbumUserRole.Owner, user: { id: user1.userId } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -492,6 +492,20 @@ describe('/asset', () => {
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should set the negative rating', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ rating: -1 });
|
||||
expect(body).toMatchObject({
|
||||
id: user1Assets[0].id,
|
||||
exifInfo: expect.objectContaining({
|
||||
rating: -1,
|
||||
}),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should return tagged people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
|
||||
+1
-1
@@ -2248,6 +2248,7 @@
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"smart_album": "Smart album",
|
||||
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
"sort_created": "Date created",
|
||||
"sort_items": "Number of items",
|
||||
@@ -2415,7 +2416,6 @@
|
||||
"upload": "Upload",
|
||||
"upload_concurrency": "Upload concurrency",
|
||||
"upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}",
|
||||
"upload_deferred_edit_pair": "Waiting for the original photo, will retry automatically",
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
"upload_dialog_title": "Upload Asset",
|
||||
|
||||
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4e
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-core-2_2.34.4+21428_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-opencl-2_2.34.4+21428_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/intel-opencl-icd_26.18.38308.1-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/libigdgmm12_22.10.0_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#! /usr/bin/env node
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const asVersion = (item) => {
|
||||
const { label, url } = item;
|
||||
const [major, minor, patch] = label.substring(1).split('.').map(Number);
|
||||
const [version] = label.substring(1).split('-');
|
||||
const [major, minor, patch] = version.split('.').map(Number);
|
||||
return { major, minor, patch, label, url };
|
||||
};
|
||||
|
||||
@@ -31,7 +32,7 @@ for (const item of versions) {
|
||||
) {
|
||||
versions = versions.filter((item) => item.label !== version.label);
|
||||
console.log(
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`
|
||||
`Removed ${version.label} (replaced with ${lastVersion.label})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -41,5 +42,5 @@ for (const item of versions) {
|
||||
|
||||
writeFileSync(
|
||||
filename,
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n'
|
||||
JSON.stringify([newVersion, ...versions], null, 2) + '\n',
|
||||
);
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
#
|
||||
# Pump one or both of the server/mobile versions in appropriate files
|
||||
#
|
||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
|
||||
# usage: './scripts/pump-version.sh -s <minor|patch|premajor|preminor|prepatch|prerelease> <-m> <true|false>
|
||||
#
|
||||
# examples:
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -s premajor # 1.0.0+50 => 2.0.0-rc.0+50
|
||||
# ./scripts/pump-version.sh -s prerelease # 2.0.0-rc.0+50 => 2.0.0-rc.1+50
|
||||
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
|
||||
#
|
||||
|
||||
SERVER_PUMP="false"
|
||||
@@ -25,31 +27,15 @@ while getopts 's:m:' flag; do
|
||||
esac
|
||||
done
|
||||
|
||||
CURRENT_SERVER=$(jq -r '.version' server/package.json)
|
||||
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
|
||||
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
|
||||
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
|
||||
|
||||
if [[ $SERVER_PUMP == "major" ]]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "minor" ]]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "patch" ]]; then
|
||||
PATCH=$((PATCH + 1))
|
||||
elif [[ $SERVER_PUMP == "false" ]]; then
|
||||
echo 'Skipping Server Pump'
|
||||
else
|
||||
echo 'Expected <major|minor|patch|false> for the server argument'
|
||||
CURRENT_SERVER=$(jq -r '.version' package.json)
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
|
||||
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
|
||||
|
||||
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
|
||||
NEXT_MOBILE=$CURRENT_MOBILE
|
||||
|
||||
if [[ $MOBILE_PUMP == "true" ]]; then
|
||||
set $((NEXT_MOBILE++))
|
||||
elif [[ $MOBILE_PUMP == "false" ]]; then
|
||||
@@ -59,15 +45,17 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/sdk
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pump } from './pump.js';
|
||||
|
||||
const [versionRaw, type] = process.argv.slice(2);
|
||||
const { message, exitCode } = pump(versionRaw, type);
|
||||
|
||||
console.log(message);
|
||||
process.exit(exitCode);
|
||||
@@ -0,0 +1,105 @@
|
||||
import semver, { SemVer } from 'semver';
|
||||
|
||||
const printUsage = () => {
|
||||
return {
|
||||
message:
|
||||
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
exitCode: 1,
|
||||
};
|
||||
};
|
||||
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
|
||||
/**
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
return version;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} versionRaw
|
||||
* @param {string} type
|
||||
*/
|
||||
export const pump = (versionRaw, type) => {
|
||||
if (!versionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
versionRaw = normalize(versionRaw);
|
||||
|
||||
const version = semver.parse(versionRaw);
|
||||
if (!version) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
let newVersionRaw;
|
||||
let valid = true;
|
||||
|
||||
switch (type) {
|
||||
case 'patch':
|
||||
case 'prepatch':
|
||||
case 'minor':
|
||||
case 'preminor':
|
||||
case 'premajor': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while not in a prerelease
|
||||
valid = !isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'prerelease': {
|
||||
newVersionRaw = inc(version, type);
|
||||
// can only use while in a prerelease
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'release': {
|
||||
// drop prerelease part
|
||||
newVersionRaw = `${version.major}.${version.minor}.${version.patch}`;
|
||||
// can only use to promote a prerelease to a release (no version change)
|
||||
valid = isPrerelease(version);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
return printUsage();
|
||||
}
|
||||
}
|
||||
|
||||
if (!newVersionRaw) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
newVersionRaw = normalize(newVersionRaw);
|
||||
|
||||
const newVersion = semver.parse(newVersionRaw);
|
||||
if (!newVersion) {
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
const invalidUpgrade =
|
||||
isPrerelease(version) &&
|
||||
!isPrerelease(newVersion) &&
|
||||
(version.major !== newVersion.major ||
|
||||
version.minor !== newVersion.minor ||
|
||||
version.patch !== newVersion.patch);
|
||||
|
||||
if (!valid || invalidUpgrade) {
|
||||
return {
|
||||
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
|
||||
exitCode: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: newVersionRaw, exitCode: 0 };
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pump } from './pump';
|
||||
|
||||
describe(pump.name, () => {
|
||||
describe('usage', () => {
|
||||
it.each([
|
||||
[],
|
||||
['2.7.5'],
|
||||
['2.7.5', 'invalid'],
|
||||
['invalid', 'patch'],
|
||||
['2.7.5', 'major'],
|
||||
])('should not accept $0, $1 as inputs', (version, type) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Usage: '),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitions', () => {
|
||||
const valid = [
|
||||
{
|
||||
name: 'patch',
|
||||
items: [['patch', '2.7.5', '2.7.6']],
|
||||
},
|
||||
{
|
||||
name: 'prepatch',
|
||||
items: [
|
||||
['prepatch', '2.7.5', '2.7.6-rc.0'],
|
||||
['prerelease', '2.7.6-rc.0', '2.7.6-rc.1'],
|
||||
['release', '2.7.6-rc.1', '2.7.6'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'minor',
|
||||
items: [['minor', '2.7.5', '2.8.0']],
|
||||
},
|
||||
{
|
||||
name: 'preminor',
|
||||
items: [
|
||||
['preminor', '2.7.5', '2.8.0-rc.0'],
|
||||
['prerelease', '2.8.0-rc.0', '2.8.0-rc.1'],
|
||||
['release', '2.8.0-rc.1', '2.8.0'],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'premajor',
|
||||
items: [
|
||||
['premajor', '2.7.5', '3.0.0-rc.0'],
|
||||
['prerelease', '3.0.0-rc.0', '3.0.0-rc.1'],
|
||||
['release', '3.0.0-rc.1', '3.0.0'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const group of valid) {
|
||||
describe(group.name, () => {
|
||||
it.each(group.items)(
|
||||
'should allow a $0 from $1 to $2',
|
||||
(type, version, next) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: next,
|
||||
exitCode: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('invalid', () => {
|
||||
it.each([
|
||||
['patch', 'v3.0.0-rc.0'],
|
||||
['prepatch', 'v3.0.0-rc.0'],
|
||||
['minor', 'v3.0.0-rc.0'],
|
||||
['preminor', 'v3.0.0-rc.0'],
|
||||
['premajor', 'v3.0.0-rc.0'],
|
||||
['prerelease', 'v3.0.0'],
|
||||
['release', 'v3.0.0'],
|
||||
])('should not allow a $0 on $1', (type, version) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Invalid pump'),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,40 +82,8 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
|
||||
version = "7.1.3-6"
|
||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
|
||||
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
|
||||
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
||||
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
|
||||
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
|
||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
|
||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||
|
||||
[[tools."github:webassembly/binaryen"]]
|
||||
version = "version_124"
|
||||
@@ -156,6 +124,30 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
|
||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
|
||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
|
||||
|
||||
[[tools.java]]
|
||||
version = "21.0.2"
|
||||
backend = "core:java"
|
||||
|
||||
[tools.java."platforms.linux-arm64"]
|
||||
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.linux-x64"]
|
||||
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.macos-arm64"]
|
||||
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.macos-x64"]
|
||||
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.windows-x64"]
|
||||
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
|
||||
|
||||
[[tools.node]]
|
||||
version = "24.15.0"
|
||||
backend = "core:node"
|
||||
@@ -225,36 +217,38 @@ checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c70773
|
||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
||||
|
||||
[[tools.pnpm]]
|
||||
version = "10.33.4"
|
||||
version = "11.4.0"
|
||||
backend = "aqua:pnpm/pnpm"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
|
||||
checksum = "sha256:cc38ebd5b2610a5744f84576b963c49e6609a8df5aed714ae3de749998d4478c"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-arm64.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
|
||||
checksum = "sha256:a1e2ec9123c709fd04b704227cfcf3b50cd2bbbc1bd39d2df414530b5697eb75"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-arm64-musl.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
|
||||
checksum = "sha256:f3f8d1217eef013bbc71a24d52efb1f1041e4aff55edd80e0b08e25f409305a4"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-x64.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
|
||||
checksum = "sha256:60010ad00a96b71e20d1618acaca7a71395e710cbd5e88946c030a1d07c56916"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-x64-musl.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:7aae186a04e1ffaa0047d43cd07d68a98dec303304f28be52234ba955d26c671"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-arm64"
|
||||
|
||||
[tools.pnpm."platforms.macos-x64"]
|
||||
checksum = "sha256:3b0c97b9f794cdda293949a8ee0e0151ca08f512f4a832408386221c7c73eec6"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-x64"
|
||||
checksum = "sha256:ba59014c2c1ce8b76af9f559385206a2623de4ff2b694b5c91598a8f44abb4e2"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-darwin-arm64.tar.gz"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:3268b2f29defe0dce8a3a26c0ef01488f0d4aa4872923173186ef618ab7d68ef"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-win-x64.exe"
|
||||
checksum = "sha256:84ce90e38bc0b1164173eb853a0fbffc7edcb050cb0d5c8ce4ca609f5c808e0a"
|
||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-win32-x64.zip"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
version = "1.0.3"
|
||||
|
||||
@@ -16,13 +16,14 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
pnpm = "10.33.4"
|
||||
pnpm = "11.4.0"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
"github:extism/js-pdk" = "1.6.0"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"]
|
||||
version = "7.1.3-6"
|
||||
|
||||
+9
-190
@@ -207,18 +207,6 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class EditState(val raw: Int) {
|
||||
NOT_EDITED(0),
|
||||
EDITED(1),
|
||||
UNKNOWN(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): EditState? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class PlatformAsset (
|
||||
val id: String,
|
||||
@@ -484,82 +472,6 @@ data class CloudIdResult (
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseResource (
|
||||
val path: String,
|
||||
val sha1: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
|
||||
val path = pigeonVar_list[0] as String
|
||||
val sha1 = pigeonVar_list[1] as String
|
||||
return BaseResource(path, sha1)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
path,
|
||||
sha1,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as BaseResource
|
||||
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseLivePhoto (
|
||||
val still: BaseResource,
|
||||
val video: BaseResource? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseLivePhoto {
|
||||
val still = pigeonVar_list[0] as BaseResource
|
||||
val video = pigeonVar_list[1] as BaseResource?
|
||||
return BaseLivePhoto(still, video)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
still,
|
||||
video,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as BaseLivePhoto
|
||||
return MessagesPigeonUtils.deepEquals(this.still, other.still) && MessagesPigeonUtils.deepEquals(this.video, other.video)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.still)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.video)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -569,45 +481,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
EditState.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAsset.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAlbum.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SyncDelta.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
}
|
||||
}
|
||||
135.toByte() -> {
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
CloudIdResult.fromList(it)
|
||||
}
|
||||
}
|
||||
136.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseResource.fromList(it)
|
||||
}
|
||||
}
|
||||
137.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseLivePhoto.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -617,36 +514,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is EditState -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is PlatformAsset -> {
|
||||
stream.write(131)
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is PlatformAlbum -> {
|
||||
stream.write(132)
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SyncDelta -> {
|
||||
stream.write(133)
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is HashResult -> {
|
||||
stream.write(134)
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
stream.write(135)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseResource -> {
|
||||
stream.write(136)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseLivePhoto -> {
|
||||
stream.write(137)
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
@@ -671,9 +556,6 @@ interface NativeSyncApi {
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
|
||||
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by NativeSyncApi. */
|
||||
@@ -936,69 +818,6 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getBaseLivePhoto(assetIdArg, allowNetworkAccessArg) { result: Result<BaseLivePhoto?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,19 +509,4 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Android has no Photos-style edit original to stack; iOS-only.
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style edit.
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(EditState.NOT_EDITED))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style live edit.
|
||||
fun getBaseLivePhoto(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseLivePhoto?>) -> Unit) {
|
||||
completeWhenActive(callback, Result.success(null))
|
||||
}
|
||||
}
|
||||
|
||||
-3646
File diff suppressed because it is too large
Load Diff
Generated
+9
-169
@@ -183,12 +183,6 @@ enum PlatformAssetPlaybackStyle: Int {
|
||||
case videoLooping = 5
|
||||
}
|
||||
|
||||
enum EditState: Int {
|
||||
case notEdited = 0
|
||||
case edited = 1
|
||||
case unknown = 2
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct PlatformAsset: Hashable {
|
||||
var id: String
|
||||
@@ -464,78 +458,6 @@ struct CloudIdResult: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseResource: Hashable {
|
||||
var path: String
|
||||
var sha1: String
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
|
||||
let path = pigeonVar_list[0] as! String
|
||||
let sha1 = pigeonVar_list[1] as! String
|
||||
|
||||
return BaseResource(
|
||||
path: path,
|
||||
sha1: sha1
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
path,
|
||||
sha1,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseResource")
|
||||
deepHashMessages(value: path, hasher: &hasher)
|
||||
deepHashMessages(value: sha1, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseLivePhoto: Hashable {
|
||||
var still: BaseResource
|
||||
var video: BaseResource? = nil
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BaseLivePhoto? {
|
||||
let still = pigeonVar_list[0] as! BaseResource
|
||||
let video: BaseResource? = nilOrValue(pigeonVar_list[1])
|
||||
|
||||
return BaseLivePhoto(
|
||||
still: still,
|
||||
video: video
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
still,
|
||||
video,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BaseLivePhoto, rhs: BaseLivePhoto) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.still, rhs.still) && deepEqualsMessages(lhs.video, rhs.video)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseLivePhoto")
|
||||
deepHashMessages(value: still, hasher: &hasher)
|
||||
deepHashMessages(value: video, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
@@ -546,25 +468,15 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return EditState(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 131:
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
case 131:
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
case 133:
|
||||
case 132:
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
case 134:
|
||||
case 133:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
case 135:
|
||||
case 134:
|
||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||
case 136:
|
||||
return BaseResource.fromList(self.readValue() as! [Any?])
|
||||
case 137:
|
||||
return BaseLivePhoto.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
@@ -576,29 +488,20 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||
if let value = value as? PlatformAssetPlaybackStyle {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? EditState {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
super.writeByte(131)
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeByte(132)
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? SyncDelta {
|
||||
super.writeByte(133)
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? HashResult {
|
||||
super.writeByte(134)
|
||||
super.writeByte(133)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
super.writeByte(135)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? BaseResource {
|
||||
super.writeByte(136)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? BaseLivePhoto {
|
||||
super.writeByte(137)
|
||||
super.writeByte(134)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
@@ -637,9 +540,6 @@ protocol NativeSyncApi {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
|
||||
func getBaseLivePhoto(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -873,65 +773,5 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getBaseResourceChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getBaseResourceChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getBaseResourceChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getEditStateChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getEditStateChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getEditStateChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getBaseLivePhotoChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getBaseLivePhotoChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getBaseLivePhoto(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getBaseLivePhotoChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Photos
|
||||
import CryptoKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AssetWrapper: Hashable, Equatable {
|
||||
let asset: PlatformAsset
|
||||
@@ -477,302 +476,4 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
func getBaseResource(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
let result = try await self.streamBaseResource(
|
||||
resource: originals.still,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(result))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads both readable originals of an edited live photo (still + paired video) so the
|
||||
// backup can upload the unedited pair and stack the edit onto it. Same edited-only gate
|
||||
// as getBaseResource. video is nil when the asset has no paired video left to recover
|
||||
// (e.g. the edit turned Live off); the still temp is removed if the video read fails.
|
||||
func getBaseLivePhoto(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseLivePhoto?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
do {
|
||||
guard let originals = try await Self.originalsForEditedAsset(assetId, allowNetworkAccess: allowNetworkAccess)
|
||||
else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
let still = try await self.streamBaseResource(
|
||||
resource: originals.still,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
var video: BaseResource? = nil
|
||||
if let videoRes = originals.video {
|
||||
do {
|
||||
video = try await self.streamBaseResource(
|
||||
resource: videoRes,
|
||||
localId: assetId,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(atPath: still.path)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
self.completeWhenActive(for: completion, with: .success(BaseLivePhoto(still: still, video: video)))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns whether the asset carries a live Photos edit without reading the photo
|
||||
// itself, only the small adjustment metadata. The revert probe relies on this to
|
||||
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
|
||||
// mistakes an unreadable edit for a revert.
|
||||
func getEditState(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<EditState, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
// Not in the library, so don't answer "not edited" (the caller acts on that).
|
||||
return self.completeWhenActive(for: completion, with: .success(.unknown))
|
||||
}
|
||||
let state = await Self.classifyEdit(
|
||||
resources: PHAssetResource.assetResources(for: asset),
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(state))
|
||||
}
|
||||
}
|
||||
|
||||
// adjustmentRenderTypes for a photo with no real edit: a plain capture, a
|
||||
// Photographic Style, or a reverted edit. A real edit changes this value.
|
||||
private static let kNoEditRenderTypes = 27648
|
||||
|
||||
// Idle deadline for the base-resource reads: cancel only after this long with no
|
||||
// data received, so a stalled iCloud fetch can't hang the backup forever but a
|
||||
// big original on a slow link keeps downloading as long as chunks flow.
|
||||
private static let kBaseReadTimeoutSeconds: Double = 120
|
||||
|
||||
private final class ResourceRequestRef {
|
||||
var id: PHAssetResourceDataRequestID?
|
||||
// Written from the resource callback queue, read from the deadline timer;
|
||||
// unsynchronized on purpose — the read below clamps, so the worst case is
|
||||
// the timer re-arming one extra round.
|
||||
var lastActivity = DispatchTime.now()
|
||||
}
|
||||
|
||||
// Re-arming watchdog: fires after `delay`, cancels if nothing arrived for a full
|
||||
// timeout window, otherwise re-arms for the remainder of the window.
|
||||
private static func armIdleDeadline(_ ref: ResourceRequestRef, after delay: Double = kBaseReadTimeoutSeconds) {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
|
||||
guard let id = ref.id else { return }
|
||||
let nowNs = DispatchTime.now().uptimeNanoseconds
|
||||
let lastNs = ref.lastActivity.uptimeNanoseconds
|
||||
// lastActivity can race ahead of the captured now; treat that as activity.
|
||||
let idle = nowNs > lastNs ? Double(nowNs - lastNs) / 1_000_000_000 : 0
|
||||
if idle >= kBaseReadTimeoutSeconds {
|
||||
PHAssetResourceManager.default().cancelDataRequest(id)
|
||||
} else {
|
||||
armIdleDeadline(ref, after: kBaseReadTimeoutSeconds - idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shared gate for the base readers: fetch the asset, classify the edit from its
|
||||
// adjustment metadata, and pick the original resources. nil = positively nothing
|
||||
// to recover (missing asset, not edited, or no readable original still). An
|
||||
// unreadable plist throws instead — that's "can't tell right now", and Dart
|
||||
// defers the asset rather than uploading the edit standalone for good.
|
||||
private static func originalsForEditedAsset(
|
||||
_ assetId: String,
|
||||
allowNetworkAccess: Bool
|
||||
) async throws -> (still: PHAssetResource, video: PHAssetResource?)? {
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
return nil
|
||||
}
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
let state = await classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
|
||||
if state == .unknown {
|
||||
throw PigeonError(
|
||||
code: "unknownEditState",
|
||||
message: "Could not read adjustment metadata for \(assetId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
guard state == .edited, let still = originalStillResource(resources) else {
|
||||
return nil
|
||||
}
|
||||
return (still, originalPairedVideoResource(resources))
|
||||
}
|
||||
|
||||
// Works out the edit state from Adjustments.plist only (never reads the photo).
|
||||
// adjustmentRenderTypes is the signal: a real edit moves it off the baseline, while a
|
||||
// plain capture, a Photographic Style, and a reverted edit all sit at the baseline. The
|
||||
// editor id is NOT reliable: com.apple.camera authors both styles and some real edits
|
||||
// (e.g. changing the Photographic Style after capture), so we key off the render types
|
||||
// alone. Cleanup and object-removal write AdjustmentsSecondary.data, which we count as
|
||||
// edited. unknown = couldn't read the plist (offloaded, no network).
|
||||
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
|
||||
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
|
||||
return .edited
|
||||
}
|
||||
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
|
||||
return .notEdited
|
||||
}
|
||||
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
|
||||
else {
|
||||
return .unknown
|
||||
}
|
||||
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
|
||||
let isUserEdit = renderTypes != nil && renderTypes != kNoEditRenderTypes
|
||||
return isUserEdit ? .edited : .notEdited
|
||||
}
|
||||
|
||||
// The unedited original still, told apart from the edited "current" render by isCurrent.
|
||||
// Prefer the non-current .photo; fall back to the .adjustmentBasePhoto flavor some
|
||||
// creation-API / third-party-editor layouts use for the unaltered source (their .photo
|
||||
// IS the edited render, so this must come before the bare .photo net); last, a lone
|
||||
// .photo for single-resource assets or a failed isCurrent read.
|
||||
private static func originalStillResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
|
||||
return resources.first(where: { $0.type == .photo && !$0.isCurrent })
|
||||
?? resources.first(where: { $0.type == .adjustmentBasePhoto })
|
||||
?? resources.first(where: { $0.type == .photo })
|
||||
}
|
||||
|
||||
// The unedited original paired video, same isCurrent / adjustment-base ordering as the
|
||||
// still. nil when the asset carries no paired video (not live, or Live turned off).
|
||||
private static func originalPairedVideoResource(_ resources: [PHAssetResource]) -> PHAssetResource? {
|
||||
return resources.first(where: { $0.type == .pairedVideo && !$0.isCurrent })
|
||||
?? resources.first(where: { $0.type == .adjustmentBasePairedVideo })
|
||||
?? resources.first(where: { $0.type == .pairedVideo })
|
||||
}
|
||||
|
||||
private func streamBaseResource(
|
||||
resource: PHAssetResource,
|
||||
localId: String,
|
||||
allowNetworkAccess: Bool
|
||||
) async throws -> BaseResource {
|
||||
let safeId = localId.replacingOccurrences(of: "/", with: "_")
|
||||
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
|
||||
// Library/Caches, not tmp: the chain can span launches and clearCache wipes
|
||||
// tmp at the start of every upload run. Swept by clearEditBaseCache instead.
|
||||
let cacheRoot =
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let tempDir = cacheRoot.appendingPathComponent("immich_base", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
|
||||
let unique = UUID().uuidString.prefix(8)
|
||||
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
|
||||
|
||||
// Write the resource to disk and hash it chunk by chunk, so a big original (e.g.
|
||||
// ProRAW) never sits fully in memory on the upload thread.
|
||||
FileManager.default.createFile(atPath: tempUrl.path, contents: nil)
|
||||
guard let handle = try? FileHandle(forWritingTo: tempUrl) else {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw PigeonError(
|
||||
code: "baseResourceWriteFailed",
|
||||
message: "Failed to open temp file for base resource \(localId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
|
||||
var hasher = Insecure.SHA1()
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
|
||||
// Deadline + cancellation so a stalled iCloud read can't hang the backup forever;
|
||||
// a write failure also cancels right away instead of draining the download for nothing.
|
||||
let requestRef = ResourceRequestRef()
|
||||
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||
var writeFailed = false
|
||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { chunk in
|
||||
requestRef.lastActivity = DispatchTime.now()
|
||||
if writeFailed { return }
|
||||
do {
|
||||
try handle.write(contentsOf: chunk)
|
||||
hasher.update(data: chunk)
|
||||
} catch {
|
||||
writeFailed = true
|
||||
if let id = requestRef.id {
|
||||
PHAssetResourceManager.default().cancelDataRequest(id)
|
||||
}
|
||||
}
|
||||
},
|
||||
completionHandler: { error in
|
||||
requestRef.id = nil
|
||||
continuation.resume(returning: error == nil && !writeFailed)
|
||||
}
|
||||
)
|
||||
Self.armIdleDeadline(requestRef)
|
||||
}
|
||||
|
||||
try? handle.close()
|
||||
|
||||
guard succeeded else {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw PigeonError(
|
||||
code: "baseResourceReadFailed",
|
||||
message: "Failed to read base resource for \(localId)",
|
||||
details: nil
|
||||
)
|
||||
}
|
||||
|
||||
let sha1 = Data(hasher.finalize()).base64EncodedString()
|
||||
return BaseResource(path: tempUrl.path, sha1: sha1)
|
||||
}
|
||||
|
||||
private static func collectResourceData(
|
||||
_ resource: PHAssetResource,
|
||||
allowNetworkAccess: Bool
|
||||
) async -> Data? {
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
var buffer = Data()
|
||||
let requestRef = ResourceRequestRef()
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
|
||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in
|
||||
requestRef.lastActivity = DispatchTime.now()
|
||||
buffer.append(data)
|
||||
},
|
||||
completionHandler: { error in
|
||||
requestRef.id = nil
|
||||
continuation.resume(returning: error == nil ? buffer : nil)
|
||||
}
|
||||
)
|
||||
armIdleDeadline(requestRef)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,16 +20,6 @@ const String kSecuredPinCode = "secured_pin_code";
|
||||
const String kManualUploadGroup = 'manual_upload_group';
|
||||
const String kBackupGroup = 'backup_group';
|
||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kBackupEditPairGroup = 'backup_edit_pair_group';
|
||||
|
||||
// Upload multipart 'visibility' value for motion videos (server AssetVisibility.Hidden)
|
||||
// so they never flash onto the timeline before their still links them.
|
||||
const String kHiddenVisibility = 'hidden';
|
||||
|
||||
// Server's 400 message when stackParentId points at a trashed/deleted asset
|
||||
// (asset-media.service.ts). Matching it clears the stale prior stamps so the
|
||||
// next backup cycle re-resolves instead of looping on the same dead id.
|
||||
const String kDeadStackParentError = 'Cannot stack onto a trashed or missing asset';
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
|
||||
@@ -12,13 +12,6 @@ class LocalAsset extends BaseAsset {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
// Remote id of this asset's previous upload; used to stack a new edit under it.
|
||||
final String? priorRemoteId;
|
||||
|
||||
// Local checksum at the last sync action; lets backup skip an already-handled
|
||||
// local whose current render hashes fresh (the iOS revert case).
|
||||
final String? syncedChecksum;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
String? remoteId,
|
||||
@@ -39,8 +32,6 @@ class LocalAsset extends BaseAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -129,8 +120,6 @@ class LocalAsset extends BaseAsset {
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? isEdited,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -151,8 +140,6 @@ class LocalAsset extends BaseAsset {
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@ class RemoteAsset extends BaseAsset {
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
// The linked local's current checksum. Differs from [checksum] when the link
|
||||
// came via priorRemoteId (the local re-encoded on device, e.g. a revert); local
|
||||
// renders are cache-keyed by this so on-device changes aren't shown stale.
|
||||
final String? localChecksum;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
String? localId,
|
||||
@@ -38,7 +33,6 @@ class RemoteAsset extends BaseAsset {
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
this.localChecksum,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -97,8 +91,7 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt &&
|
||||
localChecksum == other.localChecksum;
|
||||
deletedAt == other.deletedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -111,8 +104,7 @@ class RemoteAsset extends BaseAsset {
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode ^
|
||||
localChecksum.hashCode;
|
||||
deletedAt.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -134,7 +126,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
String? localChecksum,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -156,7 +147,6 @@ class RemoteAsset extends BaseAsset {
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
localChecksum: localChecksum ?? this.localChecksum,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -184,7 +174,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
super.livePhotoVideoId,
|
||||
super.stackId,
|
||||
super.isEdited = false,
|
||||
super.localChecksum,
|
||||
this.exifInfo = const ExifInfo(),
|
||||
});
|
||||
|
||||
@@ -223,7 +212,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
String? localChecksum,
|
||||
ExifInfo? exifInfo,
|
||||
}) {
|
||||
return RemoteAssetExif(
|
||||
@@ -246,7 +234,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
localChecksum: localChecksum ?? this.localChecksum,
|
||||
exifInfo: exifInfo ?? this.exifInfo, // Use the new parameter
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Handles an edit that was reverted in Photos. The local was uploaded as an edit
|
||||
/// before but isn't edited now, so flip the stack primary back to the original (via
|
||||
/// prior_remote_id) and mark it handled so we don't re-upload the reverted render.
|
||||
/// Nothing is trashed; all the edits stay in the stack.
|
||||
class EditRevertService {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final _log = Logger('EditRevertService');
|
||||
|
||||
EditRevertService({
|
||||
required this._nativeSyncApi,
|
||||
required this._stackRepository,
|
||||
required this._localAssetRepository,
|
||||
required this._assetApiRepository,
|
||||
});
|
||||
|
||||
/// Returns the remote id the stack cover was flipped back to when the asset
|
||||
/// was a revert and was handled (caller skips the upload and can report that
|
||||
/// id); null to fall through to the normal upload path.
|
||||
Future<String?> tryHandleRevert(LocalAsset asset) async {
|
||||
if (asset.priorRemoteId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only "not edited" is a revert. `edited` is a fresh edit, so let the pair flow
|
||||
// take it. `unknown` means we couldn't read the adjustment (offloaded to iCloud,
|
||||
// network off); bail there too instead of mistaking an unreadable edit for a
|
||||
// revert and flipping the stack. Network off keeps this a cheap offline read.
|
||||
try {
|
||||
final editState = await _nativeSyncApi
|
||||
.getEditState(asset.id, allowNetworkAccess: false)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (editState != EditState.notEdited) {
|
||||
return null;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
// It's a revert. Styled photos hit this path because iOS re-encodes the revert to
|
||||
// fresh bytes, so it looks like a new backup candidate and reaches upload.
|
||||
// Non-styled reverts hash back to the base instead, aren't candidates, and get
|
||||
// flipped at hash time in HashService._reconcileReverts. Fresh bytes match nothing
|
||||
// remote, so flip by structure: prior_remote_id is the current primary (the latest
|
||||
// edit), flip it back to the base.
|
||||
final String stackId;
|
||||
final String baseId;
|
||||
try {
|
||||
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
|
||||
if (foundStack == null) {
|
||||
return null;
|
||||
}
|
||||
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
stackId = foundStack;
|
||||
baseId = base;
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(stackId, baseId);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The server flip is what makes the revert handled. If the local writes fail,
|
||||
// falling through would upload the reverted render as a brand-new edit — the
|
||||
// opposite of the user's action — so log and let checkpoint sync heal local state.
|
||||
try {
|
||||
await _stackRepository.setPrimary(stackId, baseId);
|
||||
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert local reconcile failed for ${asset.id}", error, stack);
|
||||
}
|
||||
|
||||
return baseId;
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||
@@ -22,8 +20,6 @@ class HashService {
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final Completer<void>? _cancellation;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
HashService({
|
||||
@@ -32,8 +28,6 @@ class HashService {
|
||||
required this._trashedLocalAssetRepository,
|
||||
required this._nativeSyncApi,
|
||||
this._cancellation,
|
||||
required this._stackRepository,
|
||||
required this._assetApiRepository,
|
||||
int? batchSize,
|
||||
}) : _batchSize = batchSize ?? kBatchHashFileLimit {
|
||||
// Stop the in-flight native hash call promptly on cancellation; the loops
|
||||
@@ -72,17 +66,6 @@ class HashService {
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Revert reconcile for non-styled photos: the reverted edit hashes back to the
|
||||
// original's exact bytes, which are already the stack base, so it's not a backup
|
||||
// candidate and never reaches upload. Flip the primary here. Styled photos
|
||||
// re-encode to fresh bytes and get flipped on the upload path instead
|
||||
// (EditRevertService.tryHandleRevert). Runs every cycle, not just when something
|
||||
// hashed: a flip that failed (offline at hash time) has no second hash to ride,
|
||||
// and the stack-driven target query is cheap and self-limiting.
|
||||
if (CurrentPlatform.isIOS && !isCancelled) {
|
||||
await _reconcileReverts();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -160,30 +143,4 @@ class HashService {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconcileReverts() async {
|
||||
final List<StackReconcileTarget> targets;
|
||||
try {
|
||||
targets = await _stackRepository.findRevertReconcileTargets();
|
||||
} catch (error, stack) {
|
||||
_log.warning("findRevertReconcileTargets failed", error, stack);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final target in targets) {
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
|
||||
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
|
||||
// Roll priorRemoteId forward to the matched member (now the primary) so a
|
||||
// later edit stacks onto THAT (the current render), not the old edit.
|
||||
await _localAssetRepository.markSynced(
|
||||
target.localAssetId,
|
||||
priorRemoteId: target.newPrimaryId,
|
||||
syncedChecksum: target.localAssetChecksum,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -138,7 +137,7 @@ class RemoteAlbumService {
|
||||
Future<RemoteAlbum> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? description,
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -360,7 +360,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
@@ -403,7 +403,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
if (assets.isNotEmpty && exifs.isNotEmpty) {
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch');
|
||||
await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch');
|
||||
_logger.info('Successfully processed ${assets.length} assets in batch');
|
||||
}
|
||||
@@ -444,7 +444,7 @@ class SyncStreamService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
@@ -482,7 +482,7 @@ class SyncStreamService {
|
||||
.whereType<SyncAssetEditV1>()
|
||||
.toList();
|
||||
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit', fromWebsocket: true);
|
||||
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
|
||||
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
|
||||
|
||||
_logger.info(
|
||||
|
||||
@@ -160,22 +160,6 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
|
||||
/// joins an in-flight sync whose snapshot can pre-date a just-received change
|
||||
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
|
||||
/// first, then run a fresh one.
|
||||
Future<void> runFreshRemoteSync() async {
|
||||
final inflight = _syncTask;
|
||||
if (inflight != null) {
|
||||
try {
|
||||
await inflight.future;
|
||||
} catch (_) {
|
||||
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
|
||||
}
|
||||
}
|
||||
await syncRemote();
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diacritic/diacritic.dart' as diacritic;
|
||||
|
||||
extension StringExtension on String {
|
||||
String capitalize() {
|
||||
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
|
||||
}
|
||||
|
||||
String? get nullIfEmpty => isEmpty ? null : this;
|
||||
|
||||
String removeDiacritics() => diacritic.removeDiacritics(this);
|
||||
}
|
||||
|
||||
extension DurationExtension on String {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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_cloud_id ON local_asset_entity (i_cloud_id)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)')
|
||||
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const LocalAssetEntity();
|
||||
|
||||
@@ -29,14 +28,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
// remote id of the previous upload (iOS edit-pair stacking)
|
||||
TextColumn get priorRemoteId => text().nullable()();
|
||||
|
||||
// local checksum at the last sync action. Lets the backup query skip a local
|
||||
// whose current hash matches nothing remote but is still "handled": the iOS
|
||||
// revert case, where the reverted render hashes fresh but is already reconciled.
|
||||
TextColumn get syncedChecksum => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -61,7 +52,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
longitude: longitude,
|
||||
cloudId: iCloudId,
|
||||
isEdited: false,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-155
@@ -26,8 +26,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -47,8 +45,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -145,16 +141,6 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -245,16 +231,6 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -324,16 +300,6 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -393,8 +359,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -412,8 +376,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -434,8 +396,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -453,8 +413,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -679,28 +637,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
static const i0.VerificationMeta _priorRemoteIdMeta =
|
||||
const i0.VerificationMeta('priorRemoteId');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> priorRemoteId =
|
||||
i0.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _syncedChecksumMeta =
|
||||
const i0.VerificationMeta('syncedChecksum');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> syncedChecksum =
|
||||
i0.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
@@ -719,8 +655,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -825,24 +759,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('prior_remote_id')) {
|
||||
context.handle(
|
||||
_priorRemoteIdMeta,
|
||||
priorRemoteId.isAcceptableOrUnknown(
|
||||
data['prior_remote_id']!,
|
||||
_priorRemoteIdMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('synced_checksum')) {
|
||||
context.handle(
|
||||
_syncedChecksumMeta,
|
||||
syncedChecksum.isAcceptableOrUnknown(
|
||||
data['synced_checksum']!,
|
||||
_syncedChecksumMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -923,14 +839,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
priorRemoteId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}prior_remote_id'],
|
||||
),
|
||||
syncedChecksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}synced_checksum'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -969,8 +877,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
final String? priorRemoteId;
|
||||
final String? syncedChecksum;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -988,8 +894,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -1034,12 +938,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
||||
);
|
||||
}
|
||||
if (!nullToAbsent || priorRemoteId != null) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
|
||||
}
|
||||
if (!nullToAbsent || syncedChecksum != null) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1069,8 +967,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||
serializer.fromJson<int>(json['playbackStyle']),
|
||||
),
|
||||
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
|
||||
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1097,8 +993,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||
),
|
||||
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
|
||||
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1119,8 +1013,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -1140,12 +1032,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId.present
|
||||
? priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum.present
|
||||
? syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -1175,12 +1061,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
priorRemoteId: data.priorRemoteId.present
|
||||
? data.priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: data.syncedChecksum.present
|
||||
? data.syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1202,9 +1082,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1227,8 +1105,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1249,9 +1125,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle &&
|
||||
other.priorRemoteId == this.priorRemoteId &&
|
||||
other.syncedChecksum == this.syncedChecksum);
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1272,8 +1146,6 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
final i0.Value<String?> priorRemoteId;
|
||||
final i0.Value<String?> syncedChecksum;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1291,8 +1163,6 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1311,8 +1181,6 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1333,8 +1201,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
i0.Expression<String>? priorRemoteId,
|
||||
i0.Expression<String>? syncedChecksum,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1353,8 +1219,6 @@ class LocalAssetEntityCompanion
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
|
||||
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1375,8 +1239,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
i0.Value<String?>? priorRemoteId,
|
||||
i0.Value<String?>? syncedChecksum,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1395,8 +1257,6 @@ class LocalAssetEntityCompanion
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1457,12 +1317,6 @@ class LocalAssetEntityCompanion
|
||||
),
|
||||
);
|
||||
}
|
||||
if (priorRemoteId.present) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
|
||||
}
|
||||
if (syncedChecksum.present) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1484,9 +1338,7 @@ class LocalAssetEntityCompanion
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1500,7 +1352,3 @@ i0.Index get idxLocalAssetCreatedAt => i0.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
i0.Index get idxLocalAssetPriorRemoteId => i0.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
|
||||
@@ -7,13 +7,7 @@ import 'local_album_asset.entity.dart';
|
||||
mergedAsset:
|
||||
SELECT
|
||||
rae.id as remote_id,
|
||||
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
|
||||
-- edit re-encodes to fresh bytes so the checksum no longer matches, but its
|
||||
-- prior_remote_id still points at this remote, so fall back to that.
|
||||
COALESCE(
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_id,
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
|
||||
rae.name,
|
||||
rae."type",
|
||||
rae.created_at as created_at,
|
||||
@@ -24,12 +18,6 @@ SELECT
|
||||
rae.is_favorite,
|
||||
rae.thumb_hash,
|
||||
rae.checksum,
|
||||
-- the linked local's current checksum (same row local_id picks), so local
|
||||
-- renders are cache-keyed by the bytes on device, not the server value.
|
||||
COALESCE(
|
||||
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.checksum FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_checksum,
|
||||
rae.owner_id,
|
||||
rae.live_photo_video_id,
|
||||
0 as orientation,
|
||||
@@ -69,7 +57,6 @@ SELECT
|
||||
lae.is_favorite,
|
||||
NULL as thumb_hash,
|
||||
lae.checksum,
|
||||
lae.checksum as local_checksum,
|
||||
NULL as owner_id,
|
||||
NULL as live_photo_video_id,
|
||||
lae.orientation,
|
||||
@@ -96,15 +83,6 @@ AND NOT EXISTS (
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: if this local was already uploaded (its
|
||||
-- prior_remote_id resolves to a remote row), hide the local tile so the remote
|
||||
-- (the edit, or the flipped-back original) is the single source of truth. Kills
|
||||
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
|
||||
-- A trashed prior still hides it — trashing on the server shouldn't pop the
|
||||
-- photo back onto the local timeline; only a hard delete (row gone) does.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit;
|
||||
|
||||
@@ -158,11 +136,6 @@ FROM
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: hide a local already represented by a remote
|
||||
-- row (trashed included, same as the tile query above).
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
|
||||
)
|
||||
)
|
||||
GROUP BY bucket_date
|
||||
ORDER BY bucket_date DESC;
|
||||
|
||||
+2
-5
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
);
|
||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||
return customSelect(
|
||||
'SELECT rae.id AS remote_id, COALESCE((SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, COALESCE((SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.checksum FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, lae.checksum AS local_checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds)) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -58,7 +58,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
isFavorite: row.read<bool>('is_favorite'),
|
||||
thumbHash: row.readNullable<String>('thumb_hash'),
|
||||
checksum: row.readNullable<String>('checksum'),
|
||||
localChecksum: row.readNullable<String>('local_checksum'),
|
||||
ownerId: row.readNullable<String>('owner_id'),
|
||||
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
|
||||
orientation: row.read<int>('orientation'),
|
||||
@@ -82,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||
$arrayStartIndex += userIds.length;
|
||||
return customSelect(
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds))) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
variables: [
|
||||
i0.Variable<int>(groupBy),
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
@@ -133,7 +132,6 @@ class MergedAssetResult {
|
||||
final bool isFavorite;
|
||||
final String? thumbHash;
|
||||
final String? checksum;
|
||||
final String? localChecksum;
|
||||
final String? ownerId;
|
||||
final String? livePhotoVideoId;
|
||||
final int orientation;
|
||||
@@ -158,7 +156,6 @@ class MergedAssetResult {
|
||||
required this.isFavorite,
|
||||
this.thumbHash,
|
||||
this.checksum,
|
||||
this.localChecksum,
|
||||
this.ownerId,
|
||||
this.livePhotoVideoId,
|
||||
required this.orientation,
|
||||
|
||||
@@ -55,8 +55,6 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
|
||||
}
|
||||
|
||||
extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
// localId callers attach it via a checksum-equality join, so the local's
|
||||
// bytes are the remote's — key local renders by the same checksum.
|
||||
RemoteAsset toDto({String? localId}) => RemoteAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
@@ -74,7 +72,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
visibility: visibility,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
localId: localId,
|
||||
localChecksum: localId == null ? null : checksum,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
deletedAt: deletedAt,
|
||||
|
||||
@@ -34,27 +34,14 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
/// - total: number of distinct assets in selected albums, excluding those that are also in excluded albums
|
||||
/// - backup: number of those assets that already exist on the server for [userId]
|
||||
/// - remainder: number of those assets that do not yet exist on the server for [userId]
|
||||
/// (includes processing), excluding handled iOS reverts (syncedChecksum == checksum
|
||||
/// with the prior upload still on the server — trashed counts, like the
|
||||
/// checksum arm; only a hard delete re-opens the asset)
|
||||
/// (includes processing)
|
||||
/// - processing: number of those assets that are still preparing/have a null checksum
|
||||
Future<({int total, int remainder, int processing})> getAllCounts(String userId) async {
|
||||
const sql = '''
|
||||
SELECT
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE lae.checksum IS NULL) AS processing_count,
|
||||
COUNT(*) FILTER (
|
||||
WHERE rae.id IS NULL
|
||||
AND (
|
||||
lae.checksum IS NULL
|
||||
OR lae.synced_checksum IS NULL
|
||||
OR lae.synced_checksum != lae.checksum
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM main.remote_asset_entity pr
|
||||
WHERE pr.id = lae.prior_remote_id
|
||||
)
|
||||
)
|
||||
) AS remainder_count
|
||||
COUNT(*) FILTER (WHERE rae.id IS NULL) AS remainder_count
|
||||
FROM local_asset_entity lae
|
||||
LEFT JOIN main.remote_asset_entity rae
|
||||
ON lae.checksum = rae.checksum AND rae.owner_id = ?1
|
||||
@@ -117,20 +104,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
||||
),
|
||||
) &
|
||||
// iOS revert: a reverted local hashes fresh (matches nothing remote),
|
||||
// but if it was already reconciled (syncedChecksum == current checksum)
|
||||
// it's handled, so don't re-queue it as a fresh upload. Suppress while
|
||||
// the prior row exists at all — trashed stays suppressed (same
|
||||
// convention as the checksum arm above); only a hard-deleted remote
|
||||
// must become a candidate again.
|
||||
(lae.checksum.isNull() |
|
||||
lae.syncedChecksum.isNull() |
|
||||
lae.syncedChecksum.equalsExp(lae.checksum).not() |
|
||||
notExistsQuery(
|
||||
_db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([_db.remoteAssetEntity.id])
|
||||
..where(_db.remoteAssetEntity.id.equalsExp(lae.priorRemoteId)),
|
||||
)) &
|
||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||
)
|
||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||
|
||||
@@ -120,7 +120,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 30;
|
||||
int get schemaVersion => 29;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -308,11 +308,6 @@ class Drift extends $Drift {
|
||||
await m.createTable(v29.assetOcrEntity);
|
||||
await m.createIndex(v29.idxAssetOcrAssetId);
|
||||
},
|
||||
from29To30: (m, v30) async {
|
||||
await m.addColumn(v30.localAssetEntity, v30.localAssetEntity.priorRemoteId);
|
||||
await m.addColumn(v30.localAssetEntity, v30.localAssetEntity.syncedChecksum);
|
||||
await m.createIndex(v30.idxLocalAssetPriorRemoteId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i4.idxLocalAssetChecksum,
|
||||
i4.idxLocalAssetCloudId,
|
||||
i4.idxLocalAssetCreatedAt,
|
||||
i4.idxLocalAssetPriorRemoteId,
|
||||
i3.idxStackPrimaryAssetId,
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
|
||||
@@ -15331,650 +15331,6 @@ i1.GeneratedColumn<String> _column_223(String aliasedName) =>
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
|
||||
final class Schema30 extends i0.VersionedSchema {
|
||||
Schema30({required super.database}) : super(version: 30);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetCreatedAt,
|
||||
idxLocalAssetPriorRemoteId,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
settings,
|
||||
assetOcrEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
idxAssetOcrAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape52 localAssetEntity = Shape52(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_224,
|
||||
_column_225,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
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 idxLocalAssetCreatedAt = i1.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
final i1.Index idxLocalAssetPriorRemoteId = i1.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
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)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
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_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
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_159, _column_177],
|
||||
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_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
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_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
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_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_205,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_206,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 settings = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'settings',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_211, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 assetOcrEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
_column_217,
|
||||
_column_218,
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_221,
|
||||
_column_222,
|
||||
_column_223,
|
||||
_column_201,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
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 idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
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)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetOcrAssetId = i1.Index(
|
||||
'idx_asset_ocr_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape52 extends i0.VersionedTable {
|
||||
Shape52({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<String> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
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 durationMs =>
|
||||
columnsByName['duration_ms']! 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<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
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<String> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get priorRemoteId =>
|
||||
columnsByName['prior_remote_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get syncedChecksum =>
|
||||
columnsByName['synced_checksum']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_224(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_225(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -16004,7 +15360,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -16148,11 +15503,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from28To29(migrator, schema);
|
||||
return 29;
|
||||
case 29:
|
||||
final schema = Schema30(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from29To30(migrator, schema);
|
||||
return 30;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -16188,7 +15538,6 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -16219,6 +15568,5 @@ i1.OnUpgrade stepByStep({
|
||||
from26To27: from26To27,
|
||||
from27To28: from27To28,
|
||||
from28To29: from28To29,
|
||||
from29To30: from29To30,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -64,20 +64,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> markSynced(String localId, {required String priorRemoteId, required String? syncedChecksum}) {
|
||||
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drops the edit-stacking stamps so the next backup cycle re-resolves the
|
||||
/// asset from scratch (used when the server says the stamped prior is gone).
|
||||
Future<void> clearSyncStamps(String localId) {
|
||||
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||
const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(List<String> ids) {
|
||||
if (ids.isEmpty) {
|
||||
return Future.value();
|
||||
|
||||
@@ -46,9 +46,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
|
||||
return query.map((row) {
|
||||
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
||||
final localId = row.read(_db.localAssetEntity.id);
|
||||
// checksum-equality join: the local's bytes are the remote's
|
||||
return asset.copyWith(localId: localId, localChecksum: localId == null ? null : asset.checksum);
|
||||
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,22 +3,6 @@ import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class StackReconcileTarget {
|
||||
final String stackId;
|
||||
final String newPrimaryId;
|
||||
final String localAssetId;
|
||||
final String localAssetChecksum;
|
||||
|
||||
const StackReconcileTarget({
|
||||
required this.stackId,
|
||||
required this.newPrimaryId,
|
||||
required this.localAssetId,
|
||||
required this.localAssetChecksum,
|
||||
});
|
||||
}
|
||||
|
||||
enum PriorState { live, trashed, missing }
|
||||
|
||||
class DriftStackRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftStackRepository(this._db) : super(_db);
|
||||
@@ -30,134 +14,6 @@ class DriftStackRepository extends DriftDatabaseRepository {
|
||||
return stack.toDto();
|
||||
}).get();
|
||||
}
|
||||
|
||||
// Find stacks whose primary should flip back after a revert: a local that was
|
||||
// uploaded as an edit (prior in the stack) now hashes to a DIFFERENT member
|
||||
// that isn't the primary. Two discriminators keep this from fighting stacks
|
||||
// the user arranged by hand: the matched member must not be the local's own
|
||||
// prior (a true revert has prior = the edit, member = the base), and the
|
||||
// local must be unreconciled (synced_checksum != checksum — the flip below
|
||||
// writes synced = checksum, which is what makes this self-limiting). Driven
|
||||
// from stack_entity so the work scales with the number of stacks (few), and
|
||||
// runs every hash cycle so a flip that failed offline gets retried.
|
||||
Future<List<StackReconcileTarget>> findRevertReconcileTargets() async {
|
||||
final rows = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT
|
||||
s.id AS stack_id,
|
||||
member.id AS new_primary,
|
||||
local.id AS local_id,
|
||||
local.checksum AS local_checksum
|
||||
FROM stack_entity s
|
||||
INNER JOIN remote_asset_entity member
|
||||
ON member.stack_id = s.id
|
||||
AND member.deleted_at IS NULL
|
||||
INNER JOIN local_asset_entity local
|
||||
ON local.checksum = member.checksum
|
||||
AND local.prior_remote_id IS NOT NULL
|
||||
AND local.prior_remote_id != member.id
|
||||
AND local.synced_checksum IS NOT local.checksum
|
||||
INNER JOIN remote_asset_entity prior
|
||||
ON prior.id = local.prior_remote_id
|
||||
AND prior.stack_id = s.id
|
||||
AND prior.deleted_at IS NULL
|
||||
WHERE s.primary_asset_id != member.id
|
||||
''',
|
||||
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
|
||||
)
|
||||
.get();
|
||||
|
||||
return rows
|
||||
.map(
|
||||
(row) => StackReconcileTarget(
|
||||
stackId: row.read<String>('stack_id'),
|
||||
newPrimaryId: row.read<String>('new_primary'),
|
||||
localAssetId: row.read<String>('local_id'),
|
||||
localAssetChecksum: row.read<String>('local_checksum'),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// What the synced remote table knows about a stamped prior. missing is
|
||||
// ambiguous: either just uploaded and not synced back yet, or hard-deleted on
|
||||
// the server — the caller tells them apart via syncedChecksum (null = a chain
|
||||
// is still mid-flight, so the row simply hasn't synced yet). A locked-folder
|
||||
// row counts as trashed: the server refuses to stack onto it (and with a
|
||||
// message the dead-parent belt doesn't match), so defer until it's unlocked.
|
||||
Future<PriorState> priorState(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
// 3 = locked
|
||||
'SELECT (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE id = ? LIMIT 1',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return PriorState.missing;
|
||||
}
|
||||
return row.read<bool>('blocked') ? PriorState.trashed : PriorState.live;
|
||||
}
|
||||
|
||||
// The synced remote owned by [ownerId] with these exact bytes, if any. The
|
||||
// server keys assets by (owner, checksum), so at most one row matches.
|
||||
// Locked rows count as trashed here too, same reasoning as [priorState].
|
||||
Future<({PriorState state, String? remoteId})> remoteByChecksum(String checksum, String ownerId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
// 3 = locked
|
||||
'SELECT id, (deleted_at IS NOT NULL OR visibility = 3) AS blocked FROM remote_asset_entity WHERE checksum = ? AND owner_id = ? LIMIT 1',
|
||||
variables: [Variable<String>(checksum), Variable<String>(ownerId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
if (row == null) {
|
||||
return (state: PriorState.missing, remoteId: null);
|
||||
}
|
||||
return (state: row.read<bool>('blocked') ? PriorState.trashed : PriorState.live, remoteId: row.read<String>('id'));
|
||||
}
|
||||
|
||||
// The stack a remote asset belongs to, if any. Used by the revert path to find
|
||||
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
|
||||
Future<String?> findStackIdByRemoteId(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('stack_id');
|
||||
}
|
||||
|
||||
// The stack's original base member to flip back to on revert: the earliest-
|
||||
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
|
||||
// before its edits, so oldest uploaded_at = the original.
|
||||
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT id FROM remote_asset_entity
|
||||
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
|
||||
LIMIT 1
|
||||
''',
|
||||
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('id');
|
||||
}
|
||||
|
||||
// Optimistic local primary flip so the timeline updates immediately; the
|
||||
// server's stack-update websocket rewrites it shortly after.
|
||||
Future<void> setPrimary(String stackId, String primaryAssetId) {
|
||||
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
|
||||
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackEntityData {
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class StorageRepository {
|
||||
@@ -151,34 +150,4 @@ class StorageRepository {
|
||||
log.warning("Error deleting temporary directory", error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Base originals for the edit chain live under Library/Caches (immich_base),
|
||||
/// not tmp, so [clearCache] can't wipe a chain still in flight across
|
||||
/// launches. Sweeps only files older than a day: live chains and concurrent
|
||||
/// foreground pair uploads keep their temps; orphans from dead chains go.
|
||||
Future<void> clearEditBaseCache() async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final cache = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${cache.path}/immich_base');
|
||||
if (!await dir.exists()) {
|
||||
return;
|
||||
}
|
||||
final cutoff = DateTime.now().subtract(const Duration(days: 1));
|
||||
await for (final entry in dir.list()) {
|
||||
try {
|
||||
final stat = await entry.stat();
|
||||
if (stat.modified.isBefore(cutoff)) {
|
||||
await entry.delete(recursive: true);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error sweeping ${entry.path}", error, stackTrace);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error sweeping edit base cache", error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dar
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart';
|
||||
@@ -72,12 +71,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetEditEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
// The edit-stacking stamps point at remote rows wiped above; left in
|
||||
// place they'd make the next backup (possibly a different account or
|
||||
// server) stack onto ids that no longer exist.
|
||||
await _db.localAssetEntity.update().write(
|
||||
const LocalAssetEntityCompanion(priorRemoteId: Value(null), syncedChecksum: Value(null)),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
// re-enable FK even if the transaction throws, otherwise the connection
|
||||
@@ -202,27 +195,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// websocket events are a point-in-time snapshot, so on the fast path don't overwrite
|
||||
// link state the checkpoint sync owns (a motion video uploads visible then gets hidden).
|
||||
RemoteAssetEntityCompanion _conflictUpdate(RemoteAssetEntityCompanion companion, bool fromWebsocket) {
|
||||
if (!fromWebsocket) {
|
||||
return companion;
|
||||
}
|
||||
// deletedAt is checkpoint-owned too: a debounced upload-ready snapshot always
|
||||
// carries null and must not resurrect an asset trashed in the meantime.
|
||||
return companion.copyWith(
|
||||
visibility: const Value.absent(),
|
||||
livePhotoVideoId: const Value.absent(),
|
||||
stackId: const Value.absent(),
|
||||
deletedAt: const Value.absent(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV1(
|
||||
Iterable<SyncAssetV1> data, {
|
||||
String debugLabel = 'user',
|
||||
bool fromWebsocket = false,
|
||||
}) async {
|
||||
Future<void> updateAssetsV1(Iterable<SyncAssetV1> data, {String debugLabel = 'user'}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final asset in data) {
|
||||
@@ -251,7 +224,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
batch.insert(
|
||||
_db.remoteAssetEntity,
|
||||
companion.copyWith(id: Value(asset.id)),
|
||||
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -261,11 +234,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetsV2(
|
||||
Iterable<SyncAssetV2> data, {
|
||||
String debugLabel = 'user',
|
||||
bool fromWebsocket = false,
|
||||
}) async {
|
||||
Future<void> updateAssetsV2(Iterable<SyncAssetV2> data, {String debugLabel = 'user'}) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final asset in data) {
|
||||
@@ -294,7 +263,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
batch.insert(
|
||||
_db.remoteAssetEntity,
|
||||
companion.copyWith(id: Value(asset.id)),
|
||||
onConflict: DoUpdate((_) => _conflictUpdate(companion, fromWebsocket)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -88,7 +88,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
livePhotoVideoId: row.livePhotoVideoId,
|
||||
stackId: row.stackId,
|
||||
isEdited: row.isEdited,
|
||||
localChecksum: row.localChecksum,
|
||||
)
|
||||
: LocalAsset(
|
||||
id: row.localId!,
|
||||
|
||||
@@ -197,7 +197,7 @@ class FolderContent extends HookConsumerWidget {
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
"${asset.exifInfo.fileSize != null ? formatBytes(asset.exifInfo.fileSize ?? 0) : ""} • ${DateFormat.yMMMd().format(asset.createdAt)}",
|
||||
"${asset.exifInfo.fileSize != null ? "${formatBytes(asset.exifInfo.fileSize ?? 0)} • " : ""}${DateFormat.yMMMd().format(asset.createdAt.toLocal())}",
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
),
|
||||
|
||||
+9
-158
@@ -88,8 +88,6 @@ int _deepHash(Object? value) {
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
required this.id,
|
||||
@@ -397,80 +395,6 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
BaseResource({required this.path, required this.sha1});
|
||||
|
||||
String path;
|
||||
|
||||
String sha1;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[path, sha1];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BaseResource decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BaseResource(path: result[0]! as String, sha1: result[1]! as String);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BaseResource || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(path, other.path) && _deepEquals(sha1, other.sha1);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseLivePhoto {
|
||||
BaseLivePhoto({required this.still, this.video});
|
||||
|
||||
BaseResource still;
|
||||
|
||||
BaseResource? video;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[still, video];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BaseLivePhoto decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BaseLivePhoto(still: result[0]! as BaseResource, video: result[1] as BaseResource?);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BaseLivePhoto || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(still, other.still) && _deepEquals(video, other.video);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -481,29 +405,20 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is EditState) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(131);
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(132);
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(133);
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(134);
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(135);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is BaseResource) {
|
||||
buffer.putUint8(136);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is BaseLivePhoto) {
|
||||
buffer.putUint8(137);
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
@@ -517,22 +432,15 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||
case 130:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : EditState.values[value];
|
||||
case 131:
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
case 131:
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
case 133:
|
||||
case 132:
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
case 134:
|
||||
case 133:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
case 135:
|
||||
case 134:
|
||||
return CloudIdResult.decode(readValue(buffer)!);
|
||||
case 136:
|
||||
return BaseResource.decode(readValue(buffer)!);
|
||||
case 137:
|
||||
return BaseLivePhoto.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@@ -797,61 +705,4 @@ class NativeSyncApi {
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||
}
|
||||
|
||||
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as BaseResource?;
|
||||
}
|
||||
|
||||
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as EditState;
|
||||
}
|
||||
|
||||
Future<BaseLivePhoto?> getBaseLivePhoto(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseLivePhoto$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as BaseLivePhoto?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftCreateAlbumPage extends ConsumerStatefulWidget {
|
||||
@@ -47,7 +48,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canCreateAlbum => albumTitleController.text.isNotEmpty;
|
||||
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty;
|
||||
|
||||
String _getEffectiveTitle() {
|
||||
return albumTitleController.text.isNotEmpty
|
||||
@@ -169,25 +170,23 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||
onBackgroundTapped();
|
||||
|
||||
final title = _getEffectiveTitle().trim();
|
||||
if (title.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('create_album_title_required'.t()), backgroundColor: context.colorScheme.error),
|
||||
);
|
||||
|
||||
try {
|
||||
final album = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.createAlbumWithAssets(
|
||||
title: title,
|
||||
description: albumDescriptionController.text.trim(),
|
||||
assets: selectedAssets,
|
||||
);
|
||||
|
||||
if (album != null && context.mounted) {
|
||||
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final album = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.createAlbumWithAssets(
|
||||
title: title,
|
||||
description: albumDescriptionController.text.trim(),
|
||||
assets: selectedAssets,
|
||||
);
|
||||
|
||||
if (album != null && context.mounted) {
|
||||
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -64,7 +65,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
|
||||
data: (people) {
|
||||
if (_search != null) {
|
||||
people = people.where((person) {
|
||||
return person.name.toLowerCase().contains(_search!.toLowerCase());
|
||||
return person.name.toLowerCase().removeDiacritics().contains(
|
||||
_search!.toLowerCase().removeDiacritics(),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
return GridView.builder(
|
||||
|
||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dar
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
|
||||
|
||||
@@ -248,13 +247,10 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
||||
try {
|
||||
final newTitle = titleController.text.trim();
|
||||
final newDescription = descriptionController.text.trim();
|
||||
final description = newDescription.isEmpty
|
||||
? const Option<String?>.some(null)
|
||||
: Option<String?>.some(newDescription);
|
||||
|
||||
await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: description);
|
||||
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -100,6 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
OcrToggleButton(asset: asset),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
|
||||
@@ -157,6 +157,55 @@ class _OcrBoxes extends StatelessWidget {
|
||||
final cx = viewportWidth / 2 + position.dx;
|
||||
final cy = viewportHeight / 2 + position.dy;
|
||||
|
||||
final quads = <List<Offset>>[];
|
||||
final boxes = <Widget>[];
|
||||
|
||||
for (final entry in ocrData.asMap().entries) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
|
||||
// Map normalized image coords (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
quads.add([Offset(x1, y1), Offset(x2, y2), Offset(x3, y3), Offset(x4, y4)]);
|
||||
|
||||
boxes.add(
|
||||
_OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => onSelectionChanged(null),
|
||||
@@ -165,47 +214,13 @@ class _OcrBoxes extends StatelessWidget {
|
||||
children: [
|
||||
// Fills the viewport so taps outside boxes deselect
|
||||
SizedBox(width: viewportWidth, height: viewportHeight),
|
||||
...ocrData.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
|
||||
// Map normalized image coords (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return _OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
}),
|
||||
// Dark scrim with the text boxes punched out
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(painter: _OcrScrimPainter(quads: quads)),
|
||||
),
|
||||
),
|
||||
...boxes,
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -307,6 +322,35 @@ class _OcrBoxItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrScrimPainter extends CustomPainter {
|
||||
final List<List<Offset>> quads;
|
||||
|
||||
const _OcrScrimPainter({required this.quads});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// Fill the whole viewport, then subtract each text quad using the even-odd
|
||||
// rule so the original image shows through the boxes.
|
||||
final path = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size);
|
||||
|
||||
for (final quad in quads) {
|
||||
path
|
||||
..moveTo(quad[0].dx, quad[0].dy)
|
||||
..lineTo(quad[1].dx, quad[1].dy)
|
||||
..lineTo(quad[2].dx, quad[2].dy)
|
||||
..lineTo(quad[3].dx, quad[3].dy)
|
||||
..close();
|
||||
}
|
||||
|
||||
canvas.drawPath(path, Paint()..color = Colors.black54);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OcrScrimPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
class _OcrBoxPainter extends CustomPainter {
|
||||
final List<Offset> points;
|
||||
final bool isSelected;
|
||||
@@ -322,7 +366,7 @@ class _OcrBoxPainter extends CustomPainter {
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
|
||||
..color = isSelected ? colorScheme.primary.withValues(alpha: 0.45) : Colors.transparent
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
|
||||
class OcrToggleButton extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
const OcrToggleButton({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
child: !hasOcr
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 32, bottom: 8),
|
||||
child: Material(
|
||||
color: showingOcr ? context.primaryColor : Colors.black.withValues(alpha: 0.4),
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(10.0),
|
||||
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -36,7 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
|
||||
@@ -48,15 +46,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (hasOcr)
|
||||
IconButton(
|
||||
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
|
||||
onPressed: ref.read(assetViewerProvider.notifier).toggleOcr,
|
||||
color: showingOcr ? context.primaryColor : null,
|
||||
),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
@@ -14,21 +16,68 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class ArchiveBottomSheet extends ConsumerWidget {
|
||||
class ArchiveBottomSheet extends ConsumerStatefulWidget {
|
||||
const ArchiveBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ArchiveBottomSheet> createState() => _ArchiveBottomSheetState();
|
||||
}
|
||||
|
||||
class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
||||
late final DraggableScrollableController sheetController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
sheetController = DraggableScrollableController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
sheetController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
|
||||
Future<void> addToAlbum(RemoteAlbum album) async {
|
||||
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.count == 0
|
||||
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
|
||||
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onKeyboardExpand() {
|
||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||
}
|
||||
|
||||
return BaseBottomSheet(
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.25,
|
||||
maxChildSize: 0.4,
|
||||
maxChildSize: 0.85,
|
||||
shouldCloseOnMinExtent: false,
|
||||
actions: [
|
||||
const ShareActionButton(source: ActionSource.timeline),
|
||||
@@ -48,6 +97,10 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
||||
],
|
||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
slivers: [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class MapBottomSheet extends StatelessWidget {
|
||||
resizeOnScroll: false,
|
||||
actions: [],
|
||||
backgroundColor: context.themeData.colorScheme.surface,
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: false, child: _ScopedMapTimeline())],
|
||||
slivers: [const SliverFillRemaining(hasScrollBody: true, child: _ScopedMapTimeline())],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,13 +159,7 @@ ImageProvider getFullImageProvider(
|
||||
provider = FileImage(File(localFilePath));
|
||||
} else if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
provider = LocalFullImageProvider(
|
||||
id: id,
|
||||
size: size,
|
||||
assetType: asset.type,
|
||||
isAnimated: asset.isAnimatedImage,
|
||||
checksum: _localRenderChecksum(asset),
|
||||
);
|
||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||
} else {
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
@@ -193,7 +187,7 @@ ImageProvider getFullImageProvider(
|
||||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
|
||||
if (_shouldUseLocalAsset(asset)) {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: _localRenderChecksum(asset));
|
||||
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||
}
|
||||
|
||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||
@@ -201,14 +195,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
|
||||
}
|
||||
|
||||
// Cache key for rendering the LOCAL bytes: a remote linked via priorRemoteId carries
|
||||
// the server checksum, which doesn't move when the on-device bytes change again.
|
||||
String? _localRenderChecksum(BaseAsset asset) => asset is RemoteAsset ? asset.localChecksum : asset.checksum;
|
||||
|
||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||
asset.hasLocal &&
|
||||
(!asset.hasRemote || !SettingsRepository.instance.appConfig.image.preferRemote) &&
|
||||
!asset.isEdited &&
|
||||
// A prior-linked local that hasn't rehashed yet has no trustworthy cache key
|
||||
// (its bytes may differ from the server checksum) — render the remote instead.
|
||||
(asset is! RemoteAsset || asset.localChecksum != null);
|
||||
!asset.isEdited;
|
||||
|
||||
@@ -14,11 +14,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
|
||||
// an on-device edit/revert keeps the same id but changes the bytes, so the checksum
|
||||
// is what keys a cached thumbnail to its render.
|
||||
final String? checksum;
|
||||
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution});
|
||||
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
|
||||
|
||||
@override
|
||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -48,13 +44,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
return true;
|
||||
}
|
||||
if (other is LocalThumbProvider) {
|
||||
return id == other.id && checksum == other.checksum;
|
||||
return id == other.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ checksum.hashCode;
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
||||
@@ -63,15 +59,8 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
final Size size;
|
||||
final AssetType assetType;
|
||||
final bool isAnimated;
|
||||
final String? checksum;
|
||||
|
||||
LocalFullImageProvider({
|
||||
required this.id,
|
||||
required this.assetType,
|
||||
required this.size,
|
||||
required this.isAnimated,
|
||||
this.checksum,
|
||||
});
|
||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -84,7 +73,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return AnimatedImageStreamCompleter(
|
||||
stream: _animatedCodec(key, decode),
|
||||
scale: 1.0,
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
@@ -97,7 +86,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
|
||||
return OneFramePlaceholderImageStreamCompleter(
|
||||
_codec(key, decode),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
|
||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<String>('Id', key.id),
|
||||
@@ -174,11 +163,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return true;
|
||||
}
|
||||
if (other is LocalFullImageProvider) {
|
||||
return id == other.id && size == other.size && isAnimated == other.isAnimated && checksum == other.checksum;
|
||||
return id == other.id && size == other.size && isAnimated == other.isAnimated;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode ^ checksum.hashCode;
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -133,8 +132,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
// When the AssetViewer is open, the DriftMap route stays alive in the background.
|
||||
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
|
||||
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
|
||||
final currentRoute = ref.read(currentRouteNameProvider);
|
||||
if (currentRoute == AssetViewerRoute.name) {
|
||||
if (ref.read(isAssetViewerOpenProvider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,6 +181,11 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<bool>(isAssetViewerOpenProvider, (previous, current) {
|
||||
if (previous == true && !current) {
|
||||
_debouncer.run(() => setBounds(forceReload: true));
|
||||
}
|
||||
});
|
||||
return Stack(
|
||||
children: [
|
||||
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
|
||||
|
||||
@@ -30,7 +30,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
|
||||
style: TextStyle(color: Colors.grey[400], fontSize: 13.0, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
df.format(fileCreatedDate),
|
||||
df.format(fileCreatedDate.toLocal()),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15.0, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
@@ -41,7 +41,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
|
||||
minWidth: 0,
|
||||
onPressed: () async {
|
||||
await context.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate));
|
||||
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate.toLocal()));
|
||||
},
|
||||
shape: const CircleBorder(),
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
|
||||
@@ -221,6 +221,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
return;
|
||||
}
|
||||
|
||||
if (_scrubberHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/utils/upload_speed_calculator.dart';
|
||||
@@ -379,19 +380,19 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
}
|
||||
_logger.info("Start background backup sequence");
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
final pending = await _backgroundUploadService.getActiveBackupTaskCount();
|
||||
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
|
||||
return;
|
||||
}
|
||||
_logger.info("Found $pending pending tasks");
|
||||
_logger.info("Found ${tasks.length} pending tasks");
|
||||
|
||||
if (pending == 0) {
|
||||
if (tasks.isEmpty) {
|
||||
_logger.info("No pending tasks, starting new upload");
|
||||
return _backgroundUploadService.uploadBackupCandidates(userId);
|
||||
}
|
||||
|
||||
_logger.info("Resuming upload $pending assets");
|
||||
_logger.info("Resuming upload ${tasks.length} assets");
|
||||
return _backgroundUploadService.resume();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
@@ -154,7 +153,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? description,
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
@@ -12,8 +11,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
@@ -49,15 +46,6 @@ final localSyncServiceProvider = Provider(
|
||||
),
|
||||
);
|
||||
|
||||
final editRevertServiceProvider = Provider(
|
||||
(ref) => EditRevertService(
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
final hashServiceProvider = Provider(
|
||||
(ref) => HashService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
@@ -65,7 +53,5 @@ final hashServiceProvider = Provider(
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
cancellation: ref.watch(cancellationProvider),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
|
||||
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);
|
||||
|
||||
@@ -53,18 +53,9 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
);
|
||||
final List<dynamic> _batchedAssetUploadReady = [];
|
||||
|
||||
// Batches a burst of stack updates (one per uploaded edit) into a single
|
||||
// remote sync. Kept separate from _batchDebouncer so the two don't overwrite
|
||||
// each other's pending action.
|
||||
final Debouncer _stackUpdateDebouncer = Debouncer(
|
||||
interval: const Duration(seconds: 2),
|
||||
maxWaitTime: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_batchDebouncer.dispose();
|
||||
_stackUpdateDebouncer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -114,7 +105,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
|
||||
} catch (e) {
|
||||
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||
}
|
||||
@@ -198,14 +188,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
||||
}
|
||||
|
||||
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
|
||||
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
|
||||
// the stacked primary instead of briefly hiding the asset. Debounced so a
|
||||
// backup of many edits doesn't trigger a sync per event.
|
||||
void _handleAssetStackUpdate(dynamic _) {
|
||||
_stackUpdateDebouncer.run(() => _ref.read(backgroundSyncProvider).runFreshRemoteSync());
|
||||
}
|
||||
|
||||
void _processBatchedAssetUploadReadyV1() {
|
||||
if (_batchedAssetUploadReady.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -75,10 +75,6 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
||||
}
|
||||
|
||||
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
|
||||
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: Optional.present(primaryAssetId)));
|
||||
}
|
||||
|
||||
Future<Response> downloadAsset(String id, {required bool edited}) {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart' hide AlbumUserRole;
|
||||
|
||||
@@ -72,7 +71,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
String albumId,
|
||||
UserDto owner, {
|
||||
String? name,
|
||||
Option<String?> description = const Option.none(),
|
||||
String? description,
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
@@ -87,7 +86,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
albumId,
|
||||
UpdateAlbumDto(
|
||||
albumName: name == null ? const Optional.absent() : Optional.present(name),
|
||||
description: description.toOptional(),
|
||||
description: description == null ? const Optional.absent() : Optional.present(description),
|
||||
albumThumbnailAssetId: thumbnailAssetId == null
|
||||
? const Optional.absent()
|
||||
: Optional.present(thumbnailAssetId),
|
||||
|
||||
@@ -30,11 +30,6 @@ class UploadRepository {
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupEditPairGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kManualUploadGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
@@ -67,11 +62,6 @@ class UploadRepository {
|
||||
return FileDownloader().allTasks(group: group);
|
||||
}
|
||||
|
||||
/// The ENQUEUED or RUNNING task with this id, if any.
|
||||
Future<Task?> getTaskById(String taskId) {
|
||||
return FileDownloader().taskForId(taskId);
|
||||
}
|
||||
|
||||
Future<void> start() {
|
||||
return FileDownloader().start();
|
||||
}
|
||||
|
||||
@@ -24,9 +24,20 @@ class AppNavigationObserver extends AutoRouterObserver {
|
||||
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
||||
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
||||
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
|
||||
if (route.settings.name == AssetViewerRoute.name) {
|
||||
ref.read(isAssetViewerOpenProvider.notifier).state = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
_handleDriftLockedFolderState(previousRoute ?? route, null);
|
||||
if (route.settings.name == AssetViewerRoute.name) {
|
||||
Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false);
|
||||
}
|
||||
}
|
||||
|
||||
_handleDriftLockedFolderState(Route route, Route? previousRoute) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final isFromLockedViewToDetailView =
|
||||
|
||||
@@ -6,27 +6,20 @@ import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/cloud_metadata.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -38,85 +31,41 @@ final backgroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
ref.watch(driftStackProvider),
|
||||
);
|
||||
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
/// Which hop of an iOS edit chain a background task is, so its completion knows
|
||||
/// what to enqueue next. A live-photo edit runs all four hops; a plain photo edit
|
||||
/// is the two-still chain baseStill -> editStill. none = a normal upload.
|
||||
enum LiveEditPhase { none, baseVideo, baseStill, editVideo, editStill }
|
||||
|
||||
/// Metadata for upload tasks to track live photo handling
|
||||
class UploadTaskMetadata {
|
||||
final String localAssetId;
|
||||
|
||||
// Legacy live-photo auto-chain trigger (video upload -> enqueue its still), not
|
||||
// a media-type flag; edit-chain hops keep it false. livePhotoVideoId is no
|
||||
// longer written but stays so persisted task metadata keeps decoding.
|
||||
final bool isLivePhotos;
|
||||
final String livePhotoVideoId;
|
||||
|
||||
// Path of the temp/cache file backing this task, so it can be cleaned up on a
|
||||
// terminal status.
|
||||
final String basePath;
|
||||
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
|
||||
|
||||
// Edit chain state: which hop this is, the base still temp path (carried by the
|
||||
// base video so its completion can enqueue the base still), the base still remote
|
||||
// id threaded to the edit still as its stackParentId, and the local checksum at
|
||||
// task-build time so a re-edit racing this upload can't be marked synced.
|
||||
final LiveEditPhase liveEditPhase;
|
||||
final String baseStillPath;
|
||||
final String pendingStackParentId;
|
||||
final String? checksum;
|
||||
|
||||
// The dead prior a rebuild chain is replacing (prior pointed at a hard-deleted
|
||||
// remote at plan time). Lets the base junctions tell a rebuild in progress
|
||||
// (row prior still == this) from a replayed completion (prior re-stamped).
|
||||
final String stalePriorId;
|
||||
|
||||
const UploadTaskMetadata({
|
||||
required this.localAssetId,
|
||||
this.isLivePhotos = false,
|
||||
this.livePhotoVideoId = '',
|
||||
this.basePath = '',
|
||||
this.liveEditPhase = LiveEditPhase.none,
|
||||
this.baseStillPath = '',
|
||||
this.pendingStackParentId = '',
|
||||
this.checksum,
|
||||
this.stalePriorId = '',
|
||||
});
|
||||
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
|
||||
return UploadTaskMetadata(
|
||||
localAssetId: localAssetId ?? this.localAssetId,
|
||||
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return <String, dynamic>{
|
||||
'localAssetId': localAssetId,
|
||||
'isLivePhotos': isLivePhotos,
|
||||
'livePhotoVideoId': livePhotoVideoId,
|
||||
'basePath': basePath,
|
||||
'liveEditPhase': liveEditPhase.name,
|
||||
'baseStillPath': baseStillPath,
|
||||
'pendingStackParentId': pendingStackParentId,
|
||||
'checksum': checksum,
|
||||
'stalePriorId': stalePriorId,
|
||||
};
|
||||
}
|
||||
|
||||
factory UploadTaskMetadata.fromMap(Map<String, dynamic> map) {
|
||||
return UploadTaskMetadata(
|
||||
localAssetId: map['localAssetId'] as String,
|
||||
isLivePhotos: (map['isLivePhotos'] as bool?) ?? false,
|
||||
livePhotoVideoId: (map['livePhotoVideoId'] as String?) ?? '',
|
||||
basePath: (map['basePath'] as String?) ?? '',
|
||||
liveEditPhase: LiveEditPhase.values.asNameMap()[map['liveEditPhase'] as String?] ?? LiveEditPhase.none,
|
||||
baseStillPath: (map['baseStillPath'] as String?) ?? '',
|
||||
pendingStackParentId: (map['pendingStackParentId'] as String?) ?? '',
|
||||
checksum: map['checksum'] as String?,
|
||||
stalePriorId: (map['stalePriorId'] as String?) ?? '',
|
||||
isLivePhotos: map['isLivePhotos'] as bool,
|
||||
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +76,7 @@ class UploadTaskMetadata {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, basePath: $basePath, liveEditPhase: $liveEditPhase, baseStillPath: $baseStillPath, pendingStackParentId: $pendingStackParentId, checksum: $checksum, stalePriorId: $stalePriorId)';
|
||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant UploadTaskMetadata other) {
|
||||
@@ -137,26 +86,11 @@ class UploadTaskMetadata {
|
||||
|
||||
return other.localAssetId == localAssetId &&
|
||||
other.isLivePhotos == isLivePhotos &&
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.basePath == basePath &&
|
||||
other.liveEditPhase == liveEditPhase &&
|
||||
other.baseStillPath == baseStillPath &&
|
||||
other.pendingStackParentId == pendingStackParentId &&
|
||||
other.checksum == checksum &&
|
||||
other.stalePriorId == stalePriorId;
|
||||
other.livePhotoVideoId == livePhotoVideoId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
localAssetId.hashCode ^
|
||||
isLivePhotos.hashCode ^
|
||||
livePhotoVideoId.hashCode ^
|
||||
basePath.hashCode ^
|
||||
liveEditPhase.hashCode ^
|
||||
baseStillPath.hashCode ^
|
||||
pendingStackParentId.hashCode ^
|
||||
checksum.hashCode ^
|
||||
stalePriorId.hashCode;
|
||||
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
|
||||
}
|
||||
|
||||
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
||||
@@ -170,9 +104,6 @@ class BackgroundUploadService {
|
||||
this._localAssetRepository,
|
||||
this._backupRepository,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._editRevertService,
|
||||
this._stackRepository,
|
||||
) {
|
||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
||||
@@ -183,9 +114,6 @@ class BackgroundUploadService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final EditRevertService _editRevertService;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final Logger _logger = Logger('BackgroundUploadService');
|
||||
|
||||
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
||||
@@ -224,27 +152,12 @@ class BackgroundUploadService {
|
||||
return _uploadRepository.getActiveTasks(group);
|
||||
}
|
||||
|
||||
/// Active tasks across every backup group. The resume gate needs this so a chain
|
||||
/// stalled mid-flight in the live/edit groups (with the normal group already empty)
|
||||
/// resumes instead of kicking off a duplicate cycle.
|
||||
Future<int> getActiveBackupTaskCount() async {
|
||||
final counts = await Future.wait([
|
||||
_uploadRepository.getActiveTasks(kBackupGroup),
|
||||
_uploadRepository.getActiveTasks(kBackupEditPairGroup),
|
||||
_uploadRepository.getActiveTasks(kBackupLivePhotoGroup),
|
||||
]);
|
||||
return counts.fold<int>(0, (sum, tasks) => sum + tasks.length);
|
||||
}
|
||||
|
||||
/// Start background upload using iOS URLSession
|
||||
///
|
||||
/// Finds backup candidates, builds upload tasks, and enqueues them
|
||||
/// for background processing.
|
||||
Future<void> uploadBackupCandidates(String userId) async {
|
||||
await _storageRepository.clearCache();
|
||||
// Safe here: the caller only starts a fresh cycle when no tasks are active in
|
||||
// any backup group, so no pending chain still references these base temps.
|
||||
await _storageRepository.clearEditBaseCache();
|
||||
shouldAbortQueuingTasks = false;
|
||||
|
||||
final candidates = await _backupRepository.getCandidates(userId);
|
||||
@@ -255,17 +168,12 @@ class BackgroundUploadService {
|
||||
|
||||
_logger.info("Found ${candidates.length} backup candidates for background tasks");
|
||||
|
||||
final ownerId = Store.tryGet(StoreKey.currentUser)?.id;
|
||||
const batchSize = 100;
|
||||
final batch = candidates.take(batchSize).toList();
|
||||
List<UploadTask> tasks = [];
|
||||
|
||||
// Walk the full candidate list until a batch is filled: deferred or
|
||||
// unbuildable assets return no task and must not starve what's behind them.
|
||||
for (final asset in candidates) {
|
||||
if (tasks.length >= batchSize || shouldAbortQueuingTasks) {
|
||||
break;
|
||||
}
|
||||
final task = await getUploadTask(asset, ownerId: ownerId);
|
||||
for (final asset in batch) {
|
||||
final task = await getUploadTask(asset);
|
||||
if (task != null) {
|
||||
tasks.add(task);
|
||||
}
|
||||
@@ -284,15 +192,11 @@ class BackgroundUploadService {
|
||||
shouldAbortQueuingTasks = true;
|
||||
|
||||
await _storageRepository.clearCache();
|
||||
await _storageRepository.clearEditBaseCache();
|
||||
await _uploadRepository.reset(kBackupGroup);
|
||||
await _uploadRepository.reset(kBackupEditPairGroup);
|
||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||
await _uploadRepository.deleteDatabaseRecords(kBackupEditPairGroup);
|
||||
|
||||
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
|
||||
final activeEditTasks = await _uploadRepository.getActiveTasks(kBackupEditPairGroup);
|
||||
return activeTasks.length + activeEditTasks.length;
|
||||
return activeTasks.length;
|
||||
}
|
||||
|
||||
/// Resume background backup processing
|
||||
@@ -301,20 +205,9 @@ class BackgroundUploadService {
|
||||
}
|
||||
|
||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
||||
UploadTaskMetadata? metadata;
|
||||
if (update.task.metaData.isNotEmpty) {
|
||||
try {
|
||||
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||
} catch (_) {
|
||||
metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
unawaited(_handleLivePhoto(update, metadata));
|
||||
unawaited(handleLiveEditChain(update, metadata));
|
||||
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
|
||||
unawaited(_handleLivePhoto(update));
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
try {
|
||||
@@ -327,55 +220,33 @@ class BackgroundUploadService {
|
||||
|
||||
break;
|
||||
|
||||
case TaskStatus.failed:
|
||||
case TaskStatus.canceled:
|
||||
case TaskStatus.notFound:
|
||||
unawaited(_cleanupTempResourceOnFailure(metadata));
|
||||
unawaited(_clearDeadPriorOnStack400(update, metadata));
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
|
||||
try {
|
||||
if (metadata == null || !metadata.isLivePhotos) {
|
||||
if (update.task.metaData.isEmpty || update.task.metaData == '') {
|
||||
return;
|
||||
}
|
||||
|
||||
final remoteId = _remoteIdFromResponse(update);
|
||||
if (remoteId == null) {
|
||||
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||
if (!metadata.isLivePhotos) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.responseBody == null || update.responseBody!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final response = jsonDecode(update.responseBody!);
|
||||
|
||||
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||
if (localAsset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Edited since the video task was built (redelivered completion): uploading
|
||||
// the still now would ship the edit standalone and stamp it synced. Drop —
|
||||
// the asset is a candidate and the edit chain handles it. The row itself
|
||||
// can be stale in the same window (local sync hasn't seen the edit yet),
|
||||
// so also confirm with the offline adjustment read; un-edited photos have
|
||||
// no adjustment plist, making this a cheap resources lookup.
|
||||
if (metadata.checksum != null && metadata.checksum != localAsset.checksum) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final state = await _nativeSyncApi
|
||||
.getEditState(localAsset.id, allowNetworkAccess: false)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (state == EditState.edited) {
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// No positive edit evidence; proceed like before.
|
||||
}
|
||||
|
||||
final uploadTask = await getLivePhotoUploadTask(localAsset, remoteId);
|
||||
final uploadTask = await getLivePhotoUploadTask(localAsset, response['id'] as String);
|
||||
|
||||
if (uploadTask == null) {
|
||||
return;
|
||||
@@ -387,477 +258,14 @@ class BackgroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances the edit chain on each hop's completion. Live photo:
|
||||
/// base video → base still → edit video → edit still; plain photo:
|
||||
/// base still → edit still. The base still completion stamps priorRemoteId so
|
||||
/// an app kill mid-chain resumes onto the already-uploaded original (via
|
||||
/// AbsorbIntoPrior) instead of re-uploading it; the base junctions skip once
|
||||
/// the stamp advances past what the chain knew (stalePriorId tells a rebuild
|
||||
/// over a dead prior apart from a replay) and every enqueue is skipped while a
|
||||
/// task with the same id is already active, so a replayed completion can't
|
||||
/// fork a second chain. Completions redelivered on a later launch after the
|
||||
/// chain has finished (syncedChecksum already matches) are dropped outright,
|
||||
/// and the edit hops are dropped when the photo was re-edited or reverted
|
||||
/// while the chain was in flight.
|
||||
@visibleForTesting
|
||||
Future<void> handleLiveEditChain(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
if (metadata == null || metadata.liveEditPhase == LiveEditPhase.none) {
|
||||
return;
|
||||
}
|
||||
final remoteId = _remoteIdFromResponse(update);
|
||||
if (remoteId == null) {
|
||||
return;
|
||||
}
|
||||
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||
if (localAsset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finished-chain replays drop here. Rebuild AND absorb chains can start
|
||||
// while the row still carries the previous terminal stamps (synced ==
|
||||
// checksum) — those hops carry the dead prior as stalePriorId, so the
|
||||
// drop only fires once the prior advanced past it (chain re-stamped and
|
||||
// finished). Plain chains carry no marker and drop as before.
|
||||
final hasFinishedStamps = localAsset.checksum != null && localAsset.syncedChecksum == localAsset.checksum;
|
||||
if (hasFinishedStamps && (metadata.stalePriorId.isEmpty || localAsset.priorRemoteId != metadata.stalePriorId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (metadata.liveEditPhase) {
|
||||
case LiveEditPhase.baseVideo:
|
||||
if (_priorAdvanced(localAsset, metadata) || await _hasActiveTask('${localAsset.id}_bs')) {
|
||||
return;
|
||||
}
|
||||
final next = await _buildBaseStillTask(
|
||||
localAsset,
|
||||
metadata.baseStillPath,
|
||||
baseVideoId: remoteId,
|
||||
stalePriorId: metadata.stalePriorId,
|
||||
);
|
||||
if (!await _enqueueChainTask(next)) {
|
||||
await _deleteTempFile(metadata.baseStillPath);
|
||||
}
|
||||
case LiveEditPhase.baseStill:
|
||||
if (_priorAdvanced(localAsset, metadata)) {
|
||||
return;
|
||||
}
|
||||
await _localAssetRepository.markSynced(metadata.localAssetId, priorRemoteId: remoteId, syncedChecksum: null);
|
||||
if (await _editDriftedMidChain(localAsset, metadata)) {
|
||||
return;
|
||||
}
|
||||
final next = await _buildEditTask(localAsset, stackParentId: remoteId);
|
||||
if (next != null) {
|
||||
await _enqueueChainTask(next);
|
||||
}
|
||||
case LiveEditPhase.editVideo:
|
||||
if (await _hasActiveTask(localAsset.id)) {
|
||||
return;
|
||||
}
|
||||
if (await _editDriftedMidChain(localAsset, metadata)) {
|
||||
return;
|
||||
}
|
||||
final next = await _buildEditStillTask(
|
||||
localAsset,
|
||||
editVideoId: remoteId,
|
||||
stackParentId: metadata.pendingStackParentId,
|
||||
stalePriorId: metadata.stalePriorId,
|
||||
);
|
||||
if (next != null) {
|
||||
await _enqueueChainTask(next);
|
||||
}
|
||||
case LiveEditPhase.editStill:
|
||||
await _localAssetRepository.markSynced(
|
||||
metadata.localAssetId,
|
||||
priorRemoteId: remoteId,
|
||||
syncedChecksum: metadata.checksum ?? localAsset.checksum,
|
||||
);
|
||||
case LiveEditPhase.none:
|
||||
break;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error handling live edit chain: $error $stackTrace");
|
||||
}
|
||||
}
|
||||
|
||||
/// The next hop after the base still: the edit video for a live photo (so the
|
||||
/// edit keeps its motion), the edit still for a plain photo.
|
||||
Future<UploadTask?> _buildEditTask(LocalAsset asset, {required String stackParentId}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
return entity.isLivePhoto
|
||||
? _buildEditVideoTask(asset, stackParentId: stackParentId)
|
||||
: _buildEditStillTask(asset, editVideoId: null, stackParentId: stackParentId);
|
||||
}
|
||||
|
||||
Future<bool> _hasActiveTask(String taskId) async {
|
||||
try {
|
||||
return await _uploadRepository.getTaskById(taskId) != null;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The row's prior moved past what this chain knew: stamped by a normal chain
|
||||
/// (no stale prior) or re-stamped past the dead prior a rebuild was replacing.
|
||||
/// Either way this completion is a replay, not a live junction.
|
||||
bool _priorAdvanced(LocalAsset asset, UploadTaskMetadata metadata) {
|
||||
final prior = asset.priorRemoteId;
|
||||
if (prior == null) {
|
||||
return false;
|
||||
}
|
||||
return metadata.stalePriorId.isEmpty || prior != metadata.stalePriorId;
|
||||
}
|
||||
|
||||
/// The photo changed under a mid-flight chain: re-edited (checksum moved since
|
||||
/// the hop was built) or reverted (positive notEdited probe, cheap offline
|
||||
/// read). The chain drops its edit hops — the asset is still a candidate and
|
||||
/// re-plans fresh next cycle; the uploaded base stays stamped for resume.
|
||||
Future<bool> _editDriftedMidChain(LocalAsset asset, UploadTaskMetadata metadata) async {
|
||||
if (metadata.checksum != null && metadata.checksum != asset.checksum) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
final state = await _nativeSyncApi
|
||||
.getEditState(asset.id, allowNetworkAccess: false)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
return state == EditState.notEdited;
|
||||
} catch (_) {
|
||||
// No positive revert evidence; let the chain finish.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _enqueueChainTask(UploadTask task) async {
|
||||
if (shouldAbortQueuingTasks) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
final results = await enqueueTasks([task]);
|
||||
if (results.every((enqueued) => enqueued)) {
|
||||
return true;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_logger.warning(() => "Failed to enqueue chain task ${task.taskId}", error, stack);
|
||||
return false;
|
||||
}
|
||||
_logger.warning(() => "Failed to enqueue chain task ${task.taskId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _deleteTempFile(String path) async {
|
||||
if (path.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Saves the uploaded remote id as the asset's priorRemoteId so a later edit
|
||||
/// stacks onto it. Edit-chain hops are skipped here; the chain router stamps them.
|
||||
@visibleForTesting
|
||||
Future<void> recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
// Edit stacking is iOS-only; don't stamp prior/synced state on Android.
|
||||
if (!CurrentPlatform.isIOS ||
|
||||
metadata == null ||
|
||||
metadata.isLivePhotos ||
|
||||
metadata.liveEditPhase != LiveEditPhase.none ||
|
||||
metadata.localAssetId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final remoteId = _remoteIdFromResponse(update);
|
||||
if (remoteId == null) {
|
||||
return;
|
||||
}
|
||||
// metadata.checksum is what actually uploaded (captured at build time). A
|
||||
// legacy task without it must NOT fall back to the current row checksum:
|
||||
// the photo may have been edited while the task sat in the queue, and
|
||||
// stamping the new checksum would suppress that edit forever. null keeps
|
||||
// the asset re-resolvable.
|
||||
await _localAssetRepository.markSynced(
|
||||
metadata.localAssetId,
|
||||
priorRemoteId: remoteId,
|
||||
syncedChecksum: metadata.checksum,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) => _cleanupTempResourceOnFailure(metadata);
|
||||
|
||||
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
|
||||
if (metadata == null) {
|
||||
return;
|
||||
}
|
||||
// basePath = the failed hop's own temp; baseStillPath = the base still a live-edit
|
||||
// base video carries forward (leaks otherwise when the chain aborts at hop one).
|
||||
for (final path in [metadata.basePath, metadata.baseStillPath]) {
|
||||
await _deleteTempFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed hop naming a dead stack parent means the stamped prior no longer
|
||||
/// exists server-side; clear the stamps so the next cycle re-resolves fresh
|
||||
/// instead of looping on the same dead id.
|
||||
Future<void> _clearDeadPriorOnStack400(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
if (metadata == null || metadata.localAssetId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final serverMessage = '${update.responseBody ?? ''} ${update.exception?.description ?? ''}';
|
||||
if (!serverMessage.contains(kDeadStackParentError)) {
|
||||
return;
|
||||
}
|
||||
await _localAssetRepository.clearSyncStamps(metadata.localAssetId);
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error clearing dead prior: $error $stackTrace");
|
||||
}
|
||||
}
|
||||
|
||||
/// The new asset's remote id from an upload's response body, or null if the
|
||||
/// body is missing/malformed.
|
||||
String? _remoteIdFromResponse(TaskStatusUpdate update) {
|
||||
final body = update.responseBody;
|
||||
if (body == null || body.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return jsonDecode(body)['id'] as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry of the live-photo edit chain. With an original video, upload it first so
|
||||
/// the base still can link to it; without one (the edit turned Live off) the base
|
||||
/// degrades to a still-only stack parent.
|
||||
Future<UploadTask?> _buildLiveEntryTask(
|
||||
LocalAsset asset,
|
||||
BaseResource still,
|
||||
BaseResource? video, {
|
||||
required String stalePriorId,
|
||||
}) {
|
||||
if (video != null) {
|
||||
return _buildBaseVideoTask(asset, still.path, video.path, stalePriorId: stalePriorId);
|
||||
}
|
||||
return _buildBaseStillTask(asset, still.path, baseVideoId: null, chainEntry: true, stalePriorId: stalePriorId);
|
||||
}
|
||||
|
||||
Future<UploadTask> _buildBaseVideoTask(
|
||||
LocalAsset asset,
|
||||
String baseStillPath,
|
||||
String baseVideoPath, {
|
||||
String stalePriorId = '',
|
||||
}) {
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
liveEditPhase: LiveEditPhase.baseVideo,
|
||||
basePath: baseVideoPath,
|
||||
baseStillPath: baseStillPath,
|
||||
checksum: asset.checksum,
|
||||
stalePriorId: stalePriorId,
|
||||
).toJson();
|
||||
|
||||
return buildUploadTask(
|
||||
File(baseVideoPath),
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(baseVideoPath)),
|
||||
deviceAssetId: '${asset.id}_bv',
|
||||
metadata: metadata,
|
||||
fields: {'visibility': kHiddenVisibility},
|
||||
group: kBackupGroup,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
);
|
||||
}
|
||||
|
||||
/// [chainEntry] = this base still starts the chain (plain-photo edit, or a live
|
||||
/// edit with no recoverable original video), so it queues like any new upload;
|
||||
/// a continuation hop runs at top priority to finish the started chain first.
|
||||
Future<UploadTask> _buildBaseStillTask(
|
||||
LocalAsset asset,
|
||||
String baseStillPath, {
|
||||
required String? baseVideoId,
|
||||
bool chainEntry = false,
|
||||
String stalePriorId = '',
|
||||
}) {
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
liveEditPhase: LiveEditPhase.baseStill,
|
||||
basePath: baseStillPath,
|
||||
checksum: asset.checksum,
|
||||
stalePriorId: stalePriorId,
|
||||
).toJson();
|
||||
|
||||
return buildUploadTask(
|
||||
File(baseStillPath),
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(baseStillPath)),
|
||||
deviceAssetId: '${asset.id}_bs',
|
||||
metadata: metadata,
|
||||
fields: baseVideoId != null ? {'livePhotoVideoId': baseVideoId} : null,
|
||||
group: chainEntry ? kBackupGroup : kBackupEditPairGroup,
|
||||
priority: chainEntry ? null : 0,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
// base = the unedited original, so cloudId but no adjustmentTime
|
||||
cloudId: asset.cloudId,
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<UploadTask?> _buildEditVideoTask(
|
||||
LocalAsset asset, {
|
||||
required String stackParentId,
|
||||
String stalePriorId = '',
|
||||
}) async {
|
||||
final motion = await _storageRepository.getMotionFileForAsset(asset);
|
||||
if (motion == null) {
|
||||
_logger.warning("Failed to get motion file for live edit ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
liveEditPhase: LiveEditPhase.editVideo,
|
||||
basePath: motion.path,
|
||||
pendingStackParentId: stackParentId,
|
||||
checksum: asset.checksum,
|
||||
stalePriorId: stalePriorId,
|
||||
).toJson();
|
||||
|
||||
return buildUploadTask(
|
||||
motion,
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: p.setExtension(originalFileName, p.extension(motion.path)),
|
||||
deviceAssetId: '${asset.id}_ev',
|
||||
metadata: metadata,
|
||||
fields: {'visibility': kHiddenVisibility},
|
||||
group: kBackupGroup,
|
||||
priority: 0,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
);
|
||||
}
|
||||
|
||||
/// The terminal hop: the edited still, linked to its own motion ([editVideoId],
|
||||
/// live photos only) and stacked onto the base still ([stackParentId]).
|
||||
Future<UploadTask?> _buildEditStillTask(
|
||||
LocalAsset asset, {
|
||||
required String? editVideoId,
|
||||
required String stackParentId,
|
||||
String stalePriorId = '',
|
||||
}) async {
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get still file for live edit ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
liveEditPhase: LiveEditPhase.editStill,
|
||||
basePath: file.path,
|
||||
checksum: asset.checksum,
|
||||
stalePriorId: stalePriorId,
|
||||
).toJson();
|
||||
|
||||
return buildUploadTask(
|
||||
file,
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
metadata: metadata,
|
||||
fields: {
|
||||
if (editVideoId != null) 'livePhotoVideoId': editVideoId,
|
||||
if (stackParentId.isNotEmpty) 'stackParentId': stackParentId,
|
||||
},
|
||||
group: kBackupEditPairGroup,
|
||||
priority: 0,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
// edit = WITH adjustmentTime so the server keeps the edit timestamp
|
||||
cloudId: asset.cloudId,
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<UploadTask?> getUploadTask(
|
||||
LocalAsset asset, {
|
||||
String group = kBackupGroup,
|
||||
int? priority,
|
||||
String? ownerId,
|
||||
}) async {
|
||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
_logger.warning("Asset entity not found for ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// iOS edit pair: stack a user edit onto its original. resolveEditPair reads the edit
|
||||
// state and decides whether to reuse a prior upload or upload the original first.
|
||||
if (CurrentPlatform.isIOS) {
|
||||
// A reverted edit flips the stack back to the original and skips the upload.
|
||||
if (asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset) != null) {
|
||||
return null;
|
||||
}
|
||||
final plan = await resolveEditPair(
|
||||
_nativeSyncApi,
|
||||
asset,
|
||||
stackRepository: _stackRepository,
|
||||
ownerId: ownerId ?? Store.tryGet(StoreKey.currentUser)?.id,
|
||||
log: _logger,
|
||||
isLivePhoto: entity.isLivePhoto,
|
||||
);
|
||||
switch (plan) {
|
||||
case UploadBaseFirst(:final base):
|
||||
return _buildBaseStillTask(
|
||||
asset,
|
||||
base.path,
|
||||
baseVideoId: null,
|
||||
chainEntry: true,
|
||||
stalePriorId: asset.priorRemoteId ?? '',
|
||||
);
|
||||
case UploadBaseLivePhotoFirst(:final still, :final video):
|
||||
return _buildLiveEntryTask(asset, still, video, stalePriorId: asset.priorRemoteId ?? '');
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
// Re-editing an already-stacked live photo uploads its new video then still so
|
||||
// the edit keeps its motion; a plain photo just stacks the still. The current
|
||||
// prior rides along as stalePriorId: an absorb can start while the row still
|
||||
// carries finished-chain stamps (prior hard-deleted, base re-found by checksum),
|
||||
// and the junctions must not mistake its hops for finished-chain replays.
|
||||
return entity.isLivePhoto
|
||||
? _buildEditVideoTask(asset, stackParentId: parentId, stalePriorId: asset.priorRemoteId ?? '')
|
||||
: _buildEditStillTask(
|
||||
asset,
|
||||
editVideoId: null,
|
||||
stackParentId: parentId,
|
||||
stalePriorId: asset.priorRemoteId ?? '',
|
||||
);
|
||||
case NoEditPair():
|
||||
break;
|
||||
case DeferEditPair():
|
||||
// Undecidable right now (prior in server trash, or the original
|
||||
// couldn't be read). The asset stays a candidate; retry next cycle.
|
||||
_logger.fine(() => "Deferring upload for ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
File? file;
|
||||
|
||||
/// iOS LivePhoto has two files: a photo and a video.
|
||||
@@ -892,7 +300,7 @@ class BackgroundUploadService {
|
||||
String metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
isLivePhotos: entity.isLivePhoto,
|
||||
checksum: asset.checksum,
|
||||
livePhotoVideoId: '',
|
||||
).toJson();
|
||||
|
||||
final requiresWiFi = _shouldRequireWiFi(asset);
|
||||
@@ -904,9 +312,6 @@ class BackgroundUploadService {
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
metadata: metadata,
|
||||
// for a live photo this is the motion video — upload it hidden so it never
|
||||
// flashes onto the timeline before its still (a later fire) links it.
|
||||
fields: entity.isLivePhoto ? {'visibility': kHiddenVisibility} : null,
|
||||
group: group,
|
||||
priority: priority,
|
||||
isFavorite: asset.isFavorite,
|
||||
@@ -941,9 +346,6 @@ class BackgroundUploadService {
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
// so recordPriorRemoteIdOnSuccess stamps the still's remote id and a later
|
||||
// edit absorbs onto it instead of rebuilding the whole base pair.
|
||||
metadata: UploadTaskMetadata(localAssetId: asset.id, checksum: asset.checksum).toJson(),
|
||||
fields: fields,
|
||||
group: kBackupLivePhotoGroup,
|
||||
priority: 0, // Highest priority to get upload immediately
|
||||
@@ -989,13 +391,6 @@ class BackgroundUploadService {
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
|
||||
final cloudMetadata = cloudMetadataJson(
|
||||
cloudId: cloudId,
|
||||
createdAt: createdAt,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
);
|
||||
final fieldsMap = {
|
||||
'filename': originalFileName ?? filename,
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
@@ -1006,7 +401,19 @@ class BackgroundUploadService {
|
||||
'isFavorite': isFavorite?.toString() ?? 'false',
|
||||
'duration': '0',
|
||||
if (fields != null) ...fields,
|
||||
if (cloudMetadata != null) 'metadata': cloudMetadata,
|
||||
if (CurrentPlatform.isIOS && cloudId != null)
|
||||
'metadata': jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: cloudId,
|
||||
createdAt: createdAt.toIso8601String(),
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
return UploadTask(
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
|
||||
/// The iOS mobile-app metadata multipart field, shared by the foreground and
|
||||
/// background upload paths so the payload only has one definition. null when
|
||||
/// there's nothing to attach. Pass [adjustmentTime] only for an edited render;
|
||||
/// the unedited base carries none.
|
||||
String? cloudMetadataJson({
|
||||
required String? cloudId,
|
||||
required DateTime createdAt,
|
||||
String? adjustmentTime,
|
||||
String? latitude,
|
||||
String? longitude,
|
||||
}) {
|
||||
if (!CurrentPlatform.isIOS || cloudId == null) {
|
||||
return null;
|
||||
}
|
||||
return jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: cloudId,
|
||||
createdAt: createdAt.toIso8601String(),
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// What to do with an edited iOS photo when backing it up.
|
||||
sealed class EditPairPlan {
|
||||
const EditPairPlan();
|
||||
}
|
||||
|
||||
/// Not something we stack: positively not edited, identical bytes, or the
|
||||
/// original resource simply isn't there to recover.
|
||||
class NoEditPair extends EditPairPlan {
|
||||
const NoEditPair();
|
||||
}
|
||||
|
||||
/// Can't be decided right now: the prior upload sits in the server trash, or the
|
||||
/// adjustment metadata / original couldn't be read (offloaded to iCloud, network
|
||||
/// off, stalled read). Skip the asset this cycle — it stays a candidate and
|
||||
/// resolves once conditions change. Uploading anyway would mark the edit synced
|
||||
/// and permanently drop the original from backup.
|
||||
class DeferEditPair extends EditPairPlan {
|
||||
const DeferEditPair();
|
||||
}
|
||||
|
||||
/// Already uploaded before; stack the edit onto that remote id.
|
||||
class AbsorbIntoPrior extends EditPairPlan {
|
||||
final String parentId;
|
||||
const AbsorbIntoPrior(this.parentId);
|
||||
}
|
||||
|
||||
/// Upload the original first; [base] is its temp file.
|
||||
class UploadBaseFirst extends EditPairPlan {
|
||||
final BaseResource base;
|
||||
const UploadBaseFirst(this.base);
|
||||
}
|
||||
|
||||
/// Live photo edit: upload the original pair first (the [still] always, the [video]
|
||||
/// when one survives) and stack the edited live photo onto the original still.
|
||||
/// [video] is null when the original has no paired video to recover (e.g. the edit
|
||||
/// turned Live off), which degrades to a still-only parent.
|
||||
class UploadBaseLivePhotoFirst extends EditPairPlan {
|
||||
final BaseResource still;
|
||||
final BaseResource? video;
|
||||
const UploadBaseLivePhotoFirst(this.still, this.video);
|
||||
}
|
||||
|
||||
/// Works out how an edited photo should stack: reuse a prior upload, upload the
|
||||
/// original first, do nothing, or defer to a later cycle. Shared by the foreground
|
||||
/// and background upload paths. The caller already checked it's iOS; pass
|
||||
/// [isLivePhoto] for a live photo so the original pair (still + paired video) is
|
||||
/// read instead of a single still.
|
||||
///
|
||||
/// A photo that was never edited only carries the capture-time Photographic Style,
|
||||
/// which iOS stamps at [LocalAsset.createdAt]; a real edit moves [LocalAsset.adjustmentTime]
|
||||
/// later. When they match (or there's no adjustment at all) there's nothing to stack, so
|
||||
/// we skip the native read. Anything that moved the timestamp (edit, retime, revert) falls
|
||||
/// through to [NativeSyncApi.getBaseResource] / [NativeSyncApi.getBaseLivePhoto], which read
|
||||
/// the adjustment plist and decide.
|
||||
Future<EditPairPlan> resolveEditPair(
|
||||
NativeSyncApi nativeSyncApi,
|
||||
LocalAsset asset, {
|
||||
required DriftStackRepository stackRepository,
|
||||
required String? ownerId,
|
||||
Logger? log,
|
||||
bool isLivePhoto = false,
|
||||
}) async {
|
||||
final priorRemoteId = asset.priorRemoteId;
|
||||
if (priorRemoteId != null) {
|
||||
PriorState priorState;
|
||||
try {
|
||||
priorState = await stackRepository.priorState(priorRemoteId);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to check prior remote $priorRemoteId for ${asset.id}", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
switch (priorState) {
|
||||
case PriorState.live:
|
||||
return AbsorbIntoPrior(priorRemoteId);
|
||||
case PriorState.trashed:
|
||||
// The prior sits in the server trash. Re-uploading the base would just
|
||||
// dedupe onto the trashed row and the edit would 400 stacking onto it
|
||||
// ("Cannot stack onto a trashed or missing asset"), so wait: restore
|
||||
// makes it live (absorb), emptying trash makes it missing (rebuild).
|
||||
return const DeferEditPair();
|
||||
case PriorState.missing:
|
||||
// No synced row for the stamp. With syncedChecksum unset a chain is
|
||||
// mid-flight and the row just hasn't synced back yet — resume onto it.
|
||||
// With syncedChecksum set the completed prior has since vanished from
|
||||
// the server (hard delete), so fall through and re-resolve from scratch.
|
||||
if (asset.syncedChecksum == null) {
|
||||
return AbsorbIntoPrior(priorRemoteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!_mightBeEdited(asset)) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
if (isLivePhoto) {
|
||||
return _resolveLivePair(nativeSyncApi, asset, stackRepository: stackRepository, ownerId: ownerId, log: log);
|
||||
}
|
||||
|
||||
BaseResource? base;
|
||||
try {
|
||||
// Native bounds each resource read (classify + still) at 120s idle; the outer
|
||||
// timeout only catches a reply that never comes back across the platform channel.
|
||||
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true).timeout(_baseReadTimeout);
|
||||
} catch (error, stack) {
|
||||
// Transient (timeout, unreadable plist, iCloud hiccup): defer instead of
|
||||
// uploading the edit standalone, which would permanently skip the original.
|
||||
log?.warning(() => "Failed to read base resource for ${asset.id}, deferring", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
if (base == null) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
// Identical bytes (e.g. auto-HDR), nothing real to stack. Drop the temp copy.
|
||||
if (base.sha1 == asset.checksum) {
|
||||
await _deleteTemp(base.path);
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
switch (await _planForExistingBase(stackRepository, base.sha1, ownerId, log: log)) {
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
await _deleteTemp(base.path);
|
||||
return AbsorbIntoPrior(parentId);
|
||||
case DeferEditPair():
|
||||
await _deleteTemp(base.path);
|
||||
return const DeferEditPair();
|
||||
default:
|
||||
return UploadBaseFirst(base);
|
||||
}
|
||||
}
|
||||
|
||||
/// The base bytes may already be on the server: backed up before the stamps
|
||||
/// existed, by another install, or after the stamps were belt-cleared. Absorb
|
||||
/// straight onto a live copy instead of re-uploading bytes the server has;
|
||||
/// defer while that copy sits in the trash — uploading would just dedupe onto
|
||||
/// the trashed row and the stack would 400. null = no copy, upload the base.
|
||||
Future<EditPairPlan?> _planForExistingBase(
|
||||
DriftStackRepository stackRepository,
|
||||
String baseSha1,
|
||||
String? ownerId, {
|
||||
Logger? log,
|
||||
}) async {
|
||||
if (ownerId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final dup = await stackRepository.remoteByChecksum(baseSha1, ownerId);
|
||||
return switch (dup.state) {
|
||||
PriorState.live => AbsorbIntoPrior(dup.remoteId!),
|
||||
PriorState.trashed => const DeferEditPair(),
|
||||
PriorState.missing => null,
|
||||
};
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to check base checksum against synced remotes", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the original pair of an edited live photo. Skips stacking when the original
|
||||
/// still matches the current bytes (e.g. a video-only trim) — the base still would
|
||||
/// dedupe to the edit itself on the server, so it can't be its own stack parent; the
|
||||
/// edit just uploads normally. Temps are dropped on every non-stack outcome.
|
||||
Future<EditPairPlan> _resolveLivePair(
|
||||
NativeSyncApi nativeSyncApi,
|
||||
LocalAsset asset, {
|
||||
required DriftStackRepository stackRepository,
|
||||
required String? ownerId,
|
||||
Logger? log,
|
||||
}) async {
|
||||
BaseLivePhoto? live;
|
||||
try {
|
||||
// Up to three native reads here (classify + still + paired video), 120s idle each.
|
||||
live = await nativeSyncApi.getBaseLivePhoto(asset.id, allowNetworkAccess: true).timeout(_baseLiveReadTimeout);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to read base live photo for ${asset.id}, deferring", error, stack);
|
||||
return const DeferEditPair();
|
||||
}
|
||||
if (live == null) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
if (live.still.sha1 == asset.checksum) {
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
switch (await _planForExistingBase(stackRepository, live.still.sha1, ownerId, log: log)) {
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return AbsorbIntoPrior(parentId);
|
||||
case DeferEditPair():
|
||||
await _deleteTemp(live.still.path);
|
||||
await _deleteTemp(live.video?.path);
|
||||
return const DeferEditPair();
|
||||
default:
|
||||
return UploadBaseLivePhotoFirst(live.still, live.video);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteTemp(String? path) async {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// iOS stamps the capture-time Photographic Style at the creation time and moves the
|
||||
/// adjustment timestamp on any later change. A gap past a small tolerance (capture jitter
|
||||
/// is sub-second, real edits are seconds apart) is worth a native check; no adjustment at
|
||||
/// all means the photo was never touched.
|
||||
bool _mightBeEdited(LocalAsset asset) {
|
||||
final adjustedAt = asset.adjustmentTime;
|
||||
if (adjustedAt == null) {
|
||||
return false;
|
||||
}
|
||||
return adjustedAt.difference(asset.createdAt).inSeconds.abs() > _editTimestampToleranceSeconds;
|
||||
}
|
||||
|
||||
const _editTimestampToleranceSeconds = 2;
|
||||
// Generous on purpose: the native idle watchdog (120s without a chunk) owns
|
||||
// stall detection, so these only catch a reply lost on the platform channel —
|
||||
// a tight bound here would kill big-but-flowing iCloud downloads.
|
||||
const _baseReadTimeout = Duration(minutes: 30);
|
||||
const _baseLiveReadTimeout = Duration(minutes: 45);
|
||||
@@ -1,31 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/cloud_metadata.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
@@ -47,10 +39,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
ref.watch(driftStackProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,10 +54,6 @@ class ForegroundUploadService {
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._localAssetRepository,
|
||||
this._editRevertService,
|
||||
this._stackRepository,
|
||||
);
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
@@ -77,10 +61,6 @@ class ForegroundUploadService {
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final EditRevertService _editRevertService;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
bool shouldAbortUpload = false;
|
||||
@@ -162,7 +142,7 @@ class ForegroundUploadService {
|
||||
await _executeWithWorkerPool<LocalAsset>(
|
||||
items: localAssets,
|
||||
cancelToken: cancelToken,
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks, surfaceDefers: true),
|
||||
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,7 +236,6 @@ class ForegroundUploadService {
|
||||
LocalAsset asset,
|
||||
Completer<void>? cancelToken, {
|
||||
required UploadCallbacks callbacks,
|
||||
bool surfaceDefers = false,
|
||||
}) async {
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
@@ -271,64 +250,6 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
// A reverted iOS edit flips the stack back to the original and skips the upload.
|
||||
// Works for live photos too — getEditState reads the adjustment plist, which is
|
||||
// media-agnostic. Report the flipped-to base, not the pre-flip prior (the edit
|
||||
// being reverted away) — album-add consumers link whatever id this reports.
|
||||
if (CurrentPlatform.isIOS && asset.priorRemoteId != null) {
|
||||
final revertedTo = await _editRevertService.tryHandleRevert(asset);
|
||||
if (revertedTo != null) {
|
||||
callbacks.onSuccess?.call(asset.localId!, revertedTo);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
final fields = {
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': (asset.durationMs ?? 0).toString(),
|
||||
};
|
||||
|
||||
// Edit pair: upload the unedited original first and stack the edit onto it. For a
|
||||
// live photo that's the original still+video pair; this upload stays the edit and
|
||||
// its own edited motion uploads after, below. Resolved before anything is
|
||||
// materialized so a deferred or failed pair doesn't burn an iCloud download or a
|
||||
// motion upload every retry cycle, and before the edit's metadata is added so the
|
||||
// base isn't stamped with the edit's adjustmentTime.
|
||||
final base = await _resolveStackParent(asset, Map.of(fields), cancelToken, isLivePhoto: entity.isLivePhoto);
|
||||
if (base.deferred) {
|
||||
// Undecidable right now (prior in server trash, or the original couldn't be
|
||||
// read). The asset stays a candidate; auto backup retries silently, a manual
|
||||
// upload tells the user why nothing happened.
|
||||
_logger.fine(() => "Deferring upload for ${asset.localId}: edit pair undecidable this cycle");
|
||||
if (surfaceDefers) {
|
||||
callbacks.onError?.call(asset.localId!, "upload_deferred_edit_pair".t());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (base.baseFailed) {
|
||||
// The original couldn't be uploaded. Don't upload the edit on its own and mark
|
||||
// it synced — that would permanently drop the original from backup. Leave the
|
||||
// whole pair as a candidate to retry next cycle.
|
||||
_logger.warning(() => "Base upload failed for ${asset.localId}, retrying the pair later");
|
||||
if (base.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
}
|
||||
if (base.errorMessage != null) {
|
||||
callbacks.onError?.call(asset.localId!, base.errorMessage!);
|
||||
if (base.errorMessage == _kQuotaError) {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||
@@ -396,13 +317,19 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
if (base.stackParentId != null) {
|
||||
fields['stackParentId'] = base.stackParentId!;
|
||||
}
|
||||
final fields = {
|
||||
// deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818).
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': (asset.durationMs ?? 0).toString(),
|
||||
};
|
||||
|
||||
// The edit's own motion video, uploaded hidden so it never flashes onto the
|
||||
// timeline before the still below links it.
|
||||
// Upload live photo video first if available
|
||||
String? livePhotoVideoId;
|
||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||
@@ -411,7 +338,7 @@ class ForegroundUploadService {
|
||||
final livePhotoResult = await _uploadRepository.uploadFile(
|
||||
file: livePhotoFile,
|
||||
originalFileName: livePhotoTitle,
|
||||
fields: {...fields, 'visibility': kHiddenVisibility}..remove('stackParentId'),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress != null
|
||||
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
|
||||
@@ -421,13 +348,6 @@ class ForegroundUploadService {
|
||||
|
||||
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
|
||||
livePhotoVideoId = livePhotoResult.remoteAssetId;
|
||||
} else if (livePhotoResult.isCancelled) {
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
} else if (livePhotoResult.errorMessage == _kQuotaError) {
|
||||
callbacks.onError?.call(asset.localId!, livePhotoResult.errorMessage!);
|
||||
shouldAbortUpload = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,9 +356,19 @@ class ForegroundUploadService {
|
||||
}
|
||||
|
||||
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: true);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
if (CurrentPlatform.isIOS && asset.cloudId != null) {
|
||||
fields['metadata'] = jsonEncode([
|
||||
RemoteAssetMetadataItem(
|
||||
key: RemoteAssetMetadataKey.mobileApp,
|
||||
value: RemoteAssetMobileAppMetadata(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt.toIso8601String(),
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
@@ -454,18 +384,6 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
// Edit stacking is iOS-only; leave the columns untouched on Android so the
|
||||
// candidate guard and merged-timeline hide clause never engage there.
|
||||
if (CurrentPlatform.isIOS) {
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.markSynced(asset.localId!, priorRemoteId: result.remoteAssetId!, syncedChecksum: asset.checksum)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to mark ${asset.localId} synced", error, stack),
|
||||
),
|
||||
);
|
||||
}
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
@@ -478,21 +396,9 @@ class ForegroundUploadService {
|
||||
|
||||
callbacks.onError?.call(asset.localId!, result.errorMessage!);
|
||||
|
||||
if (result.errorMessage == _kQuotaError) {
|
||||
if (result.errorMessage == "Quota has been exceeded!") {
|
||||
shouldAbortUpload = true;
|
||||
}
|
||||
if (result.errorMessage!.contains(kDeadStackParentError)) {
|
||||
// The stamped prior no longer exists server-side; drop the stamps so
|
||||
// the next cycle re-resolves fresh instead of looping on the dead id.
|
||||
unawaited(
|
||||
_localAssetRepository
|
||||
.clearSyncStamps(asset.localId!)
|
||||
.catchError(
|
||||
(Object error, StackTrace stack) =>
|
||||
_logger.warning(() => "Failed to clear stamps for ${asset.localId}", error, stack),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
|
||||
@@ -509,149 +415,6 @@ class ForegroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS still-image cloudId metadata as a JSON field, or null when there's
|
||||
/// nothing to attach. The base resource omits adjustmentTime (it's the
|
||||
/// unedited original); the edit includes it.
|
||||
String? _cloudMetadata(LocalAsset asset, {required bool includeAdjustment}) {
|
||||
return cloudMetadataJson(
|
||||
cloudId: asset.cloudId,
|
||||
createdAt: asset.createdAt,
|
||||
adjustmentTime: includeAdjustment ? asset.adjustmentTime?.toIso8601String() : null,
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Persists the uploaded base as the asset's prior so an interrupted run resumes
|
||||
/// by stacking onto it (AbsorbIntoPrior) instead of re-reading and re-uploading
|
||||
/// the original. syncedChecksum stays null: the edit itself is still pending.
|
||||
Future<void> _stampBaseUpload(LocalAsset asset, String baseRemoteId) async {
|
||||
try {
|
||||
await _localAssetRepository.markSynced(asset.localId!, priorRemoteId: baseRemoteId, syncedChecksum: null);
|
||||
} catch (error, stack) {
|
||||
_logger.warning(() => "Failed to stamp base upload for ${asset.localId}", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// For an edited iOS photo, uploads the original camera bytes so the edit can
|
||||
/// stack onto it. See [_StackParent] for the outcome.
|
||||
Future<_StackParent> _resolveStackParent(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
Completer<void>? cancelToken, {
|
||||
bool isLivePhoto = false,
|
||||
}) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return const _StackParent.none();
|
||||
}
|
||||
|
||||
final plan = await resolveEditPair(
|
||||
_nativeSyncApi,
|
||||
asset,
|
||||
stackRepository: _stackRepository,
|
||||
ownerId: Store.tryGet(StoreKey.currentUser)?.id,
|
||||
log: _logger,
|
||||
isLivePhoto: isLivePhoto,
|
||||
);
|
||||
switch (plan) {
|
||||
case NoEditPair():
|
||||
return const _StackParent.none();
|
||||
case DeferEditPair():
|
||||
return const _StackParent.deferred();
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
return _StackParent.parent(parentId);
|
||||
case UploadBaseLivePhotoFirst(:final still, :final video):
|
||||
return _uploadBaseLivePair(asset, baseFields, still, video, cancelToken);
|
||||
case UploadBaseFirst(:final base):
|
||||
final baseFile = File(base.path);
|
||||
try {
|
||||
final fields = Map.of(baseFields);
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: false);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: baseFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseResource[${asset.localId}]',
|
||||
);
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
await _stampBaseUpload(asset, result.remoteAssetId!);
|
||||
return _StackParent.parent(result.remoteAssetId!);
|
||||
}
|
||||
return _StackParent.failed(errorMessage: result.errorMessage, isCancelled: result.isCancelled);
|
||||
} finally {
|
||||
try {
|
||||
await baseFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the original live pair (paired video then still) so the edited live photo
|
||||
/// can stack onto the original still. Returns the still's remote id as the parent, or
|
||||
/// [_StackParent.failed] if either hop fails so the edit isn't left unstacked.
|
||||
Future<_StackParent> _uploadBaseLivePair(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
BaseResource still,
|
||||
BaseResource? video,
|
||||
Completer<void>? cancelToken,
|
||||
) async {
|
||||
final stillFile = File(still.path);
|
||||
final videoFile = video != null ? File(video.path) : null;
|
||||
try {
|
||||
final fields = Map.of(baseFields);
|
||||
|
||||
String? baseVideoId;
|
||||
if (videoFile != null) {
|
||||
final videoResult = await _uploadRepository.uploadFile(
|
||||
file: videoFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(videoFile.path)),
|
||||
// hidden so the original motion never flashes onto the timeline (copy: the
|
||||
// base still upload below reuses `fields`).
|
||||
fields: {...fields, 'visibility': kHiddenVisibility},
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseLiveVideo[${asset.localId}]',
|
||||
);
|
||||
if (!(videoResult.isSuccess && videoResult.remoteAssetId != null)) {
|
||||
return _StackParent.failed(errorMessage: videoResult.errorMessage, isCancelled: videoResult.isCancelled);
|
||||
}
|
||||
baseVideoId = videoResult.remoteAssetId;
|
||||
}
|
||||
|
||||
if (baseVideoId != null) {
|
||||
fields['livePhotoVideoId'] = baseVideoId;
|
||||
}
|
||||
final metadata = _cloudMetadata(asset, includeAdjustment: false);
|
||||
if (metadata != null) {
|
||||
fields['metadata'] = metadata;
|
||||
}
|
||||
|
||||
final stillResult = await _uploadRepository.uploadFile(
|
||||
file: stillFile,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(still.path)),
|
||||
fields: fields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseLiveStill[${asset.localId}]',
|
||||
);
|
||||
if (stillResult.isSuccess && stillResult.remoteAssetId != null) {
|
||||
await _stampBaseUpload(asset, stillResult.remoteAssetId!);
|
||||
return _StackParent.parent(stillResult.remoteAssetId!);
|
||||
}
|
||||
return _StackParent.failed(errorMessage: stillResult.errorMessage, isCancelled: stillResult.isCancelled);
|
||||
} finally {
|
||||
try {
|
||||
await stillFile.delete();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await videoFile?.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadResult> _uploadSingleFile(
|
||||
File file, {
|
||||
required String deviceAssetId,
|
||||
@@ -698,42 +461,3 @@ class ForegroundUploadService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Server's quota-rejection message (asset-media.service.ts requireQuota).
|
||||
const String _kQuotaError = "Quota has been exceeded!";
|
||||
|
||||
/// Outcome of resolving an edit's stack parent. [stackParentId] is the remote id
|
||||
/// to stack onto (null when the asset isn't an edit). [baseFailed] is true only
|
||||
/// when the original was found but its upload failed, so the edit must not be
|
||||
/// uploaded on its own; [deferred] means skip the asset this cycle (it stays a
|
||||
/// candidate); [errorMessage]/[isCancelled] carry why a failure happened so the
|
||||
/// caller can surface it and react to quota/cancel like the main upload does.
|
||||
class _StackParent {
|
||||
final String? stackParentId;
|
||||
final bool baseFailed;
|
||||
final bool deferred;
|
||||
final String? errorMessage;
|
||||
final bool isCancelled;
|
||||
|
||||
const _StackParent.none()
|
||||
: stackParentId = null,
|
||||
baseFailed = false,
|
||||
deferred = false,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
const _StackParent.parent(String this.stackParentId)
|
||||
: baseFailed = false,
|
||||
deferred = false,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
const _StackParent.failed({this.errorMessage, this.isCancelled = false})
|
||||
: stackParentId = null,
|
||||
baseFailed = true,
|
||||
deferred = false;
|
||||
const _StackParent.deferred()
|
||||
: stackParentId = null,
|
||||
baseFailed = false,
|
||||
deferred = true,
|
||||
errorMessage = null,
|
||||
isCancelled = false;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class _ProfileIndicator extends ConsumerWidget {
|
||||
color: serverInfoState.versionStatus == VersionStatus.error
|
||||
? context.colorScheme.error
|
||||
: context.primaryColor,
|
||||
size: widgetSize / 2,
|
||||
size: widgetSize / 2 - 3,
|
||||
semanticLabel: 'new_version_available'.tr(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/search/people.provider.dart';
|
||||
@@ -44,16 +45,19 @@ class PeoplePicker extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: people.widgetWhen(
|
||||
onData: (people) {
|
||||
final filtered = people
|
||||
.where(
|
||||
(person) => person.name.toLowerCase().removeDiacritics().contains(
|
||||
searchQuery.value.toLowerCase().removeDiacritics(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: people
|
||||
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.length,
|
||||
itemCount: filtered.length,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemBuilder: (context, index) {
|
||||
final person = people
|
||||
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
|
||||
.toList()[index];
|
||||
final person = filtered[index];
|
||||
final isSelected = selectedPeople.value.contains(person);
|
||||
|
||||
return Padding(
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
|
||||
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
|
||||
|
||||
@@ -20,6 +21,7 @@ class GroupSettings extends HookConsumerWidget {
|
||||
Future<void> updateAppSettings(GroupAssetsBy groupBy) async {
|
||||
await ref.read(settingsProvider).write(.timelineGroupAssetsBy, groupBy);
|
||||
ref.invalidate(appSettingsServiceProvider);
|
||||
ref.invalidate(timelineServiceProvider);
|
||||
}
|
||||
|
||||
void changeGroupValue(GroupAssetsBy? value) {
|
||||
@@ -46,10 +48,6 @@ class GroupSettings extends HookConsumerWidget {
|
||||
title: 'month'.t(context: context),
|
||||
value: GroupAssetsBy.month,
|
||||
),
|
||||
SettingsRadioGroup(
|
||||
title: 'asset_list_layout_settings_group_automatically'.t(context: context),
|
||||
value: GroupAssetsBy.auto,
|
||||
),
|
||||
],
|
||||
groupBy: groupBy.value,
|
||||
onRadioChanged: changeGroupValue,
|
||||
|
||||
@@ -102,8 +102,6 @@ run = "flutter run"
|
||||
[tasks."i18n:loader"]
|
||||
description = "Generate i18n loader"
|
||||
hide = true
|
||||
sources = ["i18n/"]
|
||||
outputs = "lib/generated/codegen_loader.g.dart"
|
||||
run = [
|
||||
"dart run easy_localization:generate -S ../i18n",
|
||||
"dart format lib/generated/codegen_loader.g.dart",
|
||||
@@ -112,8 +110,6 @@ run = [
|
||||
[tasks."i18n:keys"]
|
||||
description = "Generate i18n keys"
|
||||
hide = true
|
||||
sources = ["i18n/en.json"]
|
||||
outputs = "lib/generated/translations.g.dart"
|
||||
run = [
|
||||
"dart run bin/generate_keys.dart",
|
||||
"dart format lib/generated/translations.g.dart",
|
||||
|
||||
Generated
+14
@@ -148,6 +148,20 @@ Class | Method | HTTP request | Description
|
||||
*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
|
||||
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
|
||||
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
|
||||
*DeprecatedApi* | [**updateApiKey**](doc//DeprecatedApi.md#updateapikey) | **PUT** /api-keys/{id} | Update an API key
|
||||
*DeprecatedApi* | [**updateAsset**](doc//DeprecatedApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
|
||||
*DeprecatedApi* | [**updateAssets**](doc//DeprecatedApi.md#updateassets) | **PUT** /assets | Update assets
|
||||
*DeprecatedApi* | [**updateLibrary**](doc//DeprecatedApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
|
||||
*DeprecatedApi* | [**updateMemory**](doc//DeprecatedApi.md#updatememory) | **PUT** /memories/{id} | Update a memory
|
||||
*DeprecatedApi* | [**updateMyPreferences**](doc//DeprecatedApi.md#updatemypreferences) | **PUT** /users/me/preferences | Update my preferences
|
||||
*DeprecatedApi* | [**updateMyUser**](doc//DeprecatedApi.md#updatemyuser) | **PUT** /users/me | Update current user
|
||||
*DeprecatedApi* | [**updatePerson**](doc//DeprecatedApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||
*DeprecatedApi* | [**updateSession**](doc//DeprecatedApi.md#updatesession) | **PUT** /sessions/{id} | Update a session
|
||||
*DeprecatedApi* | [**updateStack**](doc//DeprecatedApi.md#updatestack) | **PUT** /stacks/{id} | Update a stack
|
||||
*DeprecatedApi* | [**updateTag**](doc//DeprecatedApi.md#updatetag) | **PUT** /tags/{id} | Update a tag
|
||||
*DeprecatedApi* | [**updateUserAdmin**](doc//DeprecatedApi.md#updateuseradmin) | **PUT** /admin/users/{id} | Update a user
|
||||
*DeprecatedApi* | [**updateUserPreferencesAdmin**](doc//DeprecatedApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | Update user preferences
|
||||
*DeprecatedApi* | [**updateWorkflow**](doc//DeprecatedApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
|
||||
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
|
||||
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
|
||||
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
|
||||
|
||||
Generated
+3
-13
@@ -1586,11 +1586,8 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets';
|
||||
|
||||
@@ -1654,10 +1651,6 @@ class AssetsApi {
|
||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||
mp.files.add(sidecarData);
|
||||
}
|
||||
if (stackParentId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
|
||||
}
|
||||
if (visibility != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'visibility'] = parameterToString(visibility);
|
||||
@@ -1718,12 +1711,9 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, abortTrigger: abortTrigger,);
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
+845
@@ -184,4 +184,849 @@ class DeprecatedApi {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update an API key
|
||||
///
|
||||
/// Updates the name and permissions of an API key by its ID. The current user must own this API key.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [ApiKeyUpdateDto] apiKeyUpdateDto (required):
|
||||
Future<Response> updateApiKeyWithHttpInfo(String id, ApiKeyUpdateDto apiKeyUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/api-keys/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = apiKeyUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update an API key
|
||||
///
|
||||
/// Updates the name and permissions of an API key by its ID. The current user must own this API key.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [ApiKeyUpdateDto] apiKeyUpdateDto (required):
|
||||
Future<ApiKeyResponseDto?> updateApiKey(String id, ApiKeyUpdateDto apiKeyUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateApiKeyWithHttpInfo(id, apiKeyUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ApiKeyResponseDto',) as ApiKeyResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update an asset
|
||||
///
|
||||
/// Update information of a specific asset.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UpdateAssetDto] updateAssetDto (required):
|
||||
Future<Response> updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = updateAssetDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update an asset
|
||||
///
|
||||
/// Update information of a specific asset.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UpdateAssetDto] updateAssetDto (required):
|
||||
Future<AssetResponseDto?> updateAsset(String id, UpdateAssetDto updateAssetDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateAssetWithHttpInfo(id, updateAssetDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetResponseDto',) as AssetResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update assets
|
||||
///
|
||||
/// Updates multiple assets at the same time.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
|
||||
Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetBulkUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update assets
|
||||
///
|
||||
/// Updates multiple assets at the same time.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
|
||||
Future<void> updateAssets(AssetBulkUpdateDto assetBulkUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateAssetsWithHttpInfo(assetBulkUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a library
|
||||
///
|
||||
/// Update an existing external library.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UpdateLibraryDto] updateLibraryDto (required):
|
||||
Future<Response> updateLibraryWithHttpInfo(String id, UpdateLibraryDto updateLibraryDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/libraries/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = updateLibraryDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a library
|
||||
///
|
||||
/// Update an existing external library.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UpdateLibraryDto] updateLibraryDto (required):
|
||||
Future<LibraryResponseDto?> updateLibrary(String id, UpdateLibraryDto updateLibraryDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateLibraryWithHttpInfo(id, updateLibraryDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LibraryResponseDto',) as LibraryResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a memory
|
||||
///
|
||||
/// Update an existing memory by its ID.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [MemoryUpdateDto] memoryUpdateDto (required):
|
||||
Future<Response> updateMemoryWithHttpInfo(String id, MemoryUpdateDto memoryUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = memoryUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a memory
|
||||
///
|
||||
/// Update an existing memory by its ID.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [MemoryUpdateDto] memoryUpdateDto (required):
|
||||
Future<MemoryResponseDto?> updateMemory(String id, MemoryUpdateDto memoryUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateMemoryWithHttpInfo(id, memoryUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemoryResponseDto',) as MemoryResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update my preferences
|
||||
///
|
||||
/// Update the preferences of the current user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
|
||||
Future<Response> updateMyPreferencesWithHttpInfo(UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/users/me/preferences';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = userPreferencesUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update my preferences
|
||||
///
|
||||
/// Update the preferences of the current user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
|
||||
Future<UserPreferencesResponseDto?> updateMyPreferences(UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateMyPreferencesWithHttpInfo(userPreferencesUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserPreferencesResponseDto',) as UserPreferencesResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update current user
|
||||
///
|
||||
/// Update the current user making the API request.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UserUpdateMeDto] userUpdateMeDto (required):
|
||||
Future<Response> updateMyUserWithHttpInfo(UserUpdateMeDto userUpdateMeDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/users/me';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = userUpdateMeDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update current user
|
||||
///
|
||||
/// Update the current user making the API request.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UserUpdateMeDto] userUpdateMeDto (required):
|
||||
Future<UserAdminResponseDto?> updateMyUser(UserUpdateMeDto userUpdateMeDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateMyUserWithHttpInfo(userUpdateMeDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update person
|
||||
///
|
||||
/// Update an individual person.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [PersonUpdateDto] personUpdateDto (required):
|
||||
Future<Response> updatePersonWithHttpInfo(String id, PersonUpdateDto personUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/people/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = personUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update person
|
||||
///
|
||||
/// Update an individual person.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [PersonUpdateDto] personUpdateDto (required):
|
||||
Future<PersonResponseDto?> updatePerson(String id, PersonUpdateDto personUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updatePersonWithHttpInfo(id, personUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a session
|
||||
///
|
||||
/// Update a specific session identified by id.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [SessionUpdateDto] sessionUpdateDto (required):
|
||||
Future<Response> updateSessionWithHttpInfo(String id, SessionUpdateDto sessionUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/sessions/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = sessionUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a session
|
||||
///
|
||||
/// Update a specific session identified by id.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [SessionUpdateDto] sessionUpdateDto (required):
|
||||
Future<SessionResponseDto?> updateSession(String id, SessionUpdateDto sessionUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateSessionWithHttpInfo(id, sessionUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SessionResponseDto',) as SessionResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a stack
|
||||
///
|
||||
/// Update an existing stack by its ID.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [StackUpdateDto] stackUpdateDto (required):
|
||||
Future<Response> updateStackWithHttpInfo(String id, StackUpdateDto stackUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/stacks/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = stackUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a stack
|
||||
///
|
||||
/// Update an existing stack by its ID.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [StackUpdateDto] stackUpdateDto (required):
|
||||
Future<StackResponseDto?> updateStack(String id, StackUpdateDto stackUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateStackWithHttpInfo(id, stackUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a tag
|
||||
///
|
||||
/// Update an existing tag identified by its ID.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [TagUpdateDto] tagUpdateDto (required):
|
||||
Future<Response> updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/tags/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = tagUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a tag
|
||||
///
|
||||
/// Update an existing tag identified by its ID.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [TagUpdateDto] tagUpdateDto (required):
|
||||
Future<TagResponseDto?> updateTag(String id, TagUpdateDto tagUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateTagWithHttpInfo(id, tagUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TagResponseDto',) as TagResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a user
|
||||
///
|
||||
/// Update an existing user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UserAdminUpdateDto] userAdminUpdateDto (required):
|
||||
Future<Response> updateUserAdminWithHttpInfo(String id, UserAdminUpdateDto userAdminUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/users/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = userAdminUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a user
|
||||
///
|
||||
/// Update an existing user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UserAdminUpdateDto] userAdminUpdateDto (required):
|
||||
Future<UserAdminResponseDto?> updateUserAdmin(String id, UserAdminUpdateDto userAdminUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateUserAdminWithHttpInfo(id, userAdminUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update user preferences
|
||||
///
|
||||
/// Update the preferences of a specific user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
|
||||
Future<Response> updateUserPreferencesAdminWithHttpInfo(String id, UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/users/{id}/preferences'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = userPreferencesUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update user preferences
|
||||
///
|
||||
/// Update the preferences of a specific user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
|
||||
Future<UserPreferencesResponseDto?> updateUserPreferencesAdmin(String id, UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateUserPreferencesAdminWithHttpInfo(id, userPreferencesUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserPreferencesResponseDto',) as UserPreferencesResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a workflow
|
||||
///
|
||||
/// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [WorkflowUpdateDto] workflowUpdateDto (required):
|
||||
Future<Response> updateWorkflowWithHttpInfo(String id, WorkflowUpdateDto workflowUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/workflows/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = workflowUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update a workflow
|
||||
///
|
||||
/// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [WorkflowUpdateDto] workflowUpdateDto (required):
|
||||
Future<WorkflowResponseDto?> updateWorkflow(String id, WorkflowUpdateDto workflowUpdateDto, { Future<void>? abortTrigger, }) async {
|
||||
final response = await updateWorkflowWithHttpInfo(id, workflowUpdateDto, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -95,9 +95,9 @@ class AssetBulkUpdateDto {
|
||||
///
|
||||
Optional<num?> longitude;
|
||||
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
Optional<int?> rating;
|
||||
|
||||
|
||||
+2
-2
@@ -77,9 +77,9 @@ class UpdateAssetDto {
|
||||
///
|
||||
Optional<num?> longitude;
|
||||
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
Optional<int?> rating;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user