Compare commits

..

16 Commits

Author SHA1 Message Date
Santo Shakil 32ac4f694c feat(mobile): stack original + edited photo on ios
Detect an iOS edit, upload the unedited original, and stack the edited
version on top of it. Reverting in Photos flips the stack cover back to
the original and keeps the edits. Adds an optional stackParentId field to
the asset upload on the server.
2026-06-02 16:22:38 +06:00
renovate[bot] da8ed3eceb chore(deps): update docker.io/valkey/valkey:9 docker digest to 4963247 (#28622) 2026-06-02 08:09:27 +00:00
renovate[bot] 2afde23a5d chore(deps): update github-actions (#28750)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 00:39:19 -04:00
renovate[bot] d57a152040 chore(deps): update prom/prometheus docker digest to 69f5241 (#28757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 00:37:42 -04:00
renovate[bot] 728e92ea33 chore(deps): update dependency @immich/ui to v0.79.3 (#28758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 00:37:10 -04:00
Mert 138e2d9158 feat(web): hls player (#28312)
* update e2e

* hls player

* fix transcoding restart on explicit quality selection

* move level filtering to manager

* move init to manager declaration

* refactor commit on release

* these lints...

* fix seek sometimes being ignored

* fix panic downswitch
2026-06-01 15:49:57 -04:00
Mert 7eabac6702 feat(server): hls with real-time transcoding (#28230)
* hls implementation

* fix stale state after ffmpeg exit
2026-06-01 18:52:29 +00:00
renovate[bot] cf4789e008 chore(deps): update github-actions (major) (#28752) 2026-06-01 18:35:36 +00:00
renovate[bot] 412884fce3 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.5.18 (#28749) 2026-06-01 19:47:53 +02:00
Jason Rasmussen 16aee2b869 fix: album name (#28751) 2026-06-01 19:45:24 +02:00
Daniel Dietzler 3f7af51531 fix: version check (#28746) 2026-06-01 13:41:08 -04:00
Brandon Wees 4eb100327e fix: disallow cross origin/non http protocols for continueUrl on login (#28706)
* fix: disallow cross origin/non http protocols for continueUrl on login

* chore: use Route helper

* fix: also use Route.continue in pin code prompt

* fix: typecheck
2026-06-01 13:38:26 -04:00
bo0tzz 69b1946484 feat: handle prereleases in publish workflows (#28701) 2026-06-01 17:11:45 +02:00
bo0tzz 61cd69a286 fix: strip rc suffix from iOS marketing version (#28741) 2026-06-01 09:56:43 -05:00
Daniel Dietzler c8a1d0e400 feat: release candidate support (#28665)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-01 16:10:07 +02:00
Paul Makles d120444a87 fix(devcontainer): update build cache volume (#28736) 2026-06-01 12:41:53 +00:00
160 changed files with 20925 additions and 674 deletions
@@ -15,7 +15,7 @@ services:
volumes:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- build_cache:/buildcache
- ../packages/plugin-core:/build/plugins/immich-plugin-core
immich-web:
env_file: !reset []
+3 -3
View File
@@ -51,7 +51,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -79,7 +79,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -201,7 +201,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
actions: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@6147a58e5d1249a12f42fc864ab791d571a30015 # v0.0.47
uses: oasdiff/oasdiff-action/breaking@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # v0.0.48
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
+11 -9
View File
@@ -31,7 +31,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -49,7 +49,9 @@ jobs:
- name: Publish
if: ${{ github.event_name == 'release' }}
run: mise run ci-publish
env:
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
run: mise run ci-publish -- --tag "$NPM_TAG"
docker:
name: Docker
@@ -61,7 +63,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -73,13 +75,13 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
@@ -94,7 +96,7 @@ jobs:
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
flavor: |
latest=false
@@ -102,10 +104,10 @@ jobs:
name=ghcr.io/${{ github.repository_owner }}/immich-cli
tags: |
type=raw,value=${{ steps.package-version.outputs.version }},enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
- name: Build and push image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
file: packages/cli/Dockerfile
platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:0a8b8867773a0f8368061f47578603f438349f8f1f28b0e16105f481e5c794e0
image: ghcr.io/immich-app/mdq:main@sha256:e73f60195b39748c4876f23e3e6cd22a68a9754acec8aef1fd6979fd52cd2c9f
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+4 -4
View File
@@ -44,7 +44,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: '/language:${{matrix.language}}'
+7 -7
View File
@@ -23,7 +23,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "pokedex-large"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@db54dcf16fbb12c43479a23749ceea0ad1b4a704 # multi-runner-build-workflow-v3.0.0
permissions:
contents: read
actions: read
@@ -147,7 +147,7 @@ jobs:
platforms: ${{ matrix.platforms }}
runner-mapping: ${{ matrix.runner-mapping }}
suffixes: ${{ matrix.suffixes }}
dockerhub-push: ${{ github.event_name == 'release' }}
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
build-args: |
DEVICE=${{ matrix.device }}
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@5813c7c4f7016c748ae7ac5d5f684846649d4d20 # multi-runner-build-workflow-v2.4.0
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@db54dcf16fbb12c43479a23749ceea0ad1b4a704 # multi-runner-build-workflow-v3.0.0
permissions:
contents: read
actions: read
@@ -167,7 +167,7 @@ jobs:
image: immich-server
context: .
dockerfile: server/Dockerfile
dockerhub-push: ${{ github.event_name == 'release' }}
dockerhub-push: ${{ github.event_name == 'release' && !github.event.release.prerelease }}
build-args: |
DEVICE=cpu
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -54,7 +54,7 @@ jobs:
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+12 -4
View File
@@ -20,7 +20,7 @@ jobs:
artifact: ${{ steps.get-artifact.outputs.result }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -98,9 +98,16 @@ jobs:
shouldDeploy: true
};
} else if (eventType == "release") {
const tag = context.payload.workflow_run.head_branch;
const { data: release } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
parameters = {
event: "release",
name: context.payload.workflow_run.head_branch,
name: tag,
prerelease: release.prerelease,
shouldDeploy: !isFork
};
}
@@ -119,7 +126,7 @@ jobs:
if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -146,6 +153,7 @@ jobs:
const parameters = JSON.parse(process.env.PARAM_JSON);
core.setOutput("event", parameters.event);
core.setOutput("name", parameters.name);
core.setOutput("prerelease", parameters.prerelease);
core.setOutput("shouldDeploy", parameters.shouldDeploy);
- name: Download artifact
@@ -203,7 +211,7 @@ jobs:
run: mise run //docs:deploy
- name: Deploy Docs Release Domain
if: ${{ steps.parameters.outputs.event == 'release' }}
if: ${{ steps.parameters.outputs.event == 'release' && steps.parameters.outputs.prerelease != 'true' }}
env:
TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+2 -2
View File
@@ -49,7 +49,7 @@ jobs:
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -137,7 +137,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
draft: true
tag_name: ${{ needs.bump_version.outputs.version }}
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -32,7 +32,7 @@ jobs:
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+4 -2
View File
@@ -16,7 +16,7 @@ jobs:
packages: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -39,4 +39,6 @@ jobs:
run: pnpm --filter @immich/sdk build
- name: Publish
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks
env:
NPM_TAG: ${{ github.event.release.prerelease && 'rc' || 'latest' }}
run: pnpm --filter @immich/sdk publish --provenance --no-git-checks --tag "$NPM_TAG"
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -49,7 +49,7 @@ jobs:
working-directory: ./mobile
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+19 -19
View File
@@ -17,7 +17,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -71,7 +71,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -102,7 +102,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -133,7 +133,7 @@ jobs:
working-directory: ./packages/cli
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -177,7 +177,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -215,7 +215,7 @@ jobs:
working-directory: ./web
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -243,7 +243,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -293,7 +293,7 @@ jobs:
working-directory: ./e2e
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -325,7 +325,7 @@ jobs:
working-directory: ./server
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -361,7 +361,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -374,7 +374,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -438,7 +438,7 @@ jobs:
runner: [ubuntu-latest, ubuntu-24.04-arm]
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -451,7 +451,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -546,7 +546,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -583,7 +583,7 @@ jobs:
working-directory: ./machine-learning
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -613,7 +613,7 @@ jobs:
working-directory: ./.github
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -643,7 +643,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -664,7 +664,7 @@ jobs:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -722,7 +722,7 @@ jobs:
- 5432:5432
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
@@ -47,7 +47,7 @@ jobs:
if: ${{ fromJSON(needs.pre-job.outputs.should_run).i18n == true }}
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
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 }}
+1 -1
View File
@@ -154,7 +154,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
+2 -2
View File
@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
image: prom/prometheus@sha256:69f5241418838263316593f7274a304b095c40bcf22e57272865da91bd60a8ac
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1 -1
View File
@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
user: '1000:1000'
security_opt:
- no-new-privileges:true
+1 -1
View File
@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
restart: always
+1 -1
View File
@@ -44,7 +44,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
healthcheck:
test: redis-cli ping || exit 1
@@ -95,6 +95,7 @@ describe('/server', () => {
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
prerelease: null,
});
});
});
@@ -115,6 +116,7 @@ describe('/server', () => {
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
realtimeTranscoding: false,
search: true,
sidecar: true,
trash: true,
@@ -21,18 +21,18 @@ describe('/system-config', () => {
const response1 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: false } });
.send({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
expect(response1.status).toBe(200);
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false } });
expect(response1.body).toEqual({ ...config, newVersionCheck: { enabled: false, channel: 'stable' } });
const response2 = await request(app)
.put('/system-config')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...config, newVersionCheck: { enabled: true } });
.send({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
expect(response2.status).toBe(200);
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true } });
expect(response2.body).toEqual({ ...config, newVersionCheck: { enabled: true, channel: 'stable' } });
});
it('should reject an invalid config entry', async () => {
+9
View File
@@ -305,6 +305,8 @@
"refreshing_all_libraries": "Refreshing all libraries",
"registration": "Admin Registration",
"registration_description": "Since you are the first user on the system, you will be assigned as the Admin and are responsible for administrative tasks, and additional users will be created by you.",
"release_channel_release_candidate": "Release candidate",
"release_channel_stable": "Stable",
"remove_failed_jobs": "Remove failed jobs",
"require_password_change_on_login": "Require user to change password on first login",
"reset_settings_to_default": "Reset settings to default",
@@ -399,6 +401,10 @@
"transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.",
"transcoding_preset_preset": "Preset (-preset)",
"transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above 'faster'.",
"transcoding_realtime": "Real-time Transcoding [EXPERIMENTAL]",
"transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.",
"transcoding_realtime_enabled": "Enable real-time transcoding",
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
"transcoding_reference_frames": "Reference frames",
"transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
"transcoding_required_description": "Only videos not in an accepted format",
@@ -442,6 +448,8 @@
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page",
"version_check_channel": "Release channel",
"version_check_channel_description": "Pick the release channel you want to get version announcements for",
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with {server}",
"version_check_settings": "Version Check",
@@ -2452,6 +2460,7 @@
"video": "Video",
"video_hover_setting": "Play video thumbnail on hover",
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
"video_quality": "Video quality",
"videos": "Videos",
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
"videos_only": "Videos only",
@@ -207,6 +207,18 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
}
}
enum class EditState(val raw: Int) {
NOT_EDITED(0),
EDITED(1),
UNKNOWN(2);
companion object {
fun ofRaw(raw: Int): EditState? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlatformAsset (
val id: String,
@@ -472,6 +484,52 @@ data class CloudIdResult (
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class BaseResource (
val path: String,
val sha1: String,
val sizeBytes: Long,
val mimeType: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
val path = pigeonVar_list[0] as String
val sha1 = pigeonVar_list[1] as String
val sizeBytes = pigeonVar_list[2] as Long
val mimeType = pigeonVar_list[3] as String
return BaseResource(path, sha1, sizeBytes, mimeType)
}
}
fun toList(): List<Any?> {
return listOf(
path,
sha1,
sizeBytes,
mimeType,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as BaseResource
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1) && MessagesPigeonUtils.deepEquals(this.sizeBytes, other.sizeBytes) && MessagesPigeonUtils.deepEquals(this.mimeType, other.mimeType)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sizeBytes)
result = 31 * result + MessagesPigeonUtils.deepHash(this.mimeType)
return result
}
}
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -481,30 +539,40 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it)
return (readValue(buffer) as Long?)?.let {
EditState.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it)
PlatformAsset.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
PlatformAlbum.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
SyncDelta.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BaseResource.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -514,26 +582,34 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
is PlatformAsset -> {
is EditState -> {
stream.write(130)
writeValue(stream, value.toList())
writeValue(stream, value.raw.toLong())
}
is PlatformAlbum -> {
is PlatformAsset -> {
stream.write(131)
writeValue(stream, value.toList())
}
is SyncDelta -> {
is PlatformAlbum -> {
stream.write(132)
writeValue(stream, value.toList())
}
is HashResult -> {
is SyncDelta -> {
stream.write(133)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
is HashResult -> {
stream.write(134)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(135)
writeValue(stream, value.toList())
}
is BaseResource -> {
stream.write(136)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -555,6 +631,8 @@ 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)
companion object {
/** The codec used by NativeSyncApi. */
@@ -786,6 +864,48 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -476,4 +476,14 @@ 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) {
callback(Result.success(null))
}
// iOS-only; Android assets never carry a Photos-style edit.
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
callback(Result.success(EditState.NOT_EDITED))
}
}
File diff suppressed because it is too large Load Diff
+118 -10
View File
@@ -183,6 +183,12 @@ enum PlatformAssetPlaybackStyle: Int {
case videoLooping = 5
}
enum EditState: Int {
case notEdited = 0
case edited = 1
case unknown = 2
}
/// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable {
var id: String
@@ -458,6 +464,52 @@ struct CloudIdResult: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct BaseResource: Hashable {
var path: String
var sha1: String
var sizeBytes: Int64
var mimeType: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
let path = pigeonVar_list[0] as! String
let sha1 = pigeonVar_list[1] as! String
let sizeBytes = pigeonVar_list[2] as! Int64
let mimeType = pigeonVar_list[3] as! String
return BaseResource(
path: path,
sha1: sha1,
sizeBytes: sizeBytes,
mimeType: mimeType
)
}
func toList() -> [Any?] {
return [
path,
sha1,
sizeBytes,
mimeType,
]
}
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
if Swift.type(of: lhs) != Swift.type(of: rhs) {
return false
}
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1) && deepEqualsMessages(lhs.sizeBytes, rhs.sizeBytes) && deepEqualsMessages(lhs.mimeType, rhs.mimeType)
}
func hash(into hasher: inout Hasher) {
hasher.combine("BaseResource")
deepHashMessages(value: path, hasher: &hasher)
deepHashMessages(value: sha1, hasher: &hasher)
deepHashMessages(value: sizeBytes, hasher: &hasher)
deepHashMessages(value: mimeType, hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -468,15 +520,23 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
}
return nil
case 130:
return PlatformAsset.fromList(self.readValue() as! [Any?])
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return EditState(rawValue: enumResultAsInt)
}
return nil
case 131:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
return PlatformAsset.fromList(self.readValue() as! [Any?])
case 132:
return SyncDelta.fromList(self.readValue() as! [Any?])
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 133:
return HashResult.fromList(self.readValue() as! [Any?])
return SyncDelta.fromList(self.readValue() as! [Any?])
case 134:
return HashResult.fromList(self.readValue() as! [Any?])
case 135:
return CloudIdResult.fromList(self.readValue() as! [Any?])
case 136:
return BaseResource.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -488,21 +548,27 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
if let value = value as? PlatformAssetPlaybackStyle {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
} else if let value = value as? EditState {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
} else if let value = value as? PlatformAlbum {
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
} else if let value = value as? SyncDelta {
super.writeByte(133)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
} else if let value = value as? HashResult {
super.writeByte(134)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(135)
super.writeValue(value.toList())
} else if let value = value as? BaseResource {
super.writeByte(136)
super.writeValue(value.toList())
} else {
super.writeValue(value)
}
@@ -539,6 +605,8 @@ 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)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -757,5 +825,45 @@ class NativeSyncApiSetup {
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
let getBaseResourceChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getBaseResourceChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getBaseResourceChannel.setMessageHandler(nil)
}
let getEditStateChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getEditStateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getEditStateChannel.setMessageHandler(nil)
}
}
}
+166
View File
@@ -1,5 +1,6 @@
import Photos
import CryptoKit
import UniformTypeIdentifiers
struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset
@@ -419,4 +420,169 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return mappings;
}
func getBaseResource(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<BaseResource?, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
let resources = PHAssetResource.assetResources(for: asset)
let state = await Self.classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
guard state == .edited, let original = resources.first(where: { $0.type == .photo }) else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
do {
let result = try await self.streamBaseResource(
resource: original,
localId: asset.localIdentifier,
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(result))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
// Returns whether the asset carries a live Photos edit without reading the photo
// itself, only the small adjustment metadata. The revert probe relies on this to
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
// mistakes an unreadable edit for a revert.
func getEditState(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<EditState, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
// Not in the library, 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
// 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
}
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"
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("immich_base", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let unique = UUID().uuidString.prefix(8)
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
// 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 {
throw NSError(
domain: "NativeSyncApi",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to open temp file for base resource \(localId)"]
)
}
var hasher = Insecure.SHA1()
var totalBytes: Int64 = 0
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
var writeFailed = false
PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { chunk in
if writeFailed { return }
do {
try handle.write(contentsOf: chunk)
hasher.update(data: chunk)
totalBytes += Int64(chunk.count)
} catch {
writeFailed = true
}
},
completionHandler: { error in continuation.resume(returning: error == nil && !writeFailed) }
)
}
try? handle.close()
guard succeeded else {
try? FileManager.default.removeItem(at: tempUrl)
throw NSError(
domain: "NativeSyncApi",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to read base resource for \(localId)"]
)
}
let sha1 = Data(hasher.finalize()).base64EncodedString()
let mime = UTType(resource.uniformTypeIdentifier)?.preferredMIMEType ?? "application/octet-stream"
return BaseResource(path: tempUrl.path, sha1: sha1, sizeBytes: totalBytes, mimeType: mime)
}
private static func collectResourceData(
_ resource: PHAssetResource,
allowNetworkAccess: Bool
) async -> Data? {
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
var buffer = Data()
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { data in buffer.append(data) },
completionHandler: { error in continuation.resume(returning: error == nil ? buffer : nil) }
)
}
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ def get_version_from_pubspec
pubspec = YAML.load_file(pubspec_path)
version_string = pubspec['version']
version_string ? version_string.split('+').first : nil
version_string ? version_string.split('+').first.split('-').first : nil
end
# Helper method to configure code signing for all targets
+1
View File
@@ -20,6 +20,7 @@ const String kSecuredPinCode = "secured_pin_code";
const String kManualUploadGroup = 'manual_upload_group';
const String kBackupGroup = 'backup_group';
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
const String kBackupEditPairGroup = 'backup_edit_pair_group';
const String kDownloadGroupImage = 'group_image';
const String kDownloadGroupVideo = 'group_video';
const String kDownloadGroupLivePhoto = 'group_livephoto';
@@ -12,6 +12,13 @@ class LocalAsset extends BaseAsset {
final double? latitude;
final double? longitude;
// Remote id of this asset's previous upload; used to stack a new edit under it.
final String? priorRemoteId;
// Local checksum at the last sync action; lets backup skip an already-handled
// local whose current render hashes fresh (the iOS revert case).
final String? syncedChecksum;
const LocalAsset({
required this.id,
String? remoteId,
@@ -32,6 +39,8 @@ class LocalAsset extends BaseAsset {
this.latitude,
this.longitude,
required super.isEdited,
this.priorRemoteId,
this.syncedChecksum,
}) : remoteAssetId = remoteId;
@override
@@ -120,6 +129,8 @@ class LocalAsset extends BaseAsset {
double? latitude,
double? longitude,
bool? isEdited,
String? priorRemoteId,
String? syncedChecksum,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -140,6 +151,8 @@ class LocalAsset extends BaseAsset {
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
);
}
}
@@ -0,0 +1,84 @@
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 true if the asset was a revert and was handled (caller skips the
/// upload); false to fall through to the normal upload path.
Future<bool> tryHandleRevert(LocalAsset asset) async {
if (asset.priorRemoteId == null) {
return false;
}
// 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);
if (editState != EditState.notEdited) {
return false;
}
} catch (error, stack) {
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
return false;
}
// 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 false;
}
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
if (base == null) {
return false;
}
stackId = foundStack;
baseId = base;
} catch (error, stack) {
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
return false;
}
try {
await _assetApiRepository.setStackPrimary(stackId, baseId);
await _stackRepository.setPrimary(stackId, baseId);
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum);
} catch (error, stack) {
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
return false;
}
return true;
}
}
+60 -6
View File
@@ -5,8 +5,10 @@ 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";
@@ -17,6 +19,8 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftStackRepository _stackRepository;
final AssetApiRepository _assetApiRepository;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -25,6 +29,8 @@ class HashService {
required this._localAssetRepository,
required this._trashedLocalAssetRepository,
required this._nativeSyncApi,
required this._stackRepository,
required this._assetApiRepository,
this._cancelChecker,
int? batchSize,
}) : _batchSize = batchSize ?? kBatchHashFileLimit;
@@ -40,6 +46,7 @@ class HashService {
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();
final hashedIds = <String>{};
for (final album in localAlbums) {
if (isCancelled) {
@@ -49,7 +56,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash);
await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
}
}
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
@@ -57,9 +64,18 @@ class HashService {
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) {
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
}
}
// 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).
if (CurrentPlatform.isIOS && hashedIds.isNotEmpty && !isCancelled) {
await _reconcileReverts(hashedIds);
}
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
@@ -76,7 +92,12 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
Future<void> _hashAssets(
LocalAlbum album,
List<LocalAsset> assetsToHash, {
bool isTrashed = false,
required Set<String> hashedIds,
}) async {
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
@@ -87,16 +108,21 @@ class HashService {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash, isTrashed);
await _processBatch(album, toHash, isTrashed, hashedIds);
toHash.clear();
}
}
await _processBatch(album, toHash, isTrashed);
await _processBatch(album, toHash, isTrashed, hashedIds);
}
/// Processes a batch of assets.
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
Future<void> _processBatch(
LocalAlbum album,
Map<String, LocalAsset> toHash,
bool isTrashed,
Set<String> hashedIds,
) async {
if (toHash.isEmpty) {
return;
}
@@ -136,5 +162,33 @@ class HashService {
} else {
await _localAssetRepository.updateHashes(hashed);
}
hashedIds.addAll(hashed.keys);
}
Future<void> _reconcileReverts(Set<String> localIds) async {
final List<StackReconcileTarget> targets;
try {
targets = await _stackRepository.findRevertReconcileTargets(localIds);
} catch (error, stack) {
_log.warning("findRevertReconcileTargets failed", error, stack);
return;
}
for (final target in targets) {
try {
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
// Roll priorRemoteId forward to the 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);
continue;
}
}
}
}
@@ -81,6 +81,10 @@ class BackgroundSyncManager {
} on CanceledError {
// Ignore cancellation errors
}
// Stop the local-sync and hash slots too. The revert reconcile runs in the hash
// task and shouldn't outlive the session.
await cancelLocal();
}
Future<void> cancelLocal() async {
@@ -186,6 +190,22 @@ class BackgroundSyncManager {
});
}
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
/// joins an in-flight sync whose snapshot can pre-date a just-received change
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
/// first, then run a fresh one.
Future<void> runFreshRemoteSync() async {
final inflight = _syncTask;
if (inflight != null) {
try {
await inflight.future;
} catch (_) {
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
}
}
await syncRemote();
}
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
@@ -6,6 +6,7 @@ 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_prior_remote_id ON local_asset_entity (prior_remote_id)')
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
const LocalAssetEntity();
@@ -27,6 +28,14 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
// remote id of the previous upload (iOS edit-pair stacking)
TextColumn get priorRemoteId => text().nullable()();
// local checksum at the last sync action. Lets the backup query skip a local
// whose current hash matches nothing remote but is still "handled": the iOS
// revert case, where the reverted render hashes fresh but is already reconciled.
TextColumn get syncedChecksum => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -51,5 +60,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
longitude: longitude,
cloudId: iCloudId,
isEdited: false,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
);
}
@@ -26,6 +26,8 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -45,6 +47,8 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
class $$LocalAssetEntityTableFilterComposer
@@ -141,6 +145,16 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$LocalAssetEntityTableOrderingComposer
@@ -231,6 +245,16 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$LocalAssetEntityTableAnnotationComposer
@@ -300,6 +324,16 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.playbackStyle,
builder: (column) => column,
);
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => column,
);
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => column,
);
}
class $$LocalAssetEntityTableTableManager
@@ -359,6 +393,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -376,6 +412,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
createCompanionCallback:
({
@@ -396,6 +434,8 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -413,6 +453,8 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -637,6 +679,28 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
).withConverter<i2.AssetPlaybackStyle>(
i1.$LocalAssetEntityTable.$converterplaybackStyle,
);
static const i0.VerificationMeta _priorRemoteIdMeta =
const i0.VerificationMeta('priorRemoteId');
@override
late final i0.GeneratedColumn<String> priorRemoteId =
i0.GeneratedColumn<String>(
'prior_remote_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _syncedChecksumMeta =
const i0.VerificationMeta('syncedChecksum');
@override
late final i0.GeneratedColumn<String> syncedChecksum =
i0.GeneratedColumn<String>(
'synced_checksum',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -655,6 +719,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -759,6 +825,24 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
);
}
if (data.containsKey('prior_remote_id')) {
context.handle(
_priorRemoteIdMeta,
priorRemoteId.isAcceptableOrUnknown(
data['prior_remote_id']!,
_priorRemoteIdMeta,
),
);
}
if (data.containsKey('synced_checksum')) {
context.handle(
_syncedChecksumMeta,
syncedChecksum.isAcceptableOrUnknown(
data['synced_checksum']!,
_syncedChecksumMeta,
),
);
}
return context;
}
@@ -839,6 +923,14 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
data['${effectivePrefix}playback_style'],
)!,
),
priorRemoteId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}prior_remote_id'],
),
syncedChecksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}synced_checksum'],
),
);
}
@@ -877,6 +969,8 @@ class LocalAssetEntityData extends i0.DataClass
final double? latitude;
final double? longitude;
final i2.AssetPlaybackStyle playbackStyle;
final String? priorRemoteId;
final String? syncedChecksum;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -894,6 +988,8 @@ class LocalAssetEntityData extends i0.DataClass
this.latitude,
this.longitude,
required this.playbackStyle,
this.priorRemoteId,
this.syncedChecksum,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -938,6 +1034,12 @@ class LocalAssetEntityData extends i0.DataClass
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
);
}
if (!nullToAbsent || priorRemoteId != null) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
}
if (!nullToAbsent || syncedChecksum != null) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
}
return map;
}
@@ -967,6 +1069,8 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
serializer.fromJson<int>(json['playbackStyle']),
),
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
);
}
@override
@@ -993,6 +1097,8 @@ class LocalAssetEntityData extends i0.DataClass
'playbackStyle': serializer.toJson<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
),
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
};
}
@@ -1013,6 +1119,8 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i2.AssetPlaybackStyle? playbackStyle,
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -1032,6 +1140,12 @@ class LocalAssetEntityData extends i0.DataClass
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId.present
? priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: syncedChecksum.present
? syncedChecksum.value
: this.syncedChecksum,
);
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData(
@@ -1061,6 +1175,12 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
priorRemoteId: data.priorRemoteId.present
? data.priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: data.syncedChecksum.present
? data.syncedChecksum.value
: this.syncedChecksum,
);
}
@@ -1082,7 +1202,9 @@ class LocalAssetEntityData extends i0.DataClass
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')'))
.toString();
}
@@ -1105,6 +1227,8 @@ class LocalAssetEntityData extends i0.DataClass
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
);
@override
bool operator ==(Object other) =>
@@ -1125,7 +1249,9 @@ class LocalAssetEntityData extends i0.DataClass
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude &&
other.playbackStyle == this.playbackStyle);
other.playbackStyle == this.playbackStyle &&
other.priorRemoteId == this.priorRemoteId &&
other.syncedChecksum == this.syncedChecksum);
}
class LocalAssetEntityCompanion
@@ -1146,6 +1272,8 @@ class LocalAssetEntityCompanion
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
final i0.Value<String?> priorRemoteId;
final i0.Value<String?> syncedChecksum;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1163,6 +1291,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
});
LocalAssetEntityCompanion.insert({
required String name,
@@ -1181,6 +1311,8 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id);
@@ -1201,6 +1333,8 @@ class LocalAssetEntityCompanion
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
i0.Expression<int>? playbackStyle,
i0.Expression<String>? priorRemoteId,
i0.Expression<String>? syncedChecksum,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1219,6 +1353,8 @@ class LocalAssetEntityCompanion
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (playbackStyle != null) 'playback_style': playbackStyle,
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
});
}
@@ -1239,6 +1375,8 @@ class LocalAssetEntityCompanion
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
i0.Value<String?>? priorRemoteId,
i0.Value<String?>? syncedChecksum,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1257,6 +1395,8 @@ class LocalAssetEntityCompanion
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
);
}
@@ -1317,6 +1457,12 @@ class LocalAssetEntityCompanion
),
);
}
if (priorRemoteId.present) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
}
if (syncedChecksum.present) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
}
return map;
}
@@ -1338,7 +1484,9 @@ class LocalAssetEntityCompanion
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write(')'))
.toString();
}
@@ -1348,3 +1496,7 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
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,7 +7,13 @@ import 'local_album_asset.entity.dart';
mergedAsset:
SELECT
rae.id as remote_id,
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_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,
rae.name,
rae."type",
rae.created_at as created_at,
@@ -83,6 +89,13 @@ AND NOT EXISTS (
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
-- iOS edit-in-progress / revert: if this local was already uploaded (its
-- prior_remote_id resolves to a live remote), hide the local tile so the remote
-- (the edit, or the flipped-back original) is the single source of truth. Kills
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids AND rae.deleted_at IS NULL
)
ORDER BY created_at DESC
LIMIT $limit;
@@ -136,6 +149,10 @@ FROM
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
-- iOS edit-in-progress / revert: hide a local already represented by a live remote.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids AND rae.deleted_at IS NULL
)
)
GROUP BY bucket_date
ORDER BY bucket_date DESC;
+2 -2
View File
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, 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, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds) AND rae.deleted_at IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds) AND rae.deleted_at IS NULL)) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),
@@ -58,7 +58,8 @@ class DriftBackupRepository extends DriftDatabaseRepository {
INNER JOIN main.local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id
AND la.backup_selection = ?3
);
)
AND (lae.checksum IS NULL OR lae.synced_checksum IS NULL OR lae.synced_checksum != lae.checksum);
''';
final row = await _db
@@ -104,6 +105,10 @@ class DriftBackupRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
),
) &
// iOS revert: a reverted local hashes fresh (matches nothing remote),
// but if it was already reconciled (syncedChecksum == current checksum)
// it's handled, so don't re-queue it as a fresh upload.
(lae.syncedChecksum.isNull() | lae.syncedChecksum.equalsExp(lae.checksum).not()) &
lae.id.isNotInQuery(_getExcludedSubquery()),
)
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
@@ -98,7 +98,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 27;
int get schemaVersion => 28;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -279,6 +279,11 @@ class Drift extends $Drift {
from26To27: (m, v27) async {
await customStatement('ALTER TABLE metadata RENAME TO settings');
},
from27To28: (m, v28) async {
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.priorRemoteId);
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.syncedChecksum);
await m.createIndex(v28.idxLocalAssetPriorRemoteId);
},
),
);
@@ -112,6 +112,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
i7.idxLocalAlbumAssetAlbumAsset,
i4.idxLocalAssetChecksum,
i4.idxLocalAssetCloudId,
i4.idxLocalAssetPriorRemoteId,
i3.idxStackPrimaryAssetId,
i2.uQRemoteAssetsOwnerChecksum,
i2.uQRemoteAssetsOwnerLibraryChecksum,
@@ -14083,6 +14083,612 @@ final class Schema27 extends i0.VersionedSchema {
);
}
final class Schema28 extends i0.VersionedSchema {
Schema28({required super.database}) : super(version: 28);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxLocalAssetPriorRemoteId,
idxStackPrimaryAssetId,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetOwnerVisibilityDeletedCreated,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
settings,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteExifCity,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxAssetFaceVisiblePerson,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
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 Shape51 localAssetEntity = Shape51(
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_213,
_column_214,
],
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 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,
);
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)',
);
}
class Shape51 extends i0.VersionedTable {
Shape51({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_213(String aliasedName) =>
i1.GeneratedColumn<String>(
'prior_remote_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i1.GeneratedColumn<String> _column_214(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,
@@ -14110,6 +14716,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -14243,6 +14850,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from26To27(migrator, schema);
return 27;
case 27:
final schema = Schema28(database: database);
final migrator = i1.Migrator(database, schema);
await from27To28(migrator, schema);
return 28;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -14276,6 +14888,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -14304,5 +14917,6 @@ i1.OnUpgrade stepByStep({
from24To25: from24To25,
from25To26: from25To26,
from26To27: from26To27,
from27To28: from27To28,
),
);
@@ -64,6 +64,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> markSynced(String localId, {required String priorRemoteId, required String? syncedChecksum}) {
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
);
}
Future<void> delete(List<String> ids) {
if (ids.isEmpty) {
return Future.value();
@@ -1,8 +1,24 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class StackReconcileTarget {
final String stackId;
final String newPrimaryId;
final String localAssetId;
final String localAssetChecksum;
const StackReconcileTarget({
required this.stackId,
required this.newPrimaryId,
required this.localAssetId,
required this.localAssetChecksum,
});
}
class DriftStackRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftStackRepository(this._db) : super(_db);
@@ -14,6 +30,111 @@ class DriftStackRepository extends DriftDatabaseRepository {
return stack.toDto();
}).get();
}
// Per local id, find a stack member whose checksum matches the local's current
// checksum but isn't the stack primary. That's the revert case: the local hashed
// back to the base while the primary still points at the edit.
Future<List<StackReconcileTarget>> findRevertReconcileTargets(Iterable<String> localAssetIds) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return const [];
}
final targets = <StackReconcileTarget>[];
for (final slice in ids.slices(kDriftMaxChunk)) {
final placeholders = List.filled(slice.length, '?').join(',');
final rows = await _db
.customSelect(
'''
SELECT
s.id AS stack_id,
member.id AS new_primary,
local.id AS local_id,
local.checksum AS local_checksum
FROM local_asset_entity local
INNER JOIN remote_asset_entity prior ON prior.id = local.prior_remote_id AND prior.deleted_at IS NULL
INNER JOIN stack_entity s ON s.id = prior.stack_id
INNER JOIN remote_asset_entity member
ON member.stack_id = s.id
AND member.checksum = local.checksum
AND member.deleted_at IS NULL
WHERE local.id IN ($placeholders)
AND s.primary_asset_id != member.id
''',
variables: slice.map((id) => Variable<String>(id)).toList(),
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
)
.get();
for (final row in rows) {
targets.add(
StackReconcileTarget(
stackId: row.read<String>('stack_id'),
newPrimaryId: row.read<String>('new_primary'),
localAssetId: row.read<String>('local_id'),
localAssetChecksum: row.read<String>('local_checksum'),
),
);
}
}
return targets;
}
// True only when we have positive evidence the remote was trashed: a synced
// row exists with deleted_at set. A missing row returns false on purpose — a
// just-uploaded prior isn't synced into remote_asset_entity yet, and treating
// "not synced" as "dead" would re-upload a duplicate base every cycle until
// the next remote sync lands.
Future<bool> isRemoteTrashed(String remoteId) async {
final row = await _db
.customSelect(
'SELECT 1 FROM remote_asset_entity WHERE id = ? AND deleted_at IS NOT NULL LIMIT 1',
variables: [Variable<String>(remoteId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
return row != null;
}
// 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 {
@@ -2,16 +2,12 @@ import 'package:immich_mobile/utils/semver.dart';
import 'package:openapi/api.dart';
class ServerVersion extends SemVer {
const ServerVersion({required super.major, required super.minor, required super.patch});
const ServerVersion({required super.major, required super.minor, required super.patch, super.prerelease});
@override
String toString() {
return 'ServerVersion(major: $major, minor: $minor, patch: $patch)';
}
ServerVersion.fromDto(ServerVersionResponseDto dto)
: super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease);
ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0}) {
return this >= SemVer(major: major, minor: minor, patch: patch);
bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) {
return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease);
}
}
+110 -10
View File
@@ -88,6 +88,8 @@ int _deepHash(Object? value) {
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum EditState { notEdited, edited, unknown }
class PlatformAsset {
PlatformAsset({
required this.id,
@@ -395,6 +397,55 @@ class CloudIdResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class BaseResource {
BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
String path;
String sha1;
int sizeBytes;
String mimeType;
List<Object?> _toList() {
return <Object?>[path, sha1, sizeBytes, mimeType];
}
Object encode() {
return _toList();
}
static BaseResource decode(Object result) {
result as List<Object?>;
return BaseResource(
path: result[0]! as String,
sha1: result[1]! as String,
sizeBytes: result[2]! as int,
mimeType: result[3]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! BaseResource || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(sha1, other.sha1) &&
_deepEquals(sizeBytes, other.sizeBytes) &&
_deepEquals(mimeType, other.mimeType);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -405,21 +456,27 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
} else if (value is EditState) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) {
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
} else if (value is PlatformAlbum) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
} else if (value is SyncDelta) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
} else if (value is HashResult) {
buffer.putUint8(134);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
buffer.putUint8(135);
writeValue(buffer, value.encode());
} else if (value is BaseResource) {
buffer.putUint8(136);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
@@ -432,15 +489,20 @@ class _PigeonCodec extends StandardMessageCodec {
final value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130:
return PlatformAsset.decode(readValue(buffer)!);
final value = readValue(buffer) as int?;
return value == null ? null : EditState.values[value];
case 131:
return PlatformAlbum.decode(readValue(buffer)!);
return PlatformAsset.decode(readValue(buffer)!);
case 132:
return SyncDelta.decode(readValue(buffer)!);
return PlatformAlbum.decode(readValue(buffer)!);
case 133:
return HashResult.decode(readValue(buffer)!);
return SyncDelta.decode(readValue(buffer)!);
case 134:
return HashResult.decode(readValue(buffer)!);
case 135:
return CloudIdResult.decode(readValue(buffer)!);
case 136:
return BaseResource.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@@ -691,4 +753,42 @@ class NativeSyncApi {
);
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
}
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as BaseResource?;
}
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as EditState;
}
}
@@ -1,4 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
@@ -11,6 +12,8 @@ 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';
@@ -45,11 +48,22 @@ final localSyncServiceProvider = Provider(
),
);
final editRevertServiceProvider = Provider(
(ref) => EditRevertService(
nativeSyncApi: ref.watch(nativeSyncApiProvider),
stackRepository: ref.watch(driftStackProvider),
localAssetRepository: ref.watch(localAssetRepository),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
),
);
final hashServiceProvider = Provider(
(ref) => HashService(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
stackRepository: ref.watch(driftStackProvider),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
),
);
@@ -53,9 +53,18 @@ 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();
}
@@ -105,6 +114,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
} catch (e) {
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
@@ -188,6 +198,14 @@ 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;
@@ -67,6 +67,10 @@ class AssetApiRepository extends ApiRepository {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
}
Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
@@ -30,6 +30,11 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupEditPairGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
@@ -9,17 +9,24 @@ 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/edit_pair.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
@@ -31,6 +38,9 @@ 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);
@@ -43,13 +53,35 @@ class UploadTaskMetadata {
final bool isLivePhotos;
final String livePhotoVideoId;
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
// Marks the base upload of an edit pair. On completion the chained edit
// upload is enqueued with stackParentId = this base's remote id.
final bool isEditPair;
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
// Path of the native temp file backing this task (the edit base), so it can
// be cleaned up on terminal status.
final String basePath;
const UploadTaskMetadata({
required this.localAssetId,
required this.isLivePhotos,
required this.livePhotoVideoId,
this.isEditPair = false,
this.basePath = '',
});
UploadTaskMetadata copyWith({
String? localAssetId,
bool? isLivePhotos,
String? livePhotoVideoId,
bool? isEditPair,
String? basePath,
}) {
return UploadTaskMetadata(
localAssetId: localAssetId ?? this.localAssetId,
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
isEditPair: isEditPair ?? this.isEditPair,
basePath: basePath ?? this.basePath,
);
}
@@ -58,6 +90,8 @@ class UploadTaskMetadata {
'localAssetId': localAssetId,
'isLivePhotos': isLivePhotos,
'livePhotoVideoId': livePhotoVideoId,
'isEditPair': isEditPair,
'basePath': basePath,
};
}
@@ -66,6 +100,8 @@ class UploadTaskMetadata {
localAssetId: map['localAssetId'] as String,
isLivePhotos: map['isLivePhotos'] as bool,
livePhotoVideoId: map['livePhotoVideoId'] as String,
isEditPair: (map['isEditPair'] as bool?) ?? false,
basePath: (map['basePath'] as String?) ?? '',
);
}
@@ -76,7 +112,7 @@ class UploadTaskMetadata {
@override
String toString() =>
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
@override
bool operator ==(covariant UploadTaskMetadata other) {
@@ -86,11 +122,18 @@ class UploadTaskMetadata {
return other.localAssetId == localAssetId &&
other.isLivePhotos == isLivePhotos &&
other.livePhotoVideoId == livePhotoVideoId;
other.livePhotoVideoId == livePhotoVideoId &&
other.isEditPair == isEditPair &&
other.basePath == basePath;
}
@override
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
int get hashCode =>
localAssetId.hashCode ^
isLivePhotos.hashCode ^
livePhotoVideoId.hashCode ^
isEditPair.hashCode ^
basePath.hashCode;
}
/// Service for handling background uploads using iOS URLSession (background_downloader)
@@ -104,6 +147,9 @@ class BackgroundUploadService {
this._localAssetRepository,
this._backupRepository,
this._assetMediaRepository,
this._nativeSyncApi,
this._editRevertService,
this._stackRepository,
) {
_uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
@@ -114,6 +160,9 @@ 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();
@@ -193,10 +242,13 @@ class BackgroundUploadService {
await _storageRepository.clearCache();
await _uploadRepository.reset(kBackupGroup);
await _uploadRepository.reset(kBackupEditPairGroup);
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
await _uploadRepository.deleteDatabaseRecords(kBackupEditPairGroup);
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
return activeTasks.length;
final activeEditTasks = await _uploadRepository.getActiveTasks(kBackupEditPairGroup);
return activeTasks.length + activeEditTasks.length;
}
/// Resume background backup processing
@@ -205,11 +257,25 @@ class BackgroundUploadService {
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
UploadTaskMetadata? metadata;
if (update.task.metaData.isNotEmpty) {
try {
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
} catch (_) {
metadata = null;
}
}
switch (update.status) {
case TaskStatus.complete:
unawaited(_handleLivePhoto(update));
unawaited(_handleLivePhoto(update, metadata));
unawaited(handleEditPair(update, metadata));
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
if (CurrentPlatform.isIOS) {
// Edit-pair bases live in the native temp dir and are deleted by
// handleEditPair via metadata.basePath; deleting here too just races it
// and logs a spurious SEVERE on the loser.
if (CurrentPlatform.isIOS && !(metadata?.isEditPair ?? false)) {
try {
final path = await update.task.filePath();
await File(path).delete();
@@ -220,19 +286,20 @@ class BackgroundUploadService {
break;
case TaskStatus.failed:
case TaskStatus.canceled:
case TaskStatus.notFound:
unawaited(_cleanupTempResourceOnFailure(metadata));
break;
default:
break;
}
}
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (update.task.metaData.isEmpty || update.task.metaData == '') {
return;
}
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
if (!metadata.isLivePhotos) {
if (metadata == null || !metadata.isLivePhotos) {
return;
}
@@ -258,6 +325,143 @@ class BackgroundUploadService {
}
}
/// When an edit-pair base upload finishes, enqueue the edit on top of it
/// (stackParentId = the base's new remote id).
@visibleForTesting
Future<void> handleEditPair(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || !metadata.isEditPair) {
return;
}
if (metadata.basePath.isNotEmpty) {
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
final baseRemoteId = _remoteIdFromResponse(update);
if (baseRemoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
final editTask = await getEditUploadTask(localAsset, baseRemoteId);
if (editTask != null) {
await enqueueTasks([editTask]);
}
} catch (error, stackTrace) {
dPrint(() => "Error handling edit pair task: $error $stackTrace");
}
}
/// Saves the uploaded remote id as the asset's priorRemoteId so a later edit
/// stacks onto it. Skipped for edit-pair base uploads; the chained edit records it.
@visibleForTesting
Future<void> recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || metadata.isEditPair || metadata.isLivePhotos || metadata.localAssetId.isEmpty) {
return;
}
final remoteId = _remoteIdFromResponse(update);
if (remoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
await _localAssetRepository.markSynced(
metadata.localAssetId,
priorRemoteId: remoteId,
syncedChecksum: localAsset?.checksum,
);
} catch (error, stackTrace) {
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
}
}
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
if (metadata == null || metadata.basePath.isEmpty) {
return;
}
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
/// 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;
}
}
Future<UploadTask> _buildBaseUploadTask(LocalAsset asset, BaseResource base) async {
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
basePath: base.path,
).toJson();
// The base is the unedited original (no adjustmentTime); the `_base`
// deviceAssetId keeps it distinct from the chained edit task.
return buildUploadTask(
File(base.path),
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
deviceAssetId: '${asset.id}_base',
metadata: metadata,
group: kBackupGroup,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getEditUploadTask(LocalAsset asset, String stackParentId) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
final fields = {'stackParentId': stackParentId};
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '').toJson();
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
fields: fields,
group: kBackupEditPairGroup,
priority: 0,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
@@ -266,6 +470,24 @@ class BackgroundUploadService {
return null;
}
// iOS edit pair: stack a user edit onto its original. resolveEditPair decides
// whether to reuse a prior upload or upload the base first. Live photos skip this.
if (!entity.isLivePhoto && CurrentPlatform.isIOS) {
// A reverted edit flips the stack back to the original and skips the upload.
if (asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
return null;
}
final plan = await resolveEditPair(_nativeSyncApi, asset, stackRepository: _stackRepository, log: _logger);
switch (plan) {
case UploadBaseFirst(:final base):
return _buildBaseUploadTask(asset, base);
case AbsorbIntoPrior(:final parentId):
return getEditUploadTask(asset, parentId);
case NoEditPair():
break;
}
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
+100
View File
@@ -0,0 +1,100 @@
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: not edited, identical bytes, or couldn't read it.
class NoEditPair extends EditPairPlan {
const NoEditPair();
}
/// 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);
}
/// Works out how an edited photo should stack: reuse a prior upload, upload the
/// original first, or do nothing. Shared by the foreground and background upload
/// paths. The caller already checked it's iOS and not a live photo.
///
/// 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], which reads the adjustment plist and decides.
Future<EditPairPlan> resolveEditPair(
NativeSyncApi nativeSyncApi,
LocalAsset asset, {
required DriftStackRepository stackRepository,
Logger? log,
}) async {
final priorRemoteId = asset.priorRemoteId;
if (priorRemoteId != null) {
// Reuse the prior upload unless it was trashed on the server. A dead parent
// makes the edit upload 400 ("Cannot stack onto a trashed or missing asset")
// forever; fall through to uploading the base again so the stack rebuilds.
bool priorTrashed;
try {
priorTrashed = await stackRepository.isRemoteTrashed(priorRemoteId);
} catch (error, stack) {
log?.warning(() => "Failed to check prior remote $priorRemoteId for ${asset.id}", error, stack);
priorTrashed = false;
}
if (!priorTrashed) {
return AbsorbIntoPrior(priorRemoteId);
}
}
if (!_mightBeEdited(asset)) {
return const NoEditPair();
}
BaseResource? base;
try {
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
} catch (error, stack) {
log?.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
return const NoEditPair();
}
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) {
try {
await File(base.path).delete();
} catch (_) {}
return const NoEditPair();
}
return UploadBaseFirst(base);
}
/// 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;
@@ -6,18 +6,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/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/edit_pair.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
@@ -39,6 +47,10 @@ 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),
);
});
@@ -54,6 +66,10 @@ class ForegroundUploadService {
this._backupRepository,
this._connectivityApi,
this._assetMediaRepository,
this._nativeSyncApi,
this._localAssetRepository,
this._editRevertService,
this._stackRepository,
);
final UploadRepository _uploadRepository;
@@ -61,6 +77,10 @@ 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;
@@ -250,6 +270,16 @@ class ForegroundUploadService {
return;
}
// A reverted iOS edit flips the stack back to the original and skips the upload.
// Live photos don't go through the edit-pair flow, so skip the native probe.
if (CurrentPlatform.isIOS &&
!entity.isLivePhoto &&
asset.priorRemoteId != null &&
await _editRevertService.tryHandleRevert(asset)) {
callbacks.onSuccess?.call(asset.localId!, asset.priorRemoteId!);
return;
}
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
@@ -355,20 +385,27 @@ class ForegroundUploadService {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Edit pair: upload the unedited original first and stack the edit onto it.
// Done before the edit's metadata is added below so the base isn't stamped
// with the edit's adjustmentTime.
if (!entity.isLivePhoto) {
final base = await _resolveStackParent(asset, Map.of(fields), cancelToken);
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");
return;
}
if (base.stackParentId != null) {
fields['stackParentId'] = base.stackParentId!;
}
}
// 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.
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 metadata = _cloudMetadata(asset, includeAdjustment: true);
if (metadata != null) {
fields['metadata'] = metadata;
}
final onProgress = callbacks.onProgress;
@@ -384,6 +421,14 @@ class ForegroundUploadService {
);
if (result.isSuccess && result.remoteAssetId != null) {
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");
@@ -415,6 +460,71 @@ 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}) {
if (!CurrentPlatform.isIOS || asset.cloudId == null) {
return null;
}
return jsonEncode([
RemoteAssetMetadataItem(
key: RemoteAssetMetadataKey.mobileApp,
value: RemoteAssetMobileAppMetadata(
cloudId: asset.cloudId,
createdAt: asset.createdAt.toIso8601String(),
adjustmentTime: includeAdjustment ? asset.adjustmentTime?.toIso8601String() : null,
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
),
),
]);
}
/// 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,
) async {
if (!CurrentPlatform.isIOS) {
return const _StackParent.none();
}
final plan = await resolveEditPair(_nativeSyncApi, asset, stackRepository: _stackRepository, log: _logger);
switch (plan) {
case NoEditPair():
return const _StackParent.none();
case AbsorbIntoPrior(:final parentId):
return _StackParent.parent(parentId);
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) {
return _StackParent.parent(result.remoteAssetId!);
}
return const _StackParent.failed();
} finally {
try {
await baseFile.delete();
} catch (_) {}
}
}
}
Future<UploadResult> _uploadSingleFile(
File file, {
required String deviceAssetId,
@@ -461,3 +571,16 @@ class ForegroundUploadService {
return true;
}
}
/// 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.
class _StackParent {
final String? stackParentId;
final bool baseFailed;
const _StackParent.none() : stackParentId = null, baseFailed = false;
const _StackParent.parent(String this.stackParentId) : baseFailed = false;
const _StackParent.failed() : stackParentId = null, baseFailed = true;
}
+52 -20
View File
@@ -1,36 +1,42 @@
enum SemVerType { major, minor, patch }
enum SemVerType { major, minor, patch, prerelease }
class SemVer {
final int major;
final int minor;
final int patch;
final int? prerelease;
const SemVer({required this.major, required this.minor, required this.patch});
const SemVer({required this.major, required this.minor, required this.patch, this.prerelease});
@override
String toString() {
return '$major.$minor.$patch';
return '$major.$minor.$patch${prerelease == null ? '' : '-rc.$prerelease'}';
}
SemVer copyWith({int? major, int? minor, int? patch}) {
return SemVer(major: major ?? this.major, minor: minor ?? this.minor, patch: patch ?? this.patch);
SemVer copyWith({int? major, int? minor, int? patch, int? prerelease}) {
return SemVer(
major: major ?? this.major,
minor: minor ?? this.minor,
patch: patch ?? this.patch,
prerelease: prerelease ?? this.prerelease,
);
}
static final _pattern = RegExp(r'^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?(?:[-+].*)?$', caseSensitive: false);
factory SemVer.fromString(String version) {
if (version.toLowerCase().startsWith("v")) {
version = version.substring(1);
}
final parts = version.split("-")[0].split('.');
if (parts.length != 3) {
final match = _pattern.firstMatch(version);
if (match == null) {
throw FormatException('Invalid semantic version string: $version');
}
try {
return SemVer(major: int.parse(parts[0]), minor: int.parse(parts[1]), patch: int.parse(parts[2]));
} catch (e) {
throw FormatException('Invalid semantic version string: $version');
}
final prerelease = match.group(4);
return SemVer(
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
prerelease: prerelease == null ? null : int.parse(prerelease),
);
}
bool operator >(SemVer other) {
@@ -40,7 +46,10 @@ class SemVer {
if (minor != other.minor) {
return minor > other.minor;
}
return patch > other.patch;
if (patch != other.patch) {
return patch > other.patch;
}
return _comparePrerelease(other) > 0;
}
bool operator <(SemVer other) {
@@ -50,7 +59,23 @@ class SemVer {
if (minor != other.minor) {
return minor < other.minor;
}
return patch < other.patch;
if (patch != other.patch) {
return patch < other.patch;
}
return _comparePrerelease(other) < 0;
}
int _comparePrerelease(SemVer other) {
if (prerelease == other.prerelease) {
return 0;
}
if (prerelease == null) {
return 1;
}
if (other.prerelease == null) {
return -1;
}
return prerelease!.compareTo(other.prerelease!);
}
bool operator >=(SemVer other) {
@@ -67,7 +92,11 @@ class SemVer {
return true;
}
return other is SemVer && other.major == major && other.minor == minor && other.patch == patch;
return other is SemVer &&
other.major == major &&
other.minor == minor &&
other.patch == patch &&
other.prerelease == prerelease;
}
SemVerType? differenceType(SemVer other) {
@@ -80,10 +109,13 @@ class SemVer {
if (patch != other.patch) {
return SemVerType.patch;
}
if (prerelease != other.prerelease) {
return SemVerType.prerelease;
}
return null;
}
@override
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode;
int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode ^ prerelease.hashCode;
}
@@ -50,9 +50,7 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "server_version".tr(),
text: serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "--",
text: serverInfoState.serverVersion.major > 0 ? "${serverInfoState.serverVersion}" : "--",
),
divider,
_ServerInfoItem(label: "server_info_box_server_url".tr(), text: getServerUrl() ?? '--', tooltip: true),
@@ -60,9 +58,7 @@ class AppBarServerInfo extends HookConsumerWidget {
divider,
_ServerInfoItem(
label: "latest_version".tr(),
text: serverInfoState.latestVersion!.major > 0
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
: "--",
text: serverInfoState.latestVersion!.major > 0 ? "${serverInfoState.latestVersion!}" : "--",
tooltip: true,
icon: serverInfoState.versionStatus == VersionStatus.serverOutOfDate
? const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12)
+8
View File
@@ -103,12 +103,16 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Apply edits to an existing asset
*AssetsApi* | [**endSession**](doc//AssetsApi.md#endsession) | **DELETE** /assets/{id}/video/stream/{sessionId} | End HLS streaming session
*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata
*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key
*AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | Retrieve asset OCR data
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics
*AssetsApi* | [**getMainPlaylist**](doc//AssetsApi.md#getmainplaylist) | **GET** /assets/{id}/video/stream/main.m3u8 | Get HLS main playlist
*AssetsApi* | [**getMediaPlaylist**](doc//AssetsApi.md#getmediaplaylist) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8 | Get HLS media playlist
*AssetsApi* | [**getSegment**](doc//AssetsApi.md#getsegment) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename} | Get HLS segment or init file
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
@@ -513,6 +517,9 @@ Class | Method | HTTP request | Description
- [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReleaseChannel](doc//ReleaseChannel.md)
- [ReleaseEventV1](doc//ReleaseEventV1.md)
- [ReleaseType](doc//ReleaseType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
- [RotateParameters](doc//RotateParameters.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
@@ -597,6 +604,7 @@ Class | Method | HTTP request | Description
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md)
- [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
+4
View File
@@ -258,6 +258,9 @@ part 'model/ratings_response.dart';
part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/release_channel.dart';
part 'model/release_event_v1.dart';
part 'model/release_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart';
part 'model/rotate_parameters.dart';
part 'model/search_album_response_dto.dart';
@@ -342,6 +345,7 @@ part 'model/sync_user_v1.dart';
part 'model/system_config_backups_dto.dart';
part 'model/system_config_dto.dart';
part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_f_fmpeg_realtime_dto.dart';
part 'model/system_config_faces_dto.dart';
part 'model/system_config_generated_fullsize_image_dto.dart';
part 'model/system_config_generated_image_dto.dart';
+327 -3
View File
@@ -423,6 +423,76 @@ class AssetsApi {
return null;
}
/// End HLS streaming session
///
/// Releases server resources for the streaming session.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> endSessionWithHttpInfo(String id, String sessionId, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}'
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// End HLS streaming session
///
/// Releases server resources for the streaming session.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<void> endSession(String id, String sessionId, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await endSessionWithHttpInfo(id, sessionId, key: key, slug: slug, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Retrieve edits for an existing asset
///
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
@@ -822,6 +892,250 @@ class AssetsApi {
return null;
}
/// Get HLS main playlist
///
/// Returns an HLS main playlist with all available variants for the asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getMainPlaylistWithHttpInfo(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/main.m3u8'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS main playlist
///
/// Returns an HLS main playlist with all available variants for the asset.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<String?> getMainPlaylist(String id, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getMainPlaylistWithHttpInfo(id, key: key, slug: slug, 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), 'String',) as String;
}
return null;
}
/// Get HLS media playlist
///
/// Returns an HLS media playlist for one variant of the streaming session.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getMediaPlaylistWithHttpInfo(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8'
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId)
.replaceAll('{variantIndex}', variantIndex.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS media playlist
///
/// Returns an HLS media playlist for one variant of the streaming session.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<String?> getMediaPlaylist(String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, 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), 'String',) as String;
}
return null;
}
/// Get HLS segment or init file
///
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] filename (required):
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}'
.replaceAll('{filename}', filename)
.replaceAll('{id}', id)
.replaceAll('{sessionId}', sessionId)
.replaceAll('{variantIndex}', variantIndex.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get HLS segment or init file
///
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
///
/// Parameters:
///
/// * [String] filename (required):
///
/// * [String] id (required):
///
/// * [String] sessionId (required):
///
/// * [int] variantIndex (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, Future<void>? abortTrigger, }) async {
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, 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), 'MultipartFile',) as MultipartFile;
}
return null;
}
/// Play asset video
///
/// Streams the video file for the specified asset. This endpoint also supports byte range requests.
@@ -1272,8 +1586,11 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, 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, String? stackParentId, AssetVisibility? visibility, Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@@ -1337,6 +1654,10 @@ class AssetsApi {
mp.fields[r'sidecarData'] = sidecarData.field;
mp.files.add(sidecarData);
}
if (stackParentId != null) {
hasFields = true;
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
}
if (visibility != null) {
hasFields = true;
mp.fields[r'visibility'] = parameterToString(visibility);
@@ -1397,9 +1718,12 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, 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,);
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,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+8
View File
@@ -563,6 +563,12 @@ class ApiClient {
return ReactionLevelTypeTransformer().decode(value);
case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value);
case 'ReleaseChannel':
return ReleaseChannelTypeTransformer().decode(value);
case 'ReleaseEventV1':
return ReleaseEventV1.fromJson(value);
case 'ReleaseType':
return ReleaseTypeTypeTransformer().decode(value);
case 'ReverseGeocodingStateResponseDto':
return ReverseGeocodingStateResponseDto.fromJson(value);
case 'RotateParameters':
@@ -731,6 +737,8 @@ class ApiClient {
return SystemConfigDto.fromJson(value);
case 'SystemConfigFFmpegDto':
return SystemConfigFFmpegDto.fromJson(value);
case 'SystemConfigFFmpegRealtimeDto':
return SystemConfigFFmpegRealtimeDto.fromJson(value);
case 'SystemConfigFacesDto':
return SystemConfigFacesDto.fromJson(value);
case 'SystemConfigGeneratedFullsizeImageDto':
+6
View File
@@ -157,6 +157,12 @@ String parameterToString(dynamic value) {
if (value is ReactionType) {
return ReactionTypeTypeTransformer().encode(value).toString();
}
if (value is ReleaseChannel) {
return ReleaseChannelTypeTransformer().encode(value).toString();
}
if (value is ReleaseType) {
return ReleaseTypeTypeTransformer().encode(value).toString();
}
if (value is SearchSuggestionType) {
return SearchSuggestionTypeTypeTransformer().encode(value).toString();
}
+3
View File
@@ -52,6 +52,7 @@ class JobName {
static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll');
static const librarySyncFiles = JobName._(r'LibrarySyncFiles');
static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll');
static const hlsSessionCleanup = JobName._(r'HlsSessionCleanup');
static const memoryCleanup = JobName._(r'MemoryCleanup');
static const memoryGenerate = JobName._(r'MemoryGenerate');
static const notificationsCleanup = JobName._(r'NotificationsCleanup');
@@ -110,6 +111,7 @@ class JobName {
librarySyncFilesQueueAll,
librarySyncFiles,
libraryScanQueueAll,
hlsSessionCleanup,
memoryCleanup,
memoryGenerate,
notificationsCleanup,
@@ -203,6 +205,7 @@ class JobNameTypeTransformer {
case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll;
case r'LibrarySyncFiles': return JobName.librarySyncFiles;
case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll;
case r'HlsSessionCleanup': return JobName.hlsSessionCleanup;
case r'MemoryCleanup': return JobName.memoryCleanup;
case r'MemoryGenerate': return JobName.memoryGenerate;
case r'NotificationsCleanup': return JobName.notificationsCleanup;
+85
View File
@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Release channel
class ReleaseChannel {
/// Instantiate a new enum with the provided [value].
const ReleaseChannel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const stable = ReleaseChannel._(r'stable');
static const releaseCandidate = ReleaseChannel._(r'releaseCandidate');
/// List of all possible values in this [enum][ReleaseChannel].
static const values = <ReleaseChannel>[
stable,
releaseCandidate,
];
static ReleaseChannel? fromJson(dynamic value) => ReleaseChannelTypeTransformer().decode(value);
static List<ReleaseChannel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseChannel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseChannel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseChannel] to String,
/// and [decode] dynamic data back to [ReleaseChannel].
class ReleaseChannelTypeTransformer {
factory ReleaseChannelTypeTransformer() => _instance ??= const ReleaseChannelTypeTransformer._();
const ReleaseChannelTypeTransformer._();
String encode(ReleaseChannel data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseChannel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseChannel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'stable': return ReleaseChannel.stable;
case r'releaseCandidate': return ReleaseChannel.releaseCandidate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseChannelTypeTransformer] instance.
static ReleaseChannelTypeTransformer? _instance;
}
+133
View File
@@ -0,0 +1,133 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseEventV1 {
/// Returns a new [ReleaseEventV1] instance.
ReleaseEventV1({
required this.checkedAt,
required this.isAvailable,
required this.releaseVersion,
required this.serverVersion,
required this.type,
});
/// When the server last checked for a latest version. As an ISO timestamp
String checkedAt;
/// Whether a new version is available
bool isAvailable;
ServerVersionResponseDto releaseVersion;
ServerVersionResponseDto serverVersion;
ReleaseType type;
@override
bool operator ==(Object other) => identical(this, other) || other is ReleaseEventV1 &&
other.checkedAt == checkedAt &&
other.isAvailable == isAvailable &&
other.releaseVersion == releaseVersion &&
other.serverVersion == serverVersion &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(checkedAt.hashCode) +
(isAvailable.hashCode) +
(releaseVersion.hashCode) +
(serverVersion.hashCode) +
(type.hashCode);
@override
String toString() => 'ReleaseEventV1[checkedAt=$checkedAt, isAvailable=$isAvailable, releaseVersion=$releaseVersion, serverVersion=$serverVersion, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'checkedAt'] = this.checkedAt;
json[r'isAvailable'] = this.isAvailable;
json[r'releaseVersion'] = this.releaseVersion;
json[r'serverVersion'] = this.serverVersion;
json[r'type'] = this.type;
return json;
}
/// Returns a new [ReleaseEventV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ReleaseEventV1? fromJson(dynamic value) {
upgradeDto(value, "ReleaseEventV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ReleaseEventV1(
checkedAt: mapValueOfType<String>(json, r'checkedAt')!,
isAvailable: mapValueOfType<bool>(json, r'isAvailable')!,
releaseVersion: ServerVersionResponseDto.fromJson(json[r'releaseVersion'])!,
serverVersion: ServerVersionResponseDto.fromJson(json[r'serverVersion'])!,
type: ReleaseType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<ReleaseEventV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseEventV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseEventV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ReleaseEventV1> mapFromJson(dynamic json) {
final map = <String, ReleaseEventV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ReleaseEventV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ReleaseEventV1-objects as value to a dart map
static Map<String, List<ReleaseEventV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ReleaseEventV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ReleaseEventV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checkedAt',
'isAvailable',
'releaseVersion',
'serverVersion',
'type',
};
}
+100
View File
@@ -0,0 +1,100 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ReleaseType {
/// Instantiate a new enum with the provided [value].
const ReleaseType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const major = ReleaseType._(r'major');
static const premajor = ReleaseType._(r'premajor');
static const minor = ReleaseType._(r'minor');
static const preminor = ReleaseType._(r'preminor');
static const patch_ = ReleaseType._(r'patch');
static const prepatch = ReleaseType._(r'prepatch');
static const prerelease = ReleaseType._(r'prerelease');
/// List of all possible values in this [enum][ReleaseType].
static const values = <ReleaseType>[
major,
premajor,
minor,
preminor,
patch_,
prepatch,
prerelease,
];
static ReleaseType? fromJson(dynamic value) => ReleaseTypeTypeTransformer().decode(value);
static List<ReleaseType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ReleaseType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ReleaseType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ReleaseType] to String,
/// and [decode] dynamic data back to [ReleaseType].
class ReleaseTypeTypeTransformer {
factory ReleaseTypeTypeTransformer() => _instance ??= const ReleaseTypeTypeTransformer._();
const ReleaseTypeTypeTransformer._();
String encode(ReleaseType data) => data.value;
/// Decodes a [dynamic value][data] to a ReleaseType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ReleaseType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'major': return ReleaseType.major;
case r'premajor': return ReleaseType.premajor;
case r'minor': return ReleaseType.minor;
case r'preminor': return ReleaseType.preminor;
case r'patch': return ReleaseType.patch_;
case r'prepatch': return ReleaseType.prepatch;
case r'prerelease': return ReleaseType.prerelease;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ReleaseTypeTypeTransformer] instance.
static ReleaseTypeTypeTransformer? _instance;
}
+10 -1
View File
@@ -23,6 +23,7 @@ class ServerFeaturesDto {
required this.oauthAutoLaunch,
required this.ocr,
required this.passwordLogin,
required this.realtimeTranscoding,
required this.reverseGeocoding,
required this.search,
required this.sidecar,
@@ -60,6 +61,9 @@ class ServerFeaturesDto {
/// Whether password login is enabled
bool passwordLogin;
/// Whether real-time transcoding is enabled
bool realtimeTranscoding;
/// Whether reverse geocoding is enabled
bool reverseGeocoding;
@@ -87,6 +91,7 @@ class ServerFeaturesDto {
other.oauthAutoLaunch == oauthAutoLaunch &&
other.ocr == ocr &&
other.passwordLogin == passwordLogin &&
other.realtimeTranscoding == realtimeTranscoding &&
other.reverseGeocoding == reverseGeocoding &&
other.search == search &&
other.sidecar == sidecar &&
@@ -106,6 +111,7 @@ class ServerFeaturesDto {
(oauthAutoLaunch.hashCode) +
(ocr.hashCode) +
(passwordLogin.hashCode) +
(realtimeTranscoding.hashCode) +
(reverseGeocoding.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
@@ -113,7 +119,7 @@ class ServerFeaturesDto {
(trash.hashCode);
@override
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, realtimeTranscoding=$realtimeTranscoding, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -127,6 +133,7 @@ class ServerFeaturesDto {
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
json[r'ocr'] = this.ocr;
json[r'passwordLogin'] = this.passwordLogin;
json[r'realtimeTranscoding'] = this.realtimeTranscoding;
json[r'reverseGeocoding'] = this.reverseGeocoding;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
@@ -154,6 +161,7 @@ class ServerFeaturesDto {
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
ocr: mapValueOfType<bool>(json, r'ocr')!,
passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
realtimeTranscoding: mapValueOfType<bool>(json, r'realtimeTranscoding')!,
reverseGeocoding: mapValueOfType<bool>(json, r'reverseGeocoding')!,
search: mapValueOfType<bool>(json, r'search')!,
sidecar: mapValueOfType<bool>(json, r'sidecar')!,
@@ -216,6 +224,7 @@ class ServerFeaturesDto {
'oauthAutoLaunch',
'ocr',
'passwordLogin',
'realtimeTranscoding',
'reverseGeocoding',
'search',
'sidecar',
+22 -6
View File
@@ -16,47 +16,61 @@ class ServerVersionResponseDto {
required this.major,
required this.minor,
required this.patch_,
required this.prerelease,
});
/// Major version number
///
/// Minimum value: -9007199254740991
/// Minimum value: 0
/// Maximum value: 9007199254740991
int major;
/// Minor version number
///
/// Minimum value: -9007199254740991
/// Minimum value: 0
/// Maximum value: 9007199254740991
int minor;
/// Patch version number
///
/// Minimum value: -9007199254740991
/// Minimum value: 0
/// Maximum value: 9007199254740991
int patch_;
/// Pre-release version number
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? prerelease;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
other.major == major &&
other.minor == minor &&
other.patch_ == patch_;
other.patch_ == patch_ &&
other.prerelease == prerelease;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(major.hashCode) +
(minor.hashCode) +
(patch_.hashCode);
(patch_.hashCode) +
(prerelease == null ? 0 : prerelease!.hashCode);
@override
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_, prerelease=$prerelease]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'major'] = this.major;
json[r'minor'] = this.minor;
json[r'patch'] = this.patch_;
if (this.prerelease != null) {
json[r'prerelease'] = this.prerelease;
} else {
// json[r'prerelease'] = null;
}
return json;
}
@@ -72,6 +86,7 @@ class ServerVersionResponseDto {
major: mapValueOfType<int>(json, r'major')!,
minor: mapValueOfType<int>(json, r'minor')!,
patch_: mapValueOfType<int>(json, r'patch')!,
prerelease: mapValueOfType<int>(json, r'prerelease'),
);
}
return null;
@@ -122,6 +137,7 @@ class ServerVersionResponseDto {
'major',
'minor',
'patch',
'prerelease',
};
}
+9 -1
View File
@@ -25,6 +25,7 @@ class SystemConfigFFmpegDto {
required this.maxBitrate,
required this.preferredHwDevice,
required this.preset,
required this.realtime,
required this.refs,
required this.targetAudioCodec,
required this.targetResolution,
@@ -79,6 +80,8 @@ class SystemConfigFFmpegDto {
/// Preset
String preset;
SystemConfigFFmpegRealtimeDto realtime;
/// References
///
/// Minimum value: 0
@@ -122,6 +125,7 @@ class SystemConfigFFmpegDto {
other.maxBitrate == maxBitrate &&
other.preferredHwDevice == preferredHwDevice &&
other.preset == preset &&
other.realtime == realtime &&
other.refs == refs &&
other.targetAudioCodec == targetAudioCodec &&
other.targetResolution == targetResolution &&
@@ -147,6 +151,7 @@ class SystemConfigFFmpegDto {
(maxBitrate.hashCode) +
(preferredHwDevice.hashCode) +
(preset.hashCode) +
(realtime.hashCode) +
(refs.hashCode) +
(targetAudioCodec.hashCode) +
(targetResolution.hashCode) +
@@ -158,7 +163,7 @@ class SystemConfigFFmpegDto {
(twoPass.hashCode);
@override
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, realtime=$realtime, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -174,6 +179,7 @@ class SystemConfigFFmpegDto {
json[r'maxBitrate'] = this.maxBitrate;
json[r'preferredHwDevice'] = this.preferredHwDevice;
json[r'preset'] = this.preset;
json[r'realtime'] = this.realtime;
json[r'refs'] = this.refs;
json[r'targetAudioCodec'] = this.targetAudioCodec;
json[r'targetResolution'] = this.targetResolution;
@@ -207,6 +213,7 @@ class SystemConfigFFmpegDto {
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
preset: mapValueOfType<String>(json, r'preset')!,
realtime: SystemConfigFFmpegRealtimeDto.fromJson(json[r'realtime'])!,
refs: mapValueOfType<int>(json, r'refs')!,
targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!,
targetResolution: mapValueOfType<String>(json, r'targetResolution')!,
@@ -275,6 +282,7 @@ class SystemConfigFFmpegDto {
'maxBitrate',
'preferredHwDevice',
'preset',
'realtime',
'refs',
'targetAudioCodec',
'targetResolution',
@@ -0,0 +1,100 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SystemConfigFFmpegRealtimeDto {
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
SystemConfigFFmpegRealtimeDto({
required this.enabled,
});
/// Enable real-time HLS transcoding (alpha)
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode);
@override
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
return json;
}
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigFFmpegRealtimeDto? fromJson(dynamic value) {
upgradeDto(value, "SystemConfigFFmpegRealtimeDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigFFmpegRealtimeDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
return null;
}
static List<SystemConfigFFmpegRealtimeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigFFmpegRealtimeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigFFmpegRealtimeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigFFmpegRealtimeDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigFFmpegRealtimeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigFFmpegRealtimeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigFFmpegRealtimeDto-objects as value to a dart map
static Map<String, List<SystemConfigFFmpegRealtimeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigFFmpegRealtimeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigFFmpegRealtimeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
};
}
@@ -13,26 +13,32 @@ part of openapi.api;
class SystemConfigNewVersionCheckDto {
/// Returns a new [SystemConfigNewVersionCheckDto] instance.
SystemConfigNewVersionCheckDto({
required this.channel,
required this.enabled,
});
ReleaseChannel channel;
/// Enabled
bool enabled;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigNewVersionCheckDto &&
other.channel == channel &&
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(channel.hashCode) +
(enabled.hashCode);
@override
String toString() => 'SystemConfigNewVersionCheckDto[enabled=$enabled]';
String toString() => 'SystemConfigNewVersionCheckDto[channel=$channel, enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'channel'] = this.channel;
json[r'enabled'] = this.enabled;
return json;
}
@@ -46,6 +52,7 @@ class SystemConfigNewVersionCheckDto {
final json = value.cast<String, dynamic>();
return SystemConfigNewVersionCheckDto(
channel: ReleaseChannel.fromJson(json[r'channel'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
);
}
@@ -94,6 +101,7 @@ class SystemConfigNewVersionCheckDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'channel',
'enabled',
};
}
+23
View File
@@ -103,6 +103,21 @@ class CloudIdResult {
const CloudIdResult({required this.assetId, this.error, this.cloudId});
}
class BaseResource {
final String path;
final String sha1;
final int sizeBytes;
final String mimeType;
const BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
}
// Whether an iOS asset currently carries a user edit, as opposed to a
// capture-time Photographic Style or a reverted edit. `unknown` means the
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
// network wasn't allowed), so callers must not treat it as "not edited".
enum EditState { notEdited, edited, unknown }
@HostApi()
abstract class NativeSyncApi {
bool shouldFullSync();
@@ -140,4 +155,12 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
}
+3 -3
View File
@@ -34,7 +34,7 @@ dependencies:
flutter_web_auth_2: ^5.0.2
fluttertoast: ^8.2.14
geolocator: ^14.0.2
home_widget: ^0.9.0
home_widget: ^0.8.1
hooks_riverpod: ^2.6.1
http: ^1.6.0
image_picker: ^1.2.1
@@ -43,7 +43,7 @@ dependencies:
intl: ^0.20.2
local_auth: ^2.3.0
logging: ^1.3.0
maplibre_gl: ^0.26.0
maplibre_gl: ^0.22.0
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
@@ -67,7 +67,7 @@ dependencies:
sliver_tools: ^0.2.12
stream_transform: ^2.1.1
thumbhash: 0.1.0+1
timezone: ^0.11.0
timezone: ^0.9.4
url_launcher: ^6.3.2
uuid: ^4.5.3
wakelock_plus: ^1.3.3
+3
View File
@@ -1,6 +1,9 @@
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class MockSyncApi extends Mock implements SyncApi {}
class MockServerApi extends Mock implements ServerApi {}
class MockConnectivityApi extends Mock implements ConnectivityApi {}
+3
View File
@@ -1,3 +1,4 @@
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -11,3 +12,5 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockEditRevertService extends Mock implements EditRevertService {}
@@ -116,7 +116,7 @@ void main() {
when(() => mockApi.serverInfoApi).thenReturn(mockServerApi);
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0));
).thenAnswer((_) async => ServerVersionResponseDto(major: 1, minor: 132, patch_: 0, prerelease: null));
when(() => mockSyncStreamRepo.updateUsersV1(any())).thenAnswer(successHandler);
when(() => mockSyncStreamRepo.deleteUsersV1(any())).thenAnswer(successHandler);
@@ -559,7 +559,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
await sut.sync();
@@ -587,7 +587,7 @@ void main() {
await Store.put(StoreKey.syncMigrationStatus, "[]");
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 5, patch_: 0, prerelease: null));
await sut.sync();
verifyInOrder([
@@ -617,7 +617,7 @@ void main() {
when(
() => mockServerApi.getServerVersion(),
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1));
).thenAnswer((_) async => ServerVersionResponseDto(major: 2, minor: 4, patch_: 1, prerelease: null));
await sut.sync();
+4
View File
@@ -31,6 +31,7 @@ import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -90,6 +91,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v26.DatabaseAtV26(db);
case 27:
return v27.DatabaseAtV27(db);
case 28:
return v28.DatabaseAtV28(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -123,5 +126,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
25,
26,
27,
28,
];
}
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
@@ -36,6 +37,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
@@ -0,0 +1,132 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftStackRepository sut;
late String userId;
setUp(() async {
ctx = MediumRepositoryContext();
sut = DriftStackRepository(ctx.db);
final user = await ctx.newUser();
userId = user.id;
});
tearDown(() async {
await ctx.dispose();
});
group('isRemoteTrashed', () {
test('is false for a live remote', () async {
await ctx.newRemoteAsset(id: 'live', ownerId: userId);
expect(await sut.isRemoteTrashed('live'), isFalse);
});
test('is false for a remote that was never synced', () async {
expect(await sut.isRemoteTrashed('missing'), isFalse);
});
test('is true only when the synced remote is trashed', () async {
await ctx.newRemoteAsset(id: 'trashed', ownerId: userId, deletedAt: DateTime(2025, 6));
expect(await sut.isRemoteTrashed('trashed'), isTrue);
});
});
group('findStackIdByRemoteId', () {
test('returns the stack id for a stacked remote', () async {
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id);
expect(await sut.findStackIdByRemoteId('edit'), stack.id);
});
test('returns null for an unstacked remote', () async {
await ctx.newRemoteAsset(id: 'lonely', ownerId: userId);
expect(await sut.findStackIdByRemoteId('lonely'), isNull);
});
test('returns null for a trashed remote', () async {
final base = await ctx.newRemoteAsset(id: 'base', ownerId: userId);
final stack = await ctx.newStack(ownerId: userId, primaryAssetId: base.id);
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: stack.id, deletedAt: DateTime(2025, 6));
expect(await sut.findStackIdByRemoteId('edit'), isNull);
});
});
group('findStackBaseId', () {
test('returns the earliest-uploaded member that is not the excluded one', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025));
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
// base uploaded before the edit → it's the flip target.
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), 'base');
});
test('returns null when the only member is excluded', () async {
final base = await ctx.newRemoteAsset(id: 'solo', ownerId: userId, stackId: 'stack-1');
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: base.id);
expect(await sut.findStackBaseId('stack-1', excludeId: 'solo'), isNull);
});
test('skips trashed members', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(
id: 'base',
ownerId: userId,
stackId: 'stack-1',
uploadedAt: DateTime(2025),
deletedAt: DateTime(2025, 6),
);
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', uploadedAt: DateTime(2025, 2));
expect(await sut.findStackBaseId('stack-1', excludeId: 'edit'), isNull);
});
});
group('findRevertReconcileTargets', () {
test('finds a local that hashed back to a non-primary stack member', () async {
// Stack: primary = edit, also holds base. The local's checksum matches base.
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
final targets = await sut.findRevertReconcileTargets(['local-1']);
expect(targets, hasLength(1));
expect(targets.first.stackId, 'stack-1');
expect(targets.first.newPrimaryId, 'base');
expect(targets.first.localAssetId, 'local-1');
});
test('returns nothing when the local already matches the primary', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'edit', ownerId: userId, stackId: 'stack-1', checksum: 'edit-sum');
await ctx.newLocalAsset(id: 'local-1', checksum: 'edit-sum', priorRemoteId: 'edit');
expect(await sut.findRevertReconcileTargets(['local-1']), isEmpty);
});
test('ignores a local whose prior remote was trashed', () async {
await ctx.newStack(id: 'stack-1', ownerId: userId, primaryAssetId: 'edit');
await ctx.newRemoteAsset(id: 'base', ownerId: userId, stackId: 'stack-1', checksum: 'base-sum');
await ctx.newRemoteAsset(
id: 'edit',
ownerId: userId,
stackId: 'stack-1',
checksum: 'edit-sum',
deletedAt: DateTime(2025, 6),
);
await ctx.newLocalAsset(id: 'local-1', checksum: 'base-sum', priorRemoteId: 'edit');
expect(await sut.findRevertReconcileTargets(['local-1']), isEmpty);
});
test('returns nothing for an empty id set', () async {
expect(await sut.findRevertReconcileTargets(const []), isEmpty);
});
});
}
@@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset_cloud_id.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
@@ -86,6 +87,7 @@ class MediumRepositoryContext {
String? stackId,
String? thumbHash,
String? libraryId,
DateTime? uploadedAt,
}) async {
id ??= TestUtils.uuid();
createdAt ??= TestUtils.date();
@@ -112,6 +114,19 @@ class MediumRepositoryContext {
localDateTime: .new(createdAt.toLocal()),
thumbHash: .new(TestUtils.uuid(thumbHash)),
libraryId: .new(TestUtils.uuid(libraryId)),
uploadedAt: .new(uploadedAt),
),
);
}
Future<StackEntityData> newStack({String? id, String? ownerId, required String primaryAssetId}) {
return db
.into(db.stackEntity)
.insertReturning(
StackEntityCompanion(
id: .new(TestUtils.uuid(id)),
ownerId: .new(TestUtils.uuid(ownerId)),
primaryAssetId: .new(primaryAssetId),
),
);
}
@@ -245,6 +260,8 @@ class MediumRepositoryContext {
int? durationMs,
int? orientation,
DateTime? updatedAt,
String? priorRemoteId,
String? syncedChecksum,
}) async {
id ??= TestUtils.uuid();
return db
@@ -266,6 +283,8 @@ class MediumRepositoryContext {
adjustmentTime: _resolveUndefined(adjustmentTime, adjustmentTimeOption, DateTime.now()),
latitude: .new(latitude ?? TestUtils.randDouble(-90, 90)),
longitude: .new(longitude ?? TestUtils.randDouble(-180, 180)),
priorRemoteId: .new(priorRemoteId),
syncedChecksum: .new(syncedChecksum),
),
);
}
@@ -1,11 +1,13 @@
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -13,9 +15,11 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
@@ -28,10 +32,15 @@ void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockDriftBackupRepository mockBackupRepository;
late MockAssetMediaRepository mockAssetMediaRepository;
late MockNativeSyncApi mockNativeSyncApi;
late MockEditRevertService mockEditRevertService;
late MockDriftStackRepository mockStackRepository;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
registerFallbackValue(LocalAssetStub.image1);
registerFallbackValue(<UploadTask>[]);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(MethodCall methodCall) async => 'test',
@@ -50,6 +59,9 @@ void main() {
mockLocalAssetRepository = MockDriftLocalAssetRepository();
mockBackupRepository = MockDriftBackupRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockNativeSyncApi = MockNativeSyncApi();
mockEditRevertService = MockEditRevertService();
mockStackRepository = MockDriftStackRepository();
sut = BackgroundUploadService(
mockUploadRepository,
@@ -57,8 +69,22 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
mockStackRepository,
);
// Default: no edit base, so getUploadTask falls through to the normal path.
when(
() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => null);
// Default: not a revert, so getUploadTask proceeds with the normal flow.
when(() => mockEditRevertService.tryHandleRevert(any())).thenAnswer((_) async => false);
// Default: prior remotes are alive, so absorb is allowed.
when(() => mockStackRepository.isRemoteTrashed(any())).thenAnswer((_) async => false);
mockUploadRepository.onUploadStatus = (_) {};
mockUploadRepository.onTaskProgress = (_) {};
});
@@ -122,6 +148,234 @@ void main() {
});
});
group('getUploadTask edit pair', () {
test('absorption: stacks the edit under the prior upload via stackParentId', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(priorRemoteId: 'prior-remote-1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupEditPairGroup);
expect(task.fields['stackParentId'], 'prior-remote-1');
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('builds a base upload task for an unsynced edit', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(
checksum: 'edited-sha1',
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
);
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'original-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.metaData, contains('"isEditPair":true'));
});
test('falls through to a normal upload when base bytes match the checksum', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(
checksum: 'same-sha1',
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
);
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'same-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.fields.containsKey('stackParentId'), isFalse);
});
test('gate: skips the native read for an unedited photo (adjustmentTime == createdAt)', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(adjustmentTime: LocalAssetStub.image1.createdAt);
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.fields.containsKey('stackParentId'), isFalse);
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('gate: skips the native read when the photo has no adjustmentTime', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1; // adjustmentTime is null
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
});
group('edit pair completion', () {
test('handleEditPair: enqueues the edit stacked onto the uploaded base', () async {
final asset = LocalAssetStub.image1;
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
);
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
TaskStatus.complete,
null,
'{"id":"base-remote-1"}',
);
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
when(() => mockUploadRepository.enqueueBackgroundAll(any())).thenAnswer((_) async => [true]);
await sut.handleEditPair(update, metadata);
final enqueued =
verify(() => mockUploadRepository.enqueueBackgroundAll(captureAny())).captured.single as List<UploadTask>;
expect(enqueued.single.fields['stackParentId'], 'base-remote-1');
expect(enqueued.single.group, kBackupEditPairGroup);
});
test('handleEditPair: does nothing for a non edit-pair upload', () async {
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: false, livePhotoVideoId: '');
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
TaskStatus.complete,
null,
'{"id":"remote-1"}',
);
await sut.handleEditPair(update, metadata);
verifyNever(() => mockUploadRepository.enqueueBackgroundAll(any()));
});
test('recordPriorRemoteIdOnSuccess: marks the local synced with the uploaded id', () async {
final asset = LocalAssetStub.image1;
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '');
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
TaskStatus.complete,
null,
'{"id":"remote-1"}',
);
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
when(
() => mockLocalAssetRepository.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
verify(
() => mockLocalAssetRepository.markSynced(
asset.id,
priorRemoteId: 'remote-1',
syncedChecksum: asset.checksum,
),
).called(1);
});
test('recordPriorRemoteIdOnSuccess: skips edit-pair base uploads', () async {
const metadata = UploadTaskMetadata(
localAssetId: 'local-1',
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
);
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
TaskStatus.complete,
null,
'{"id":"base-remote-1"}',
);
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
verifyNever(
() => mockLocalAssetRepository.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
test('recordPriorRemoteIdOnSuccess: skips live photos', () async {
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: true, livePhotoVideoId: '');
final update = TaskStatusUpdate(
UploadTask(url: 'http://test-server.com', filename: 'live.mov'),
TaskStatus.complete,
null,
'{"id":"video-remote-1"}',
);
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
verifyNever(
() => mockLocalAssetRepository.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
});
group('getLivePhotoUploadTask', () {
test('should call getOriginalFilename for live photo upload task', () async {
final asset = LocalAssetStub.image1;
@@ -172,6 +426,9 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
mockStackRepository,
);
addTearDown(() => sutWithV24.dispose());
@@ -222,6 +479,9 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
mockStackRepository,
);
addTearDown(() => sutAndroid.dispose());
@@ -262,6 +522,9 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
mockStackRepository,
);
addTearDown(() => sutWithV24.dispose());
@@ -302,6 +565,9 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
mockStackRepository,
);
addTearDown(() => sutWithV24.dispose());
@@ -0,0 +1,220 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../api.mocks.dart';
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
import '../repository.mocks.dart';
void main() {
late ForegroundUploadService sut;
late MockUploadRepository mockUpload;
late MockStorageRepository mockStorage;
late MockDriftBackupRepository mockBackup;
late MockConnectivityApi mockConnectivity;
late MockAssetMediaRepository mockAssetMedia;
late MockNativeSyncApi mockNativeApi;
late MockDriftLocalAssetRepository mockLocalAsset;
late MockEditRevertService mockEditRevert;
late MockDriftStackRepository mockStack;
late Drift db;
late Directory tmp;
final edited = LocalAsset(
id: 'edited-1',
name: 'edited-1.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1, 12),
updatedAt: DateTime(2025, 1, 1, 12),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
checksum: 'edited-sha1',
// 30s past createdAt → the edit gate fires.
adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30),
);
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
registerFallbackValue(edited);
registerFallbackValue(File('/tmp/fallback'));
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(_) async => 'test',
);
db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
await StoreService.init(storeRepository: DriftStoreRepository(db));
await SettingsRepository.ensureInitialized(db);
await Store.put(StoreKey.serverEndpoint, 'http://test-server.com');
await Store.put(StoreKey.deviceId, 'test-device-id');
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
});
tearDownAll(() {
debugDefaultTargetPlatformOverride = null;
});
setUp(() async {
mockUpload = MockUploadRepository();
mockStorage = MockStorageRepository();
mockBackup = MockDriftBackupRepository();
mockConnectivity = MockConnectivityApi();
mockAssetMedia = MockAssetMediaRepository();
mockNativeApi = MockNativeSyncApi();
mockLocalAsset = MockDriftLocalAssetRepository();
mockEditRevert = MockEditRevertService();
mockStack = MockDriftStackRepository();
sut = ForegroundUploadService(
mockUpload,
mockStorage,
mockBackup,
mockConnectivity,
mockAssetMedia,
mockNativeApi,
mockLocalAsset,
mockEditRevert,
mockStack,
);
tmp = await Directory.systemTemp.createTemp('fg_upload_test');
final assetFile = File('${tmp.path}/edited-1.jpg')..writeAsStringSync('edit-bytes');
final baseFile = File('${tmp.path}/edited-1_base.jpg')..writeAsStringSync('base-bytes');
when(() => mockStorage.clearCache()).thenAnswer((_) async {});
when(() => mockConnectivity.getCapabilities()).thenAnswer((_) async => [NetworkCapability.unmetered]);
final entity = MockAssetEntity();
when(() => entity.isLivePhoto).thenReturn(false);
when(() => mockStorage.getAssetEntityForAsset(any())).thenAnswer((_) async => entity);
when(() => mockStorage.isAssetAvailableLocally(any())).thenAnswer((_) async => true);
when(() => mockStorage.getFileForAsset(any())).thenAnswer((_) async => assetFile);
when(() => mockAssetMedia.getOriginalFilename(any())).thenAnswer((_) async => 'edited-1.jpg');
// Not a revert; prior is alive; the edit gate fires with a real base file.
when(() => mockEditRevert.tryHandleRevert(any())).thenAnswer((_) async => false);
when(() => mockStack.isRemoteTrashed(any())).thenAnswer((_) async => false);
when(() => mockNativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer(
(_) async => BaseResource(path: baseFile.path, sha1: 'base-sha1', sizeBytes: 10, mimeType: 'image/jpeg'),
);
when(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
});
tearDown(() async {
if (tmp.existsSync()) {
tmp.deleteSync(recursive: true);
}
});
group('edit pair base failure', () {
test('does not upload the edit or mark synced when the base upload fails', () async {
// Base upload fails; the edit upload should never run.
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((_) async => UploadResult.error(errorMessage: 'boom', statusCode: 500));
await sut.uploadManual([edited]);
// Exactly one upload attempt (the base). The edit must not be uploaded,
// and the asset must stay a candidate (no markSynced).
verify(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: 'baseResource[edited-1]',
),
).called(1);
verifyNever(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: 'asset[edited-1]',
),
);
verifyNever(
() => mockLocalAsset.markSynced(
any(),
priorRemoteId: any(named: 'priorRemoteId'),
syncedChecksum: any(named: 'syncedChecksum'),
),
);
});
test('uploads the edit with stackParentId and marks synced when the base succeeds', () async {
var uploadCount = 0;
when(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: any(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: any(named: 'logContext'),
),
).thenAnswer((invocation) async {
uploadCount++;
// base first → base-remote, then the edit → edit-remote.
return UploadResult.success(remoteAssetId: uploadCount == 1 ? 'base-remote' : 'edit-remote');
});
await sut.uploadManual([edited]);
// The edit upload carries the base's id as stackParentId.
final captured = verify(
() => mockUpload.uploadFile(
file: any(named: 'file'),
originalFileName: any(named: 'originalFileName'),
fields: captureAny(named: 'fields'),
cancelToken: any(named: 'cancelToken'),
onProgress: any(named: 'onProgress'),
logContext: 'asset[edited-1]',
),
).captured.single as Map<String, String>;
expect(captured['stackParentId'], 'base-remote');
verify(
() => mockLocalAsset.markSynced(
'edited-1',
priorRemoteId: 'edit-remote',
syncedChecksum: 'edited-sha1',
),
).called(1);
});
});
}
+5
View File
@@ -4,11 +4,14 @@ import 'package:mocktail/mocktail.dart' as mocktail;
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class UnitMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final stack = MockDriftStackRepository();
final assetApi = MockAssetApiRepository();
final nativeApi = MockNativeSyncApi();
@@ -31,6 +34,8 @@ class UnitMocks {
mocktail.reset(localAlbum);
mocktail.reset(localAsset);
mocktail.reset(trashedAsset);
mocktail.reset(stack);
mocktail.reset(assetApi);
mocktail.reset(nativeApi);
}
}
@@ -0,0 +1,145 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/edit_pair.dart';
import 'package:mocktail/mocktail.dart';
import '../mocks.dart';
void main() {
final mocks = UnitMocks();
// createdAt fixed; adjustmentTime is what moves a real edit past the gate.
LocalAsset asset({DateTime? adjustmentTime, String? priorRemoteId, String? checksum = 'local-sha1'}) => LocalAsset(
id: 'local-1',
name: 'photo.jpg',
type: AssetType.image,
createdAt: DateTime(2025, 1, 1, 12),
updatedAt: DateTime(2025, 1, 1, 12),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
adjustmentTime: adjustmentTime,
priorRemoteId: priorRemoteId,
checksum: checksum,
);
BaseResource base(String sha1) => BaseResource(path: '/tmp/none', sha1: sha1, sizeBytes: 1, mimeType: 'image/jpeg');
void stubBase(BaseResource? result) {
when(
() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => result);
}
Future<EditPairPlan> resolve(LocalAsset asset) =>
resolveEditPair(mocks.nativeApi, asset, stackRepository: mocks.stack);
setUp(() {
// Default: the prior remote is alive, so absorb is allowed.
when(() => mocks.stack.isRemoteTrashed(any())).thenAnswer((_) async => false);
});
tearDown(() {
mocks.reset();
});
group('resolveEditPair', () {
test('reuses the prior remote when the asset was already uploaded as an edit', () async {
final plan = await resolve(asset(priorRemoteId: 'remote-edit'));
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-edit'));
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('uploads the base instead of absorbing when the prior remote was trashed', () async {
when(() => mocks.stack.isRemoteTrashed('remote-edit')).thenAnswer((_) async => true);
stubBase(base('different-sha1'));
final plan = await resolve(asset(priorRemoteId: 'remote-edit', adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<UploadBaseFirst>());
});
test('does not absorb a trashed prior even when the asset reads as not edited', () async {
when(() => mocks.stack.isRemoteTrashed('remote-edit')).thenAnswer((_) async => true);
// Trashed prior + no adjustment → falls through to the gate, which skips.
final plan = await resolve(asset(priorRemoteId: 'remote-edit', adjustmentTime: null));
expect(plan, isA<NoEditPair>());
});
test('absorbs the prior when the trashed check itself fails (cheap-path safety)', () async {
when(() => mocks.stack.isRemoteTrashed('remote-edit')).thenThrow(Exception('db error'));
final plan = await resolve(asset(priorRemoteId: 'remote-edit'));
expect(plan, isA<AbsorbIntoPrior>().having((p) => p.parentId, 'parentId', 'remote-edit'));
});
test('skips a photo that was never adjusted without touching native', () async {
final plan = await resolve(asset(adjustmentTime: null));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips a capture-time style (adjustment within the 2s window)', () async {
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 1)));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('skips at exactly the 2s boundary (tolerance is exclusive)', () async {
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 2)));
expect(plan, isA<NoEditPair>());
verifyNever(() => mocks.nativeApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('checks the original just past the 2s boundary', () async {
stubBase(base('different-sha1'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 3)));
expect(plan, isA<UploadBaseFirst>());
verify(() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: true)).called(1);
});
test('uploads the original first when a real edit moved the timestamp', () async {
stubBase(base('different-sha1'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<UploadBaseFirst>());
verify(() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: true)).called(1);
});
test('skips when the original cannot be read (offloaded to iCloud)', () async {
stubBase(null);
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<NoEditPair>());
});
test('skips when the original bytes match the asset (auto-HDR, nothing to stack)', () async {
stubBase(base('local-sha1'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<NoEditPair>());
});
test('skips when reading the original throws', () async {
when(
() => mocks.nativeApi.getBaseResource('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenThrow(Exception('boom'));
final plan = await resolve(asset(adjustmentTime: DateTime(2025, 1, 1, 12, 0, 30)));
expect(plan, isA<NoEditPair>());
});
});
}
@@ -0,0 +1,117 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
import '../mocks.dart';
void main() {
late EditRevertService sut;
final mocks = UnitMocks();
LocalAsset asset({String? priorRemoteId, String? checksum = 'reverted-sha1'}) => LocalAsset(
id: 'local-1',
name: 'photo.jpg',
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
priorRemoteId: priorRemoteId,
checksum: checksum,
);
setUp(() {
sut = EditRevertService(
nativeSyncApi: mocks.nativeApi,
stackRepository: mocks.stack,
localAssetRepository: mocks.localAsset,
assetApiRepository: mocks.assetApi,
);
});
tearDown(() {
mocks.reset();
});
group('tryHandleRevert', () {
test('returns false when the asset was never uploaded as an edit', () async {
expect(await sut.tryHandleRevert(asset(priorRemoteId: null)), isFalse);
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('returns false (lets the pair flow run) when there is still a live edit', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.edited);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the edit state cannot be read (offloaded to iCloud)', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.unknown);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the prior remote is not in a stack', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('returns false when the stack has no base member to flip back to', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('flips the primary back to the base via prior_remote_id and keeps the edit (no trash)', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(
() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit'),
).thenAnswer((_) async => 'remote-base');
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isTrue);
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
verify(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).called(1);
// Nothing is trashed or unstacked; every edit stays in the stack.
verifyNever(() => mocks.assetApi.delete(any(), any()));
verifyNever(() => mocks.assetApi.unStack(any()));
});
});
}
@@ -1,6 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
@@ -18,6 +20,8 @@ void main() {
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
@@ -110,6 +114,8 @@ void main() {
nativeSyncApi: mocks.nativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
);
final album = LocalAlbumFactory.create();
@@ -183,5 +189,61 @@ void main() {
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
group('iOS revert reconcile', () {
test('flips the stack primary for a non-styled revert that re-hashed to the base', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
registerFallbackValue(<String>[]);
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
const target = StackReconcileTarget(
stackId: 'stack-1',
newPrimaryId: 'base-1',
localAssetId: 'local-1',
localAssetChecksum: 'reverted-sha1',
);
when(() => mocks.stack.findRevertReconcileTargets(any())).thenAnswer((_) async => [target]);
when(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
when(() => mocks.stack.setPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
when(
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
).thenAnswer((_) async {});
await sut.hashAssets();
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).called(1);
verify(() => mocks.stack.setPrimary('stack-1', 'base-1')).called(1);
verify(
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
).called(1);
});
test('does not reconcile on a non-iOS platform', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
registerFallbackValue(<String>[]);
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.trashedAsset.getAssetsToHash(any())).thenAnswer((_) async => []);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
await sut.hashAssets();
verifyNever(() => mocks.stack.findRevertReconcileTargets(any()));
});
});
});
}
+66
View File
@@ -88,5 +88,71 @@ void main() {
expect(version2.minor, 2);
expect(version2.patch, 3);
});
test('Orders later prerelease above earlier prerelease', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc2 > rc1, isTrue);
expect(rc1 < rc2, isTrue);
expect(rc1 == rc2, isFalse);
});
test('Final release outranks its prerelease of the same version', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const release = SemVer(major: 1, minor: 151, patch: 0);
expect(release > rc, isTrue);
expect(rc < release, isTrue);
});
test('Higher major outranks a prerelease regardless of ordinal', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 9);
const next = SemVer(major: 2, minor: 0, patch: 0);
expect(next > rc, isTrue);
});
test('Equal prerelease versions compare as equal', () {
const a = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
const b = SemVer(major: 1, minor: 151, patch: 0, prerelease: 3);
expect(a == b, isTrue);
expect(a > b, isFalse);
expect(a < b, isFalse);
});
test('Reports prerelease difference type', () {
const rc1 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 1);
const rc2 = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc1.differenceType(rc2), SemVerType.prerelease);
});
test('toString includes prerelease suffix when present', () {
const rc = SemVer(major: 1, minor: 151, patch: 0, prerelease: 2);
expect(rc.toString(), '1.151.0-rc.2');
});
test('Parses prerelease ordinal from -rc strings', () {
final dotted = SemVer.fromString('1.151.0-rc.2');
expect(dotted.major, 1);
expect(dotted.minor, 151);
expect(dotted.patch, 0);
expect(dotted.prerelease, 2);
expect(SemVer.fromString('v1.151.0-rc.3').prerelease, 3);
expect(SemVer.fromString('1.2.3-rc.2+build.5').prerelease, 2);
});
test('Plain version string has null prerelease', () {
expect(SemVer.fromString('3.0.0').prerelease, isNull);
});
test('Invalid rc suffixes parse without error and have null prerelease', () {
final debug = SemVer.fromString('1.2.3-debug');
expect(debug.major, 1);
expect(debug.minor, 2);
expect(debug.patch, 3);
expect(debug.prerelease, isNull);
expect(SemVer.fromString('1.2.3+build.5').prerelease, isNull);
expect(SemVer.fromString('1.151.0-rc4').prerelease, isNull);
});
});
}
+446 -4
View File
@@ -4308,6 +4308,351 @@
"x-immich-state": "Stable"
}
},
"/assets/{id}/video/stream/main.m3u8": {
"get": {
"description": "Returns an HLS main playlist with all available variants for the asset.",
"operationId": "getMainPlaylist",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/vnd.apple.mpegurl": {
"schema": {
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get HLS main playlist",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/assets/{id}/video/stream/{sessionId}": {
"delete": {
"description": "Releases server resources for the streaming session.",
"operationId": "endSession",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sessionId",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "End HLS streaming session",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8": {
"get": {
"description": "Returns an HLS media playlist for one variant of the streaming session.",
"operationId": "getMediaPlaylist",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sessionId",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "variantIndex",
"required": true,
"in": "path",
"schema": {
"minimum": 0,
"maximum": 9007199254740991,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/vnd.apple.mpegurl": {
"schema": {
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get HLS media playlist",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}": {
"get": {
"description": "Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).",
"operationId": "getSegment",
"parameters": [
{
"name": "filename",
"required": true,
"in": "path",
"schema": {
"pattern": "^(init\\.mp4|seg_\\d+\\.m4s)$",
"type": "string"
}
},
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "sessionId",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "variantIndex",
"required": true,
"in": "path",
"schema": {
"minimum": 0,
"maximum": 9007199254740991,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get HLS segment or init file",
"tags": [
"Assets"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Alpha"
}
],
"x-immich-permission": "asset.view",
"x-immich-state": "Alpha"
}
},
"/auth/admin-sign-up": {
"post": {
"description": "Create the first admin user in the system.",
@@ -16554,6 +16899,12 @@
"format": "binary",
"type": "string"
},
"stackParentId": {
"description": "Stack this asset onto the parent asset, with the new asset as the stack primary",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"visibility": {
"$ref": "#/components/schemas/AssetVisibility"
}
@@ -18146,6 +18497,7 @@
"LibrarySyncFilesQueueAll",
"LibrarySyncFiles",
"LibraryScanQueueAll",
"HlsSessionCleanup",
"MemoryCleanup",
"MemoryGenerate",
"NotificationsCleanup",
@@ -20828,6 +21180,57 @@
],
"type": "string"
},
"ReleaseChannel": {
"description": "Release channel",
"enum": [
"stable",
"releaseCandidate"
],
"type": "string"
},
"ReleaseEventV1": {
"properties": {
"checkedAt": {
"description": "When the server last checked for a latest version. As an ISO timestamp",
"type": "string"
},
"isAvailable": {
"description": "Whether a new version is available",
"type": "boolean"
},
"releaseVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"serverVersion": {
"$ref": "#/components/schemas/ServerVersionResponseDto"
},
"type": {
"$ref": "#/components/schemas/ReleaseType",
"description": "Release type",
"nullable": true
}
},
"required": [
"checkedAt",
"isAvailable",
"releaseVersion",
"serverVersion",
"type"
],
"type": "object"
},
"ReleaseType": {
"enum": [
"major",
"premajor",
"minor",
"preminor",
"patch",
"prepatch",
"prerelease"
],
"type": "string"
},
"ReverseGeocodingStateResponseDto": {
"properties": {
"lastImportFileName": {
@@ -21285,6 +21688,10 @@
"description": "Whether password login is enabled",
"type": "boolean"
},
"realtimeTranscoding": {
"description": "Whether real-time transcoding is enabled",
"type": "boolean"
},
"reverseGeocoding": {
"description": "Whether reverse geocoding is enabled",
"type": "boolean"
@@ -21317,6 +21724,7 @@
"oauthAutoLaunch",
"ocr",
"passwordLogin",
"realtimeTranscoding",
"reverseGeocoding",
"search",
"sidecar",
@@ -21497,26 +21905,40 @@
"major": {
"description": "Major version number",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"minimum": 0,
"type": "integer"
},
"minor": {
"description": "Minor version number",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"minimum": 0,
"type": "integer"
},
"patch": {
"description": "Patch version number",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"minimum": 0,
"type": "integer"
},
"prerelease": {
"description": "Pre-release version number",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer",
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
}
]
}
},
"required": [
"major",
"minor",
"patch"
"patch",
"prerelease"
],
"type": "object"
},
@@ -24177,6 +24599,9 @@
"description": "Preset",
"type": "string"
},
"realtime": {
"$ref": "#/components/schemas/SystemConfigFFmpegRealtimeDto"
},
"refs": {
"description": "References",
"maximum": 6,
@@ -24227,6 +24652,7 @@
"maxBitrate",
"preferredHwDevice",
"preset",
"realtime",
"refs",
"targetAudioCodec",
"targetResolution",
@@ -24239,6 +24665,18 @@
],
"type": "object"
},
"SystemConfigFFmpegRealtimeDto": {
"properties": {
"enabled": {
"description": "Enable real-time HLS transcoding (alpha)",
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"SystemConfigFacesDto": {
"properties": {
"import": {
@@ -24537,12 +24975,16 @@
},
"SystemConfigNewVersionCheckDto": {
"properties": {
"channel": {
"$ref": "#/components/schemas/ReleaseChannel"
},
"enabled": {
"description": "Enabled",
"type": "boolean"
}
},
"required": [
"channel",
"enabled"
],
"type": "object"
-1
View File
@@ -232,7 +232,6 @@
"albumName": {
"type": "string",
"title": "Album name",
"array": true,
"description": "Use an album with this name if one exists, otherwise create a new one"
}
},
+112
View File
@@ -630,6 +630,8 @@ export type AssetMediaCreateDto = {
metadata?: AssetMetadataUpsertItemDto[];
/** Sidecar file data */
sidecarData?: Blob;
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
stackParentId?: string;
visibility?: AssetVisibility;
};
export type AssetMediaResponseDto = {
@@ -1993,6 +1995,8 @@ export type ServerFeaturesDto = {
ocr: boolean;
/** Whether password login is enabled */
passwordLogin: boolean;
/** Whether real-time transcoding is enabled */
realtimeTranscoding: boolean;
/** Whether reverse geocoding is enabled */
reverseGeocoding: boolean;
/** Whether search is enabled */
@@ -2076,6 +2080,8 @@ export type ServerVersionResponseDto = {
minor: number;
/** Patch version number */
patch: number;
/** Pre-release version number */
prerelease: number | null;
};
export type VersionCheckStateResponseDto = {
/** Last check timestamp */
@@ -2251,6 +2257,10 @@ export type DatabaseBackupConfig = {
export type SystemConfigBackupsDto = {
database: DatabaseBackupConfig;
};
export type SystemConfigFFmpegRealtimeDto = {
/** Enable real-time HLS transcoding (alpha) */
enabled: boolean;
};
export type SystemConfigFFmpegDto = {
accel: TranscodeHWAccel;
/** Accelerated decode */
@@ -2274,6 +2284,7 @@ export type SystemConfigFFmpegDto = {
preferredHwDevice: string;
/** Preset */
preset: string;
realtime: SystemConfigFFmpegRealtimeDto;
/** References */
refs: number;
targetAudioCodec: AudioCodec;
@@ -2423,6 +2434,7 @@ export type SystemConfigMetadataDto = {
faces: SystemConfigFacesDto;
};
export type SystemConfigNewVersionCheckDto = {
channel: ReleaseChannel;
/** Enabled */
enabled: boolean;
};
@@ -2768,6 +2780,16 @@ export type WorkflowShareResponseDto = {
trigger: WorkflowTrigger;
};
export type LicenseResponseDto = UserLicense;
export type ReleaseEventV1 = {
/** When the server last checked for a latest version. As an ISO timestamp */
checkedAt: string;
/** Whether a new version is available */
isAvailable: boolean;
releaseVersion: ServerVersionResponseDto;
serverVersion: ServerVersionResponseDto;
/** Release type */
"type": ReleaseType;
};
export type SyncAckV1 = {};
export type SyncAlbumDeleteV1 = {
/** Album ID */
@@ -4212,6 +4234,82 @@ export function playAssetVideo({ id, key, slug }: {
...opts
}));
}
/**
* Get HLS main playlist
*/
export function getMainPlaylist({ id, key, slug }: {
id: string;
key?: string;
slug?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: string;
}>(`/assets/${encodeURIComponent(id)}/video/stream/main.m3u8${QS.query(QS.explode({
key,
slug
}))}`, {
...opts
}));
}
/**
* End HLS streaming session
*/
export function endSession({ id, key, sessionId, slug }: {
id: string;
key?: string;
sessionId: string;
slug?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}${QS.query(QS.explode({
key,
slug
}))}`, {
...opts,
method: "DELETE"
}));
}
/**
* Get HLS media playlist
*/
export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex }: {
id: string;
key?: string;
sessionId: string;
slug?: string;
variantIndex: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: string;
}>(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}/${encodeURIComponent(variantIndex)}/playlist.m3u8${QS.query(QS.explode({
key,
slug
}))}`, {
...opts
}));
}
/**
* Get HLS segment or init file
*/
export function getSegment({ filename, id, key, sessionId, slug, variantIndex }: {
filename: string;
id: string;
key?: string;
sessionId: string;
slug?: string;
variantIndex: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchBlob<{
status: 200;
data: Blob;
}>(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}/${encodeURIComponent(variantIndex)}/${encodeURIComponent(filename)}${QS.query(QS.explode({
key,
slug
}))}`, {
...opts
}));
}
/**
* Register admin
*/
@@ -7122,6 +7220,7 @@ export enum JobName {
LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll",
LibrarySyncFiles = "LibrarySyncFiles",
LibraryScanQueueAll = "LibraryScanQueueAll",
HlsSessionCleanup = "HlsSessionCleanup",
MemoryCleanup = "MemoryCleanup",
MemoryGenerate = "MemoryGenerate",
NotificationsCleanup = "NotificationsCleanup",
@@ -7312,6 +7411,10 @@ export enum LogLevel {
Error = "error",
Fatal = "fatal"
}
export enum ReleaseChannel {
Stable = "stable",
ReleaseCandidate = "releaseCandidate"
}
export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
@@ -7320,6 +7423,15 @@ export enum AssetOrderBy {
TakenAt = "takenAt",
CreatedAt = "createdAt"
}
export enum ReleaseType {
Major = "major",
Premajor = "premajor",
Minor = "minor",
Preminor = "preminor",
Patch = "patch",
Prepatch = "prepatch",
Prerelease = "prerelease"
}
export enum UserMetadataKey {
Preferences = "preferences",
License = "license",
+99 -68
View File
@@ -577,8 +577,8 @@ importers:
specifier: ^1.6.3
version: 1.6.4
semver:
specifier: ^7.6.2
version: 7.8.0
specifier: ^7.8.1
version: 7.8.1
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -615,7 +615,7 @@ importers:
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.23))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
@@ -624,7 +624,7 @@ importers:
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
'@swc/core':
specifier: ^1.4.14
version: 1.15.33(@swc/helpers@0.5.22)
version: 1.15.33(@swc/helpers@0.5.23)
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
@@ -744,7 +744,7 @@ importers:
version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
unplugin-swc:
specifier: ^1.4.5
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4)
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.23))(rollup@4.60.4)
vite-tsconfig-paths:
specifier: ^6.0.0
version: 6.1.1(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))
@@ -765,7 +765,7 @@ importers:
version: link:../packages/sdk
'@immich/ui':
specifier: ^0.79.2
version: 0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
version: 0.79.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.4.0
version: 0.4.0
@@ -820,6 +820,12 @@ importers:
happy-dom:
specifier: ^20.0.0
version: 20.9.0
hls-video-element:
specifier: ^1.5.11
version: 1.5.11
hls.js:
specifier: ^1.6.16
version: 1.6.16
intl-messageformat:
specifier: ^11.0.0
version: 11.2.6
@@ -1693,10 +1699,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
@@ -3214,8 +3216,8 @@ packages:
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
hasBin: true
'@immich/ui@0.79.2':
resolution: {integrity: sha512-tnpYhYHrjrFJK18QglRMzPUtHv6q5V6tW38HiAraQJBv7MCg+yaJDrdF8omM2L5F311FGlv1PZLJYvmR4e49PA==}
'@immich/ui@0.79.3':
resolution: {integrity: sha512-QO2gLcmAIVLvcv3eb6RZ5kDahmVMDZlfE2RfbpyRKNI1wTjvTLP5ibIsofY0fXxLf44cN3vcjmz16CXS8bvVWQ==}
peerDependencies:
'@sveltejs/kit': ^2.13.0
svelte: ^5.0.0
@@ -3363,8 +3365,8 @@ packages:
'@types/node':
optional: true
'@internationalized/date@3.12.1':
resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==}
'@internationalized/date@3.12.2':
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
@@ -4993,8 +4995,8 @@ packages:
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.22':
resolution: {integrity: sha512-/e2Ly3Docn9kYByap6TV4oquJ3wQuz3c+kC74riqtkwU9CwTMeuj6t2rW+bRr4pyOx/CYQM4wr0RgaKQwGEz0A==}
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@swc/types@0.1.26':
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
@@ -6947,6 +6949,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
custom-media-element@1.4.6:
resolution: {integrity: sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==}
cytoscape-cose-bilkent@4.1.0:
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
peerDependencies:
@@ -8271,6 +8276,12 @@ packages:
history@4.10.1:
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
hls-video-element@1.5.11:
resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hogan.js@3.0.2:
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
hasBin: true
@@ -9275,6 +9286,9 @@ packages:
media-chrome@4.19.0:
resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
media-tracks@0.3.5:
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -11250,6 +11264,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'}
hasBin: true
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
@@ -13796,8 +13815,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/runtime@7.29.2': {}
'@babel/runtime@7.29.7': {}
'@babel/template@7.28.6':
@@ -14241,7 +14258,7 @@ snapshots:
'@babel/preset-env': 7.29.5(@babel/core@7.29.0)
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
'@babel/traverse': 7.29.0
'@docusaurus/logger': 3.10.1
'@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -14275,7 +14292,7 @@ snapshots:
'@babel/preset-env': 7.29.5(@babel/core@7.29.0)
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
'@babel/traverse': 7.29.0
'@docusaurus/logger': 3.10.1
'@docusaurus/utils': 3.10.1(postcss@8.5.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -14381,7 +14398,7 @@ snapshots:
react-router: 5.3.4(react@19.2.6)
react-router-config: 5.1.1(react-router@5.3.4(react@19.2.6))(react@19.2.6)
react-router-dom: 5.3.4(react@19.2.6)
semver: 7.8.0
semver: 7.8.1
serve-handler: 6.1.7
tinypool: 1.1.1
tslib: 2.8.1
@@ -15892,12 +15909,12 @@ snapshots:
pg-connection-string: 2.13.0
postgres: 3.4.9
'@immich/ui@0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
'@immich/ui@0.79.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
dependencies:
'@internationalized/date': 3.12.1
'@internationalized/date': 3.12.2
'@mdi/js': 7.4.47
'@sveltejs/kit': 2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))
bits-ui: 2.18.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
bits-ui: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
luxon: 3.7.2
simple-icons: 16.20.0
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -16046,9 +16063,9 @@ snapshots:
optionalDependencies:
'@types/node': 24.12.4
'@internationalized/date@3.12.1':
'@internationalized/date@3.12.2':
dependencies:
'@swc/helpers': 0.5.22
'@swc/helpers': 0.5.23
'@ioredis/commands@1.5.1': {}
@@ -16307,7 +16324,7 @@ snapshots:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.8.0
semver: 7.8.1
tar: 6.2.1
transitivePeerDependencies:
- encoding
@@ -16451,7 +16468,7 @@ snapshots:
bullmq: 5.76.10
tslib: 2.8.1
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.23))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
@@ -16462,17 +16479,17 @@ snapshots:
chokidar: 4.0.3
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0))
glob: 13.0.6
node-emoji: 1.11.0
ora: 5.4.1
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack-node-externals: 3.0.0
optionalDependencies:
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
'@swc/core': 1.15.33(@swc/helpers@0.5.23)
transitivePeerDependencies:
- '@minify-html/node'
- '@swc/css'
@@ -17661,7 +17678,7 @@ snapshots:
'@swc/core-win32-x64-msvc@1.15.33':
optional: true
'@swc/core@1.15.33(@swc/helpers@0.5.22)':
'@swc/core@1.15.33(@swc/helpers@0.5.23)':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.26
@@ -17678,11 +17695,11 @@ snapshots:
'@swc/core-win32-arm64-msvc': 1.15.33
'@swc/core-win32-ia32-msvc': 1.15.33
'@swc/core-win32-x64-msvc': 1.15.33
'@swc/helpers': 0.5.22
'@swc/helpers': 0.5.23
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.22':
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@@ -17765,7 +17782,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
@@ -18469,7 +18486,7 @@ snapshots:
'@typescript-eslint/visitor-keys': 8.59.4
debug: 4.4.3
minimatch: 10.2.5
semver: 7.8.0
semver: 7.8.1
tinyglobby: 0.2.16
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
@@ -19087,11 +19104,11 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.18.1(@internationalized/date@3.12.1)(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4)):
bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4)):
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/dom': 1.7.6
'@internationalized/date': 3.12.1
'@internationalized/date': 3.12.2
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -19569,7 +19586,7 @@ snapshots:
dot-prop: 10.1.0
env-paths: 3.0.0
json-schema-typed: 8.0.2
semver: 7.8.0
semver: 7.8.1
uint8array-extras: 1.5.0
config-chain@1.1.13:
@@ -19741,7 +19758,7 @@ snapshots:
postcss-modules-scope: 3.2.1(postcss@8.5.15)
postcss-modules-values: 4.0.0(postcss@8.5.15)
postcss-value-parser: 4.2.0
semver: 7.8.0
semver: 7.8.1
optionalDependencies:
webpack: 5.107.0(postcss@8.5.15)
@@ -19864,6 +19881,8 @@ snapshots:
csstype@3.2.3: {}
custom-media-element@1.4.6: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.4):
dependencies:
cose-base: 1.0.3
@@ -20609,7 +20628,7 @@ snapshots:
find-up: 5.0.0
globals: 15.15.0
lodash.memoize: 4.1.2
semver: 7.8.0
semver: 7.8.1
eslint-plugin-prettier@5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(prettier@3.8.3):
dependencies:
@@ -20632,7 +20651,7 @@ snapshots:
postcss: 8.5.15
postcss-load-config: 3.1.4(postcss@8.5.15)
postcss-safe-parser: 7.0.1(postcss@8.5.15)
semver: 7.8.0
semver: 7.8.1
svelte-eslint-parser: 1.6.1(svelte@5.55.8(@typescript-eslint/types@8.59.4))
optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -20656,7 +20675,7 @@ snapshots:
pluralize: 8.0.0
regexp-tree: 0.1.27
regjsparser: 0.13.1
semver: 7.8.0
semver: 7.8.1
strip-indent: 4.1.1
eslint-scope@5.1.1:
@@ -21098,7 +21117,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0)):
dependencies:
'@babel/code-frame': 7.29.0
chalk: 4.1.2
@@ -21110,10 +21129,10 @@ snapshots:
minimatch: 3.1.5
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.8.0
semver: 7.8.1
tapable: 2.3.3
typescript: 5.9.3
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0)
form-data-encoder@2.1.4: {}
@@ -21546,13 +21565,21 @@ snapshots:
history@4.10.1:
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
loose-envify: 1.4.0
resolve-pathname: 3.0.0
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
value-equal: 1.0.1
hls-video-element@1.5.11:
dependencies:
custom-media-element: 1.4.6
hls.js: 1.6.16
media-tracks: 0.3.5
hls.js@1.6.16: {}
hogan.js@3.0.2:
dependencies:
mkdirp: 0.3.0
@@ -22134,7 +22161,7 @@ snapshots:
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.0
semver: 7.8.1
just-compare@2.3.0: {}
@@ -22420,7 +22447,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
semver: 7.8.0
semver: 7.8.1
maplibre-gl@5.24.0:
dependencies:
@@ -22656,6 +22683,8 @@ snapshots:
transitivePeerDependencies:
- react
media-tracks@0.3.5: {}
media-typer@0.3.0: {}
media-typer@1.1.0: {}
@@ -23255,7 +23284,7 @@ snapshots:
node-abi@3.92.0:
dependencies:
semver: 7.8.0
semver: 7.8.1
optional: true
node-abort-controller@3.1.1: {}
@@ -23296,7 +23325,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.0
semver: 7.8.1
tar: 7.5.15
tinyglobby: 0.2.16
undici: 6.25.0
@@ -23534,7 +23563,7 @@ snapshots:
got: 12.6.1
registry-auth-token: 5.1.1
registry-url: 6.0.1
semver: 7.8.0
semver: 7.8.1
package-manager-detector@1.6.0: {}
@@ -23922,7 +23951,7 @@ snapshots:
cosmiconfig: 8.3.6(typescript@6.0.3)
jiti: 1.21.7
postcss: 8.5.15
semver: 7.8.0
semver: 7.8.1
webpack: 5.107.0(postcss@8.5.15)
transitivePeerDependencies:
- typescript
@@ -24486,19 +24515,19 @@ snapshots:
react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.6))(webpack@5.107.0(postcss@8.5.15)):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.6)'
webpack: 5.107.0(postcss@8.5.15)
react-router-config@5.1.1(react-router@5.3.4(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
react: 19.2.6
react-router: 5.3.4(react@19.2.6)
react-router-dom@5.3.4(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
history: 4.10.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -24509,7 +24538,7 @@ snapshots:
react-router@5.3.4(react@19.2.6):
dependencies:
'@babel/runtime': 7.29.2
'@babel/runtime': 7.29.7
history: 4.10.1
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@@ -24977,12 +25006,14 @@ snapshots:
semver-diff@4.0.0:
dependencies:
semver: 7.8.0
semver: 7.8.1
semver@6.3.1: {}
semver@7.8.0: {}
semver@7.8.1: {}
send@0.19.2:
dependencies:
debug: 2.6.9
@@ -25097,7 +25128,7 @@ snapshots:
detect-libc: 2.1.2
node-addon-api: 8.7.0
node-gyp: 12.3.0
semver: 7.8.0
semver: 7.8.1
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
@@ -25517,7 +25548,7 @@ snapshots:
postcss: 8.5.15
postcss-scss: 4.0.9(postcss@8.5.15)
postcss-selector-parser: 7.1.1
semver: 7.8.0
semver: 7.8.1
optionalDependencies:
svelte: 5.55.8(@typescript-eslint/types@8.59.4)
@@ -25775,15 +25806,15 @@ snapshots:
- bare-abort-controller
- react-native-b4a
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.47.1
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0)
optionalDependencies:
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
'@swc/core': 1.15.33(@swc/helpers@0.5.23)
esbuild: 0.28.0
lightningcss: 1.32.0
@@ -26190,10 +26221,10 @@ snapshots:
unpipe@1.0.0: {}
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4):
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.23))(rollup@4.60.4):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.4)
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
'@swc/core': 1.15.33(@swc/helpers@0.5.23)
load-tsconfig: 0.2.5
unplugin: 2.3.11
transitivePeerDependencies:
@@ -26225,7 +26256,7 @@ snapshots:
is-yarn-global: 0.4.1
latest-version: 7.0.0
pupa: 3.3.0
semver: 7.8.0
semver: 7.8.1
semver-diff: 4.0.0
xdg-basedir: 5.1.0
@@ -26592,7 +26623,7 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0):
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -26616,7 +26647,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.23))(esbuild@0.28.0)(lightningcss@1.32.0))
watchpack: 2.5.1
webpack-sources: 3.4.1
transitivePeerDependencies:
+1 -1
View File
@@ -56,7 +56,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
+1 -1
View File
@@ -2,7 +2,7 @@
FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
+1 -1
View File
@@ -106,7 +106,7 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.6.2",
"semver": "^7.8.1",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
+9
View File
@@ -1,4 +1,5 @@
import { CronExpression } from '@nestjs/schedule';
import { ReleaseChannel } from 'src/dtos/system-config.dto';
import {
AudioCodec,
Colorspace,
@@ -45,6 +46,9 @@ export type SystemConfig = {
accel: TranscodeHardwareAcceleration;
accelDecode: boolean;
tonemap: ToneMapping;
realtime: {
enabled: boolean;
};
};
job: Record<ConcurrentQueueName, { concurrency: number }>;
logging: {
@@ -135,6 +139,7 @@ export type SystemConfig = {
};
newVersionCheck: {
enabled: boolean;
channel: ReleaseChannel;
};
nightlyTasks: {
startTime: string;
@@ -224,6 +229,9 @@ export const defaults = Object.freeze<SystemConfig>({
tonemap: ToneMapping.Hable,
accel: TranscodeHardwareAcceleration.Disabled,
accelDecode: true,
realtime: {
enabled: false,
},
},
job: {
[QueueName.BackgroundTask]: { concurrency: 5 },
@@ -344,6 +352,7 @@ export const defaults = Object.freeze<SystemConfig>({
},
newVersionCheck: {
enabled: true,
channel: ReleaseChannel.Stable,
},
nightlyTasks: {
startTime: '00:00',
+38 -1
View File
@@ -1,7 +1,15 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
import {
ApiTag,
AudioCodec,
DatabaseExtension,
ExifOrientation,
TranscodeHardwareAcceleration,
VectorIndex,
VideoCodec,
} from 'src/enum';
export const IMMICH_SERVER_START = 'Immich Server is listening';
@@ -202,3 +210,32 @@ export const AUDIO_ENCODER: Record<AudioCodec, string> = {
[AudioCodec.Opus]: 'libopus',
[AudioCodec.PcmS16le]: 'pcm_s16le',
};
export const SUPPORTED_HWA_CODECS: Record<TranscodeHardwareAcceleration, VideoCodec[]> = {
[TranscodeHardwareAcceleration.Nvenc]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Av1],
[TranscodeHardwareAcceleration.Qsv]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
[TranscodeHardwareAcceleration.Vaapi]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
[TranscodeHardwareAcceleration.Rkmpp]: [VideoCodec.H264, VideoCodec.Hevc],
[TranscodeHardwareAcceleration.Disabled]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
};
export const HLS_BACKPRESSURE_PAUSE_SEGMENTS = 30;
export const HLS_BACKPRESSURE_RESUME_SEGMENTS = 15;
export const HLS_CLEANUP_INTERVAL_MS = 60 * 1000;
export const HLS_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
export const HLS_LEASE_DURATION_MS = 30 * 60 * 1000;
export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
export const HLS_SEGMENT_DURATION = 2;
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
export const HLS_VARIANTS = [
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' },
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' },
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' },
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' },
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' },
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' },
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' },
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' },
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' },
];
export const HLS_VERSION = 7;

Some files were not shown because too many files have changed in this diff Show More