mirror of
https://github.com/immich-app/immich.git
synced 2026-07-10 06:03:14 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a435591662 | |||
| 247fdd87f0 | |||
| f4136f6487 | |||
| 2217d2e679 | |||
| 18fec31c1e | |||
| 57eee0148c | |||
| 880f8e2e4c | |||
| 3f67c400d2 | |||
| 5f5ce12041 | |||
| 1c3266e8f2 | |||
| 912484dcdd |
@@ -103,14 +103,14 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||||
|
|
||||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Restore Gradle Cache
|
- name: Restore Gradle Cache
|
||||||
id: cache-gradle-restore
|
id: cache-gradle-restore
|
||||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -181,7 +181,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Save Gradle Cache
|
- name: Save Gradle Cache
|
||||||
id: cache-gradle-save
|
id: cache-gradle-save
|
||||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -237,7 +237,7 @@ jobs:
|
|||||||
run: flutter build ios --config-only --no-codesign
|
run: flutter build ios --config-only --no-codesign
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
|
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
|
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -75,13 +75,13 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate docker image tags
|
- name: Generate docker image tags
|
||||||
id: metadata
|
id: metadata
|
||||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=false
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
with:
|
with:
|
||||||
file: packages/cli/Dockerfile
|
file: packages/cli/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
suffix: ['']
|
suffix: ['']
|
||||||
steps:
|
steps:
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
<a href="readme_i18n/README_ar_JO.md">العربية</a>
|
||||||
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
|
<a href="readme_i18n/README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="readme_i18n/README_th_TH.md">ภาษาไทย</a>
|
<a href="readme_i18n/README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="readme_i18n/README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,37 +7,30 @@ backend = "aqua:opentofu/opentofu"
|
|||||||
[tools.opentofu."platforms.linux-arm64"]
|
[tools.opentofu."platforms.linux-arm64"]
|
||||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64"]
|
[tools.opentofu."platforms.linux-x64"]
|
||||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64-musl"]
|
[tools.opentofu."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-arm64"]
|
[tools.opentofu."platforms.macos-arm64"]
|
||||||
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-x64"]
|
[tools.opentofu."platforms.macos-x64"]
|
||||||
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.windows-x64"]
|
[tools.opentofu."platforms.windows-x64"]
|
||||||
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
|
|
||||||
|
|
||||||
[[tools.terragrunt]]
|
[[tools.terragrunt]]
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -46,34 +39,27 @@ backend = "aqua:gruntwork-io/terragrunt"
|
|||||||
[tools.terragrunt."platforms.linux-arm64"]
|
[tools.terragrunt."platforms.linux-arm64"]
|
||||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-x64"]
|
[tools.terragrunt."platforms.linux-x64"]
|
||||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.macos-arm64"]
|
[tools.terragrunt."platforms.macos-arm64"]
|
||||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.macos-x64"]
|
[tools.terragrunt."platforms.macos-x64"]
|
||||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.windows-x64"]
|
[tools.terragrunt."platforms.windows-x64"]
|
||||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ You can trigger a database backup manually:
|
|||||||
|
|
||||||
1. Go to **Administration > Job Queues**
|
1. Go to **Administration > Job Queues**
|
||||||
2. Click **Create job** in the top right
|
2. Click **Create job** in the top right
|
||||||
3. Select **Create Database Dump** and click **Confirm**
|
3. Select **Create Database Backup** and click **Confirm**
|
||||||
|
|
||||||
The backup will appear in `UPLOAD_LOCATION/backups` and counts toward your retention limit.
|
The backup will appear in `UPLOAD_LOCATION/backups` and counts toward your retention limit.
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ This environment includes the services below. Additional details are available i
|
|||||||
- Redis
|
- Redis
|
||||||
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it
|
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it
|
||||||
|
|
||||||
All the services are packaged to run with a single Docker Compose command.
|
All the services are packaged to run as with single Docker Compose command.
|
||||||
|
|
||||||
:::tip mise
|
:::tip mise
|
||||||
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
|
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
|
||||||
@@ -41,7 +41,7 @@ All the services are packaged to run with a single Docker Compose command.
|
|||||||
1. Clone the project repo.
|
1. Clone the project repo.
|
||||||
2. Run `cp docker/example.env docker/.env`.
|
2. Run `cp docker/example.env docker/.env`.
|
||||||
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
|
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
|
||||||
4. Install dependencies - `mise x -- pnpm i`
|
4. Install dependencies - `pnpm i`
|
||||||
5. From the root directory, run:
|
5. From the root directory, run:
|
||||||
|
|
||||||
```bash title="Start development server"
|
```bash title="Start development server"
|
||||||
@@ -52,7 +52,7 @@ mise dev
|
|||||||
|
|
||||||
All the services will be started with hot-reloading enabled for a quick feedback loop.
|
All the services will be started with hot-reloading enabled for a quick feedback loop.
|
||||||
|
|
||||||
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000`
|
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000/api`
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
|
|
||||||
|
|||||||
@@ -28,15 +28,11 @@ docker image prune
|
|||||||
|
|
||||||
## Versioning Policy
|
## Versioning Policy
|
||||||
|
|
||||||
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`.
|
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
|
||||||
We intend for breaking changes, including those to the API or deployment, to be limited to major version releases.
|
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
|
||||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`. These metatags do not follow release candidates.
|
|
||||||
|
|
||||||
The mobile app is typically compatible with the current and prior major version. However, the server is only compatible with the matching major version.
|
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
|
||||||
Thus, we recommend upgrading all mobile clients before upgrading the server to ensure compatibility.
|
Switching back to an earlier version, even within the same minor release tag, is not supported.
|
||||||
|
|
||||||
We do not backport patches to earlier versions. We encourage all users to run the most recent stable release of Immich.
|
|
||||||
Downgrading to an earlier version, even within the same minor version, is not supported.
|
|
||||||
|
|
||||||
[semver]: https://semver.org/
|
[semver]: https://semver.org/
|
||||||
|
|
||||||
|
|||||||
@@ -431,10 +431,6 @@
|
|||||||
"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_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": "Enable real-time transcoding",
|
||||||
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
|
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
|
||||||
"transcoding_realtime_resolutions": "Resolutions",
|
|
||||||
"transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
|
|
||||||
"transcoding_realtime_video_codecs": "Video codecs",
|
|
||||||
"transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.",
|
|
||||||
"transcoding_reference_frames": "Reference frames",
|
"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_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",
|
"transcoding_required_description": "Only videos not in an accepted format",
|
||||||
@@ -597,7 +593,6 @@
|
|||||||
"asset_viewer_settings_title": "Asset Viewer",
|
"asset_viewer_settings_title": "Asset Viewer",
|
||||||
"assets": "Assets",
|
"assets": "Assets",
|
||||||
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
||||||
"assets_added_to_album_partial_count": "Added {successCount} out of {totalCount} {totalCount, plural, one {asset} other {assets}} to the album",
|
|
||||||
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
|
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
|
||||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
||||||
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
||||||
@@ -2155,7 +2150,6 @@
|
|||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"unknown_country": "Unknown Country",
|
"unknown_country": "Unknown Country",
|
||||||
"unknown_date": "Unknown date",
|
"unknown_date": "Unknown date",
|
||||||
"unknown_schema": "Unknown schema",
|
|
||||||
"unknown_year": "Unknown Year",
|
"unknown_year": "Unknown Year",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"unlink_motion_video": "Unlink motion video",
|
"unlink_motion_video": "Unlink motion video",
|
||||||
|
|||||||
+14
-21
@@ -5,38 +5,38 @@ version = "3.11.15"
|
|||||||
backend = "core:python"
|
backend = "core:python"
|
||||||
|
|
||||||
[tools.python."platforms.linux-arm64"]
|
[tools.python."platforms.linux-arm64"]
|
||||||
checksum = "sha256:9ac18c9a761e91e6c6452bc0ef0082922a00a3fdec734555635d57c3169309b7"
|
checksum = "sha256:cbce0660e88cd9c56be7aaf2a2df92bea51f359388a521838b6b01817d728df0"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.linux-arm64-musl"]
|
[tools.python."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:bcd414f2e9d8cc5d4ac7fab2c8f912e30379c150fa6b94da19d64daf85cfc5d4"
|
checksum = "sha256:a55ea44225ee3741d4157c383f3d5c3e8eee5f9665e2ea069233486b4275d928"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.linux-x64"]
|
[tools.python."platforms.linux-x64"]
|
||||||
checksum = "sha256:0604cd029b142dc223e131f17f5941c0c8d2d5074997c8178b515b19eea2a6c2"
|
checksum = "sha256:67a5b22f796e96f4d7fa628f95866d5fd1d524d0588f74e4601facd82b66792b"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.linux-x64-musl"]
|
[tools.python."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:45bffdb320cf4a2c2dbf183b634aeb7e28f9cb0646f715b2f4117c11084d839a"
|
checksum = "sha256:5a8544aa4303da3ca4b7505c98dd8453b671157039d25cd551e55abea0f83a60"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.macos-arm64"]
|
[tools.python."platforms.macos-arm64"]
|
||||||
checksum = "sha256:2318799eaf104f8a29bc09a93b0851b05dbbcb4ce9a5f045ddea169c0c7ff3a5"
|
checksum = "sha256:8c56f1f59142e0f9f8861ad897bdfd97fd84403afa7b3d8b0f33b208ec471355"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-apple-darwin-install_only_stripped.tar.gz"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.macos-x64"]
|
[tools.python."platforms.macos-x64"]
|
||||||
checksum = "sha256:4925e5aaa9bc77c85302d350b36c1d9def2002996a6bcfa55c88ba6eb318de29"
|
checksum = "sha256:8cd3878c656ba1698314cbcb65f78df4c37b7c8eabff958558115c6db11adb3d"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-apple-darwin-install_only_stripped.tar.gz"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.python."platforms.windows-x64"]
|
[tools.python."platforms.windows-x64"]
|
||||||
checksum = "sha256:6589ca6d63f520bec4096d62b3ab91da3d0a80b16b594c99a6b677e335814683"
|
checksum = "sha256:f081a733b4e7ba0e5e5e12d533b3c795dbef3ecbebf92f0b4202e5329bf7c8ab"
|
||||||
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[[tools.uv]]
|
[[tools.uv]]
|
||||||
@@ -46,34 +46,27 @@ backend = "aqua:astral-sh/uv"
|
|||||||
[tools.uv."platforms.linux-arm64"]
|
[tools.uv."platforms.linux-arm64"]
|
||||||
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
|
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
|
||||||
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
|
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235419"
|
|
||||||
|
|
||||||
[tools.uv."platforms.linux-arm64-musl"]
|
[tools.uv."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
|
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
|
||||||
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
|
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235419"
|
|
||||||
|
|
||||||
[tools.uv."platforms.linux-x64"]
|
[tools.uv."platforms.linux-x64"]
|
||||||
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
|
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
|
||||||
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
|
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235475"
|
|
||||||
|
|
||||||
[tools.uv."platforms.linux-x64-musl"]
|
[tools.uv."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
|
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
|
||||||
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
|
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235475"
|
|
||||||
|
|
||||||
[tools.uv."platforms.macos-arm64"]
|
[tools.uv."platforms.macos-arm64"]
|
||||||
checksum = "sha256:103367962c5cb00bf7370d84cbaa3fec5a9807be9cc833ea9d8eea400c119fa2"
|
checksum = "sha256:103367962c5cb00bf7370d84cbaa3fec5a9807be9cc833ea9d8eea400c119fa2"
|
||||||
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-apple-darwin.tar.gz"
|
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-apple-darwin.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235412"
|
|
||||||
|
|
||||||
[tools.uv."platforms.macos-x64"]
|
[tools.uv."platforms.macos-x64"]
|
||||||
checksum = "sha256:2bbef70982e97dfc36454de173f35ec1a5e83ae11e3885df6a50db3fd76171cb"
|
checksum = "sha256:2bbef70982e97dfc36454de173f35ec1a5e83ae11e3885df6a50db3fd76171cb"
|
||||||
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-apple-darwin.tar.gz"
|
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-apple-darwin.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235464"
|
|
||||||
|
|
||||||
[tools.uv."platforms.windows-x64"]
|
[tools.uv."platforms.windows-x64"]
|
||||||
checksum = "sha256:459d95892a5cc5c21779532f4f41b9238594b79e312a5142da2148ecfa10e705"
|
checksum = "sha256:459d95892a5cc5c21779532f4f41b9238594b79e312a5142da2148ecfa10e705"
|
||||||
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-pc-windows-msvc.zip"
|
url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-pc-windows-msvc.zip"
|
||||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235467"
|
|
||||||
|
|||||||
@@ -254,37 +254,30 @@ backend = "aqua:opentofu/opentofu"
|
|||||||
[tools.opentofu."platforms.linux-arm64"]
|
[tools.opentofu."platforms.linux-arm64"]
|
||||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-arm64-musl"]
|
[tools.opentofu."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64"]
|
[tools.opentofu."platforms.linux-x64"]
|
||||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.linux-x64-musl"]
|
[tools.opentofu."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-arm64"]
|
[tools.opentofu."platforms.macos-arm64"]
|
||||||
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.macos-x64"]
|
[tools.opentofu."platforms.macos-x64"]
|
||||||
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384"
|
|
||||||
|
|
||||||
[tools.opentofu."platforms.windows-x64"]
|
[tools.opentofu."platforms.windows-x64"]
|
||||||
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
||||||
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
|
|
||||||
|
|
||||||
[[tools.pnpm]]
|
[[tools.pnpm]]
|
||||||
version = "11.6.0"
|
version = "11.6.0"
|
||||||
@@ -293,37 +286,31 @@ backend = "aqua:pnpm/pnpm"
|
|||||||
[tools.pnpm."platforms.linux-arm64"]
|
[tools.pnpm."platforms.linux-arm64"]
|
||||||
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
|
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120174"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
|
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120177"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.linux-x64"]
|
[tools.pnpm."platforms.linux-x64"]
|
||||||
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
|
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120176"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.linux-x64-musl"]
|
[tools.pnpm."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
|
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120175"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.macos-arm64"]
|
[tools.pnpm."platforms.macos-arm64"]
|
||||||
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
|
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz"
|
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120180"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[tools.pnpm."platforms.windows-x64"]
|
[tools.pnpm."platforms.windows-x64"]
|
||||||
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
|
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
|
||||||
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip"
|
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip"
|
||||||
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120173"
|
|
||||||
provenance = "github-attestations"
|
provenance = "github-attestations"
|
||||||
|
|
||||||
[[tools.terragrunt]]
|
[[tools.terragrunt]]
|
||||||
@@ -333,34 +320,27 @@ backend = "aqua:gruntwork-io/terragrunt"
|
|||||||
[tools.terragrunt."platforms.linux-arm64"]
|
[tools.terragrunt."platforms.linux-arm64"]
|
||||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-arm64-musl"]
|
[tools.terragrunt."platforms.linux-arm64-musl"]
|
||||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-x64"]
|
[tools.terragrunt."platforms.linux-x64"]
|
||||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.linux-x64-musl"]
|
[tools.terragrunt."platforms.linux-x64-musl"]
|
||||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.macos-arm64"]
|
[tools.terragrunt."platforms.macos-arm64"]
|
||||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.macos-x64"]
|
[tools.terragrunt."platforms.macos-x64"]
|
||||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970"
|
|
||||||
|
|
||||||
[tools.terragrunt."platforms.windows-x64"]
|
[tools.terragrunt."platforms.windows-x64"]
|
||||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||||
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz"
|
||||||
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412"
|
|
||||||
|
|||||||
@@ -107,67 +107,3 @@ Java_app_alextran_immich_NativeImage_rotate(
|
|||||||
}
|
}
|
||||||
return (jlong) dst;
|
return (jlong) dst;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
|
||||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
|
||||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
|
||||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
|
||||||
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
|
|
||||||
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
|
|
||||||
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
|
||||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
|
||||||
for (int y = 0; y < h; y++) {
|
|
||||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
|
||||||
uint32_t *dstRow = dst + (size_t) y * w;
|
|
||||||
for (int x = 0; x < w; x++) {
|
|
||||||
uint32_t px = srcRow[x];
|
|
||||||
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
|
|
||||||
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
|
|
||||||
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
|
|
||||||
uint32_t a = ((px >> 30) & 0x3) * 85u;
|
|
||||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
|
||||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
|
||||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
|
||||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
|
||||||
JNIEXPORT jlong JNICALL
|
|
||||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
|
||||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
|
||||||
AndroidBitmapInfo info;
|
|
||||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int w = (int) info.width;
|
|
||||||
int h = (int) info.height;
|
|
||||||
|
|
||||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
|
||||||
if (dst == NULL) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void *srcPixels = NULL;
|
|
||||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
|
||||||
free(dst);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
|
||||||
|
|
||||||
AndroidBitmap_unlockPixels(env, bitmap);
|
|
||||||
|
|
||||||
jint dims[3] = {w, h, w * 4};
|
|
||||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
|
||||||
if ((*env)->ExceptionCheck(env)) {
|
|
||||||
free(dst);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return (jlong) dst;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,14 +16,4 @@ object NativeImage {
|
|||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
|
|
||||||
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
|
|
||||||
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
|
|
||||||
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
|
|
||||||
* caller can fall back to a Skia copy.
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,47 +46,22 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||||
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
|
val size = width * height * 4
|
||||||
// colors when copied verbatim. Convert those straight into the output buffer in native code -
|
|
||||||
// one pass, no intermediate ARGB_8888 bitmap.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
|
||||||
val info = IntArray(3)
|
|
||||||
val pointer = NativeImage.convert1010102(this, info)
|
|
||||||
if (pointer != 0L) {
|
|
||||||
recycle()
|
|
||||||
return mapOf(
|
|
||||||
"pointer" to pointer,
|
|
||||||
"width" to info[0].toLong(),
|
|
||||||
"height" to info[1].toLong(),
|
|
||||||
"rowBytes" to info[2].toLong()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
|
|
||||||
}
|
|
||||||
// Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
|
|
||||||
val bitmap = if (config != Bitmap.Config.ARGB_8888) {
|
|
||||||
val converted = copy(Bitmap.Config.ARGB_8888, false)
|
|
||||||
recycle()
|
|
||||||
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
val size = bitmap.width * bitmap.height * 4
|
|
||||||
val pointer = NativeBuffer.allocate(size)
|
val pointer = NativeBuffer.allocate(size)
|
||||||
try {
|
try {
|
||||||
val buffer = NativeBuffer.wrap(pointer, size)
|
val buffer = NativeBuffer.wrap(pointer, size)
|
||||||
bitmap.copyPixelsToBuffer(buffer)
|
copyPixelsToBuffer(buffer)
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"pointer" to pointer,
|
"pointer" to pointer,
|
||||||
"width" to bitmap.width.toLong(),
|
"width" to width.toLong(),
|
||||||
"height" to bitmap.height.toLong(),
|
"height" to height.toLong(),
|
||||||
"rowBytes" to (bitmap.width * 4).toLong()
|
"rowBytes" to (width * 4).toLong()
|
||||||
)
|
)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Exception) {
|
||||||
NativeBuffer.free(pointer)
|
NativeBuffer.free(pointer)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
bitmap.recycle()
|
recycle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package app.alextran.immich.images
|
package app.alextran.immich.images
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.ImageDecoder
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||||
@@ -24,7 +22,6 @@ import java.io.File
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||||
|
|
||||||
@@ -39,16 +36,12 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val CANCELLED = Result.success<Map<String, Long>?>(null)
|
val CANCELLED = Result.success<Map<String, Long>?>(null)
|
||||||
|
|
||||||
// Shared, process-lifetime pool: RemoteImagesImpl is re-created per FlutterEngine, so a
|
|
||||||
// per-instance pool would leak threads across engine restarts.
|
|
||||||
private val decodeExecutor = Executors.newFixedThreadPool(2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun requestImage(
|
override fun requestImage(
|
||||||
url: String,
|
url: String,
|
||||||
requestId: Long,
|
requestId: Long,
|
||||||
preferEncoded: Boolean,
|
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
) {
|
) {
|
||||||
val signal = CancellationSignal()
|
val signal = CancellationSignal()
|
||||||
@@ -58,51 +51,12 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
url,
|
url,
|
||||||
signal,
|
signal,
|
||||||
onSuccess = { buffer ->
|
onSuccess = { buffer ->
|
||||||
|
requestMap.remove(requestId)
|
||||||
if (signal.isCanceled) {
|
if (signal.isCanceled) {
|
||||||
requestMap.remove(requestId)
|
NativeBuffer.free(buffer.pointer)
|
||||||
buffer.free()
|
|
||||||
return@fetch callback(CANCELLED)
|
return@fetch callback(CANCELLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
|
||||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
|
||||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
|
||||||
// decode failure (including OOM on huge originals), hand Flutter the encoded bytes as
|
|
||||||
// before.
|
|
||||||
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
decodeExecutor.execute {
|
|
||||||
val res = if (signal.isCanceled) null else try {
|
|
||||||
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
|
|
||||||
source.decodeBitmap().toNativeBuffer()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
requestMap.remove(requestId)
|
|
||||||
when {
|
|
||||||
// Deliver even if the request was cancelled meanwhile: re-checking here would orphan
|
|
||||||
// res's malloc, and Dart frees the buffer itself when it sees the cancel.
|
|
||||||
res != null -> {
|
|
||||||
buffer.free()
|
|
||||||
callback(Result.success(res))
|
|
||||||
}
|
|
||||||
signal.isCanceled -> {
|
|
||||||
buffer.free()
|
|
||||||
callback(CANCELLED)
|
|
||||||
}
|
|
||||||
else -> callback(
|
|
||||||
Result.success(
|
|
||||||
mapOf(
|
|
||||||
"pointer" to buffer.pointer,
|
|
||||||
"length" to buffer.offset.toLong()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return@fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
requestMap.remove(requestId)
|
|
||||||
callback(
|
callback(
|
||||||
Result.success(
|
Result.success(
|
||||||
mapOf(
|
mapOf(
|
||||||
|
|||||||
-3626
File diff suppressed because it is too large
Load Diff
@@ -70,8 +70,7 @@
|
|||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
enableGPUValidationMode = "1"
|
enableGPUValidationMode = "1"
|
||||||
allowLocationSimulation = "YES"
|
allowLocationSimulation = "YES">
|
||||||
queueDebuggingEnabled = "NO">
|
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
|
|||||||
@@ -1,65 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class CropAspectRatio {
|
enum AspectRatioPreset {
|
||||||
final int? numerator;
|
free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
|
||||||
final int? denominator;
|
square(ratio: 1.0, label: '1:1', icon: Icons.crop_square_rounded),
|
||||||
|
ratio16x9(ratio: 16 / 9, label: '16:9', icon: Icons.crop_16_9_rounded),
|
||||||
|
ratio3x2(ratio: 3 / 2, label: '3:2', icon: Icons.crop_3_2_rounded),
|
||||||
|
ratio7x5(ratio: 7 / 5, label: '7:5', icon: Icons.crop_7_5_rounded),
|
||||||
|
ratio9x16(ratio: 9 / 16, label: '9:16', icon: Icons.crop_16_9_rounded, iconRotated: true),
|
||||||
|
ratio2x3(ratio: 2 / 3, label: '2:3', icon: Icons.crop_3_2_rounded, iconRotated: true),
|
||||||
|
ratio5x7(ratio: 5 / 7, label: '5:7', icon: Icons.crop_7_5_rounded, iconRotated: true);
|
||||||
|
|
||||||
final String? customLabel;
|
final double? ratio;
|
||||||
final IconData? icon;
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final bool iconRotated;
|
||||||
|
|
||||||
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon});
|
const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false});
|
||||||
|
|
||||||
static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
|
|
||||||
static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
|
|
||||||
|
|
||||||
String get label {
|
|
||||||
return customLabel ?? (numerator != null && denominator != null ? '$numerator:$denominator' : 'Free');
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get hasFlippedVariant => numerator != denominator;
|
|
||||||
double? get ratio => (numerator != null && denominator != null) ? numerator! / denominator! : null;
|
|
||||||
|
|
||||||
CropAspectRatio get flipped {
|
|
||||||
return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
if (identical(this, other)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return other is CropAspectRatio &&
|
|
||||||
other.numerator == numerator &&
|
|
||||||
other.denominator == denominator &&
|
|
||||||
other.customLabel == customLabel &&
|
|
||||||
other.icon == icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode {
|
|
||||||
return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const aspectRatioFree = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
|
|
||||||
const aspectRatioOriginal = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
|
|
||||||
|
|
||||||
final aspectRatioPresets = [
|
|
||||||
CropAspectRatio.free,
|
|
||||||
CropAspectRatio.original,
|
|
||||||
|
|
||||||
const CropAspectRatio(numerator: 1, denominator: 1),
|
|
||||||
|
|
||||||
// lanscape
|
|
||||||
const CropAspectRatio(numerator: 16, denominator: 9),
|
|
||||||
const CropAspectRatio(numerator: 3, denominator: 2),
|
|
||||||
const CropAspectRatio(numerator: 7, denominator: 5),
|
|
||||||
const CropAspectRatio(numerator: 4, denominator: 3),
|
|
||||||
|
|
||||||
// portrait
|
|
||||||
const CropAspectRatio(numerator: 16, denominator: 9).flipped,
|
|
||||||
const CropAspectRatio(numerator: 3, denominator: 2).flipped,
|
|
||||||
const CropAspectRatio(numerator: 7, denominator: 5).flipped,
|
|
||||||
const CropAspectRatio(numerator: 4, denominator: 3).flipped,
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ sealed class BaseAsset {
|
|||||||
final int? height;
|
final int? height;
|
||||||
final int? durationMs;
|
final int? durationMs;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
|
final String? livePhotoVideoId;
|
||||||
final bool isEdited;
|
final bool isEdited;
|
||||||
|
|
||||||
const BaseAsset({
|
const BaseAsset({
|
||||||
@@ -39,15 +40,32 @@ sealed class BaseAsset {
|
|||||||
this.height,
|
this.height,
|
||||||
this.durationMs,
|
this.durationMs,
|
||||||
this.isFavorite = false,
|
this.isFavorite = false,
|
||||||
|
this.livePhotoVideoId,
|
||||||
required this.isEdited,
|
required this.isEdited,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isImage => type == AssetType.image;
|
bool get isImage => type == AssetType.image;
|
||||||
bool get isVideo => type == AssetType.video;
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto;
|
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||||
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
||||||
|
|
||||||
|
AssetPlaybackStyle get playbackStyle {
|
||||||
|
if (isVideo) {
|
||||||
|
return AssetPlaybackStyle.video;
|
||||||
|
}
|
||||||
|
if (isMotionPhoto) {
|
||||||
|
return AssetPlaybackStyle.livePhoto;
|
||||||
|
}
|
||||||
|
if (isImage && durationMs != null && durationMs! > 0) {
|
||||||
|
return AssetPlaybackStyle.imageAnimated;
|
||||||
|
}
|
||||||
|
if (isImage) {
|
||||||
|
return AssetPlaybackStyle.image;
|
||||||
|
}
|
||||||
|
return AssetPlaybackStyle.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
Duration get duration {
|
Duration get duration {
|
||||||
final durationMs = this.durationMs;
|
final durationMs = this.durationMs;
|
||||||
if (durationMs != null) {
|
if (durationMs != null) {
|
||||||
@@ -68,7 +86,6 @@ sealed class BaseAsset {
|
|||||||
String? get localId;
|
String? get localId;
|
||||||
String? get remoteId;
|
String? get remoteId;
|
||||||
String get heroTag;
|
String get heroTag;
|
||||||
AssetPlaybackStyle get playbackStyle;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class LocalAsset extends BaseAsset {
|
|||||||
super.height,
|
super.height,
|
||||||
super.durationMs,
|
super.durationMs,
|
||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
|
super.livePhotoVideoId,
|
||||||
this.orientation = 0,
|
this.orientation = 0,
|
||||||
required this.playbackStyle,
|
required this.playbackStyle,
|
||||||
this.adjustmentTime,
|
this.adjustmentTime,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class RemoteAsset extends BaseAsset {
|
|||||||
final AssetVisibility visibility;
|
final AssetVisibility visibility;
|
||||||
final String ownerId;
|
final String ownerId;
|
||||||
final String? stackId;
|
final String? stackId;
|
||||||
final String? livePhotoVideoId;
|
|
||||||
final DateTime? uploadedAt;
|
final DateTime? uploadedAt;
|
||||||
final DateTime? deletedAt;
|
final DateTime? deletedAt;
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ class RemoteAsset extends BaseAsset {
|
|||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
this.thumbHash,
|
this.thumbHash,
|
||||||
this.visibility = AssetVisibility.timeline,
|
this.visibility = AssetVisibility.timeline,
|
||||||
this.livePhotoVideoId,
|
super.livePhotoVideoId,
|
||||||
this.stackId,
|
this.stackId,
|
||||||
required super.isEdited,
|
required super.isEdited,
|
||||||
this.deletedAt,
|
this.deletedAt,
|
||||||
@@ -39,23 +38,6 @@ class RemoteAsset extends BaseAsset {
|
|||||||
@override
|
@override
|
||||||
String? get localId => localAssetId;
|
String? get localId => localAssetId;
|
||||||
|
|
||||||
@override
|
|
||||||
AssetPlaybackStyle get playbackStyle {
|
|
||||||
if (isVideo) {
|
|
||||||
return AssetPlaybackStyle.video;
|
|
||||||
}
|
|
||||||
if (livePhotoVideoId != null) {
|
|
||||||
return AssetPlaybackStyle.livePhoto;
|
|
||||||
}
|
|
||||||
if (isImage && durationMs != null && durationMs! > 0) {
|
|
||||||
return AssetPlaybackStyle.imageAnimated;
|
|
||||||
}
|
|
||||||
if (isImage) {
|
|
||||||
return AssetPlaybackStyle.image;
|
|
||||||
}
|
|
||||||
return AssetPlaybackStyle.unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get remoteId => id;
|
String? get remoteId => id;
|
||||||
|
|
||||||
@@ -108,7 +90,6 @@ class RemoteAsset extends BaseAsset {
|
|||||||
thumbHash == other.thumbHash &&
|
thumbHash == other.thumbHash &&
|
||||||
visibility == other.visibility &&
|
visibility == other.visibility &&
|
||||||
stackId == other.stackId &&
|
stackId == other.stackId &&
|
||||||
livePhotoVideoId == other.livePhotoVideoId &&
|
|
||||||
uploadedAt == other.uploadedAt &&
|
uploadedAt == other.uploadedAt &&
|
||||||
deletedAt == other.deletedAt;
|
deletedAt == other.deletedAt;
|
||||||
}
|
}
|
||||||
@@ -122,7 +103,6 @@ class RemoteAsset extends BaseAsset {
|
|||||||
thumbHash.hashCode ^
|
thumbHash.hashCode ^
|
||||||
visibility.hashCode ^
|
visibility.hashCode ^
|
||||||
stackId.hashCode ^
|
stackId.hashCode ^
|
||||||
livePhotoVideoId.hashCode ^
|
|
||||||
uploadedAt.hashCode ^
|
uploadedAt.hashCode ^
|
||||||
deletedAt.hashCode;
|
deletedAt.hashCode;
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ class AssetService {
|
|||||||
return _localRepository.getByChecksum(checksum);
|
return _localRepository.getByChecksum(checksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LocalAsset?> getLocalAsset(String id) {
|
|
||||||
return _localRepository.get(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||||
return _remoteRepository.getByChecksum(checksum);
|
return _remoteRepository.getByChecksum(checksum);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ class LocalSyncService {
|
|||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
|
|
||||||
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
||||||
|
final getAlbumsTime = stopwatch.elapsedMilliseconds;
|
||||||
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
|
||||||
|
final getAllTime = stopwatch.elapsedMilliseconds;
|
||||||
|
|
||||||
await diffSortedLists(
|
await diffSortedLists(
|
||||||
dbAlbums,
|
dbAlbums,
|
||||||
@@ -148,10 +150,15 @@ class LocalSyncService {
|
|||||||
onlyFirst: removeAlbum,
|
onlyFirst: removeAlbum,
|
||||||
onlySecond: addAlbum,
|
onlySecond: addAlbum,
|
||||||
);
|
);
|
||||||
|
final diffTime = stopwatch.elapsedMilliseconds;
|
||||||
|
|
||||||
await _nativeSyncApi.checkpointSync();
|
await _nativeSyncApi.checkpointSync();
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
|
_log.info(
|
||||||
|
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
|
||||||
|
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
|
||||||
|
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
|
||||||
|
);
|
||||||
} on PlatformException catch (e, s) {
|
} on PlatformException catch (e, s) {
|
||||||
if (e.code == _kSyncCancelledCode) {
|
if (e.code == _kSyncCancelledCode) {
|
||||||
_log.warning("Full device sync cancelled");
|
_log.warning("Full device sync cancelled");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||||
@@ -66,15 +67,16 @@ class LogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleLogRecord(LogRecord r) {
|
void _handleLogRecord(LogRecord r) {
|
||||||
|
final int isolateHash = Isolate.current.hashCode;
|
||||||
dPrint(
|
dPrint(
|
||||||
() =>
|
() =>
|
||||||
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
|
'[${r.level.name}] [${r.time}] [${r.loggerName}] [$isolateHash] ${r.message}'
|
||||||
'${r.error == null ? '' : '\nError: ${r.error}'}'
|
'${r.error == null ? '' : '\nError: ${r.error}'}'
|
||||||
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
|
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
|
||||||
);
|
);
|
||||||
|
|
||||||
final record = LogMessage(
|
final record = LogMessage(
|
||||||
message: r.message,
|
message: '[$isolateHash] ${r.message}',
|
||||||
level: r.level.toLogLevel(),
|
level: r.level.toLogLevel(),
|
||||||
createdAt: r.time,
|
createdAt: r.time,
|
||||||
logger: r.loggerName,
|
logger: r.loggerName,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||||
|
|
||||||
@@ -85,15 +86,12 @@ class TimelineFactory {
|
|||||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||||
|
|
||||||
/// Creates a TimelineService for serving geographical map queries, such assets within bounded locations
|
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
|
||||||
TimelineService geographicMap(
|
TimelineService(_timelineRepository.map(userIds, options, groupBy));
|
||||||
List<String> userIds,
|
|
||||||
TimelineMapOptions Function() currentOptions,
|
|
||||||
Stream<TimelineMapOptions> optionsStream,
|
|
||||||
) => TimelineService(_timelineRepository.geographicMap(userIds, currentOptions, optionsStream, groupBy));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TimelineService {
|
class TimelineService {
|
||||||
|
static final Logger _log = Logger('TimelineService');
|
||||||
final TimelineAssetSource _assetSource;
|
final TimelineAssetSource _assetSource;
|
||||||
final TimelineBucketSource _bucketSource;
|
final TimelineBucketSource _bucketSource;
|
||||||
final TimelineOrigin origin;
|
final TimelineOrigin origin;
|
||||||
@@ -109,34 +107,49 @@ class TimelineService {
|
|||||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||||
|
|
||||||
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
|
||||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
_bucketSubscription = _bucketSource().listen(
|
||||||
_mutex.run(() async {
|
(buckets) {
|
||||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
_mutex.run(() async {
|
||||||
|
try {
|
||||||
|
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||||
|
|
||||||
if (totalAssets == 0) {
|
_log.info(
|
||||||
_bufferOffset = 0;
|
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
|
||||||
_buffer = [];
|
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
|
||||||
} else {
|
);
|
||||||
final int offset;
|
|
||||||
final int count;
|
if (totalAssets == 0) {
|
||||||
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
_bufferOffset = 0;
|
||||||
// we need to reset the buffer and load the first batch of assets.
|
_buffer = [];
|
||||||
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
} else {
|
||||||
offset = 0;
|
final int offset;
|
||||||
count = kTimelineAssetLoadBatchSize;
|
final int count;
|
||||||
} else {
|
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||||
offset = _bufferOffset;
|
// we need to reset the buffer and load the first batch of assets.
|
||||||
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||||
|
offset = 0;
|
||||||
|
count = kTimelineAssetLoadBatchSize;
|
||||||
|
} else {
|
||||||
|
offset = _bufferOffset;
|
||||||
|
count = math.min(_buffer.length, totalAssets - _bufferOffset);
|
||||||
|
}
|
||||||
|
_buffer = await _assetSource(offset, count);
|
||||||
|
_bufferOffset = offset;
|
||||||
|
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
|
||||||
|
}
|
||||||
|
|
||||||
|
_totalAssets = totalAssets;
|
||||||
|
EventStream.shared.emit(const TimelineReloadEvent());
|
||||||
|
} catch (error, stack) {
|
||||||
|
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
_buffer = await _assetSource(offset, count);
|
});
|
||||||
_bufferOffset = offset;
|
},
|
||||||
}
|
onError: (Object error, StackTrace stack) {
|
||||||
|
_log.severe('[$origin] bucket stream errored', error, stack);
|
||||||
// change the state's total assets count only after the buffer is reloaded
|
},
|
||||||
_totalAssets = totalAssets;
|
);
|
||||||
EventStream.shared.emit(const TimelineReloadEvent());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||||
@@ -168,6 +181,13 @@ class TimelineService {
|
|||||||
_buffer = await _assetSource(start, len);
|
_buffer = await _assetSource(start, len);
|
||||||
_bufferOffset = start;
|
_bufferOffset = start;
|
||||||
|
|
||||||
|
if (!hasRange(index, count)) {
|
||||||
|
_log.warning(
|
||||||
|
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
|
||||||
|
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return getAssets(index, count);
|
return getAssets(index, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ WHERE (library_id IS NOT NULL);
|
|||||||
CREATE INDEX IF NOT EXISTS 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)
|
ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)
|
||||||
''')
|
''')
|
||||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)')
|
|
||||||
class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||||
const RemoteAssetEntity();
|
const RemoteAssetEntity();
|
||||||
|
|
||||||
|
|||||||
@@ -1779,7 +1779,3 @@ i0.Index get idxRemoteAssetOwnerVisibilityDeletedCreated => i0.Index(
|
|||||||
'idx_remote_asset_owner_visibility_deleted_created',
|
'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)',
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||||
);
|
);
|
||||||
i0.Index get idxRemoteAssetUploaded => i0.Index(
|
|
||||||
'idx_remote_asset_uploaded',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||||
final frame = switch (info) {
|
final frame = switch (info) {
|
||||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class Drift extends $Drift {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 31;
|
int get schemaVersion => 30;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -128,209 +128,199 @@ class Drift extends $Drift {
|
|||||||
// Run migration steps without foreign keys and re-enable them later
|
// Run migration steps without foreign keys and re-enable them later
|
||||||
await customStatement('PRAGMA foreign_keys = OFF');
|
await customStatement('PRAGMA foreign_keys = OFF');
|
||||||
|
|
||||||
try {
|
await m.runMigrationSteps(
|
||||||
await transaction(
|
from: from,
|
||||||
() => m.runMigrationSteps(
|
to: to,
|
||||||
from: from,
|
steps: migrationSteps(
|
||||||
to: to,
|
from1To2: (m, v2) async {
|
||||||
steps: migrationSteps(
|
for (final entity in v2.entities) {
|
||||||
from1To2: (m, v2) async {
|
await m.drop(entity);
|
||||||
for (final entity in v2.entities) {
|
await m.create(entity);
|
||||||
await m.drop(entity);
|
}
|
||||||
await m.create(entity);
|
},
|
||||||
}
|
from2To3: (m, v3) async {
|
||||||
},
|
// Removed foreign key constraint on stack.primaryAssetId
|
||||||
from2To3: (m, v3) async {
|
await m.alterTable(TableMigration(v3.stackEntity));
|
||||||
// Removed foreign key constraint on stack.primaryAssetId
|
},
|
||||||
await m.alterTable(TableMigration(v3.stackEntity));
|
from3To4: (m, v4) async {
|
||||||
},
|
// Thumbnail path column got removed from person_entity
|
||||||
from3To4: (m, v4) async {
|
await m.alterTable(TableMigration(v4.personEntity));
|
||||||
// Thumbnail path column got removed from person_entity
|
// asset_face_entity is added
|
||||||
await m.alterTable(TableMigration(v4.personEntity));
|
await m.create(v4.assetFaceEntity);
|
||||||
// asset_face_entity is added
|
},
|
||||||
await m.create(v4.assetFaceEntity);
|
from4To5: (m, v5) async {
|
||||||
},
|
await m.alterTable(
|
||||||
from4To5: (m, v5) async {
|
TableMigration(
|
||||||
await m.alterTable(
|
v5.userEntity,
|
||||||
TableMigration(
|
newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt],
|
||||||
v5.userEntity,
|
columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime},
|
||||||
newColumns: [v5.userEntity.hasProfileImage, v5.userEntity.profileChangedAt],
|
),
|
||||||
columnTransformer: {v5.userEntity.profileChangedAt: currentDateAndTime},
|
);
|
||||||
),
|
},
|
||||||
);
|
from5To6: (m, v6) async {
|
||||||
},
|
// Drops the (checksum, ownerId) and adds it back as (ownerId, checksum)
|
||||||
from5To6: (m, v6) async {
|
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
|
||||||
// Drops the (checksum, ownerId) and adds it back as (ownerId, checksum)
|
await m.drop(v6.idxRemoteAssetOwnerChecksum);
|
||||||
await customStatement('DROP INDEX IF EXISTS UQ_remote_asset_owner_checksum');
|
await m.create(v6.idxRemoteAssetOwnerChecksum);
|
||||||
await m.drop(v6.idxRemoteAssetOwnerChecksum);
|
// Adds libraryId to remote_asset_entity
|
||||||
await m.create(v6.idxRemoteAssetOwnerChecksum);
|
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
|
||||||
// Adds libraryId to remote_asset_entity
|
await m.drop(v6.uQRemoteAssetsOwnerChecksum);
|
||||||
await m.addColumn(v6.remoteAssetEntity, v6.remoteAssetEntity.libraryId);
|
await m.create(v6.uQRemoteAssetsOwnerChecksum);
|
||||||
await m.drop(v6.uQRemoteAssetsOwnerChecksum);
|
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
|
||||||
await m.create(v6.uQRemoteAssetsOwnerChecksum);
|
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
|
||||||
await m.drop(v6.uQRemoteAssetsOwnerLibraryChecksum);
|
},
|
||||||
await m.create(v6.uQRemoteAssetsOwnerLibraryChecksum);
|
from6To7: (m, v7) async {
|
||||||
},
|
await m.createIndex(v7.idxLatLng);
|
||||||
from6To7: (m, v7) async {
|
},
|
||||||
await m.createIndex(v7.idxLatLng);
|
from7To8: (m, v8) async {
|
||||||
},
|
await m.create(v8.storeEntity);
|
||||||
from7To8: (m, v8) async {
|
},
|
||||||
await m.create(v8.storeEntity);
|
from8To9: (m, v9) async {
|
||||||
},
|
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
|
||||||
from8To9: (m, v9) async {
|
},
|
||||||
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
|
from9To10: (m, v10) async {
|
||||||
},
|
await m.createTable(v10.authUserEntity);
|
||||||
from9To10: (m, v10) async {
|
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
||||||
await m.createTable(v10.authUserEntity);
|
await m.alterTable(TableMigration(v10.userEntity));
|
||||||
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
},
|
||||||
await m.alterTable(TableMigration(v10.userEntity));
|
from10To11: (m, v11) async {
|
||||||
},
|
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
|
||||||
from10To11: (m, v11) async {
|
},
|
||||||
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
|
from11To12: (m, v12) async {
|
||||||
},
|
final localToUTCMapping = {
|
||||||
from11To12: (m, v12) async {
|
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
|
||||||
final localToUTCMapping = {
|
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
|
||||||
v12.localAssetEntity: [v12.localAssetEntity.createdAt, v12.localAssetEntity.updatedAt],
|
};
|
||||||
v12.localAlbumEntity: [v12.localAlbumEntity.updatedAt],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (final entry in localToUTCMapping.entries) {
|
for (final entry in localToUTCMapping.entries) {
|
||||||
final table = entry.key;
|
final table = entry.key;
|
||||||
await m.alterTable(
|
await m.alterTable(
|
||||||
TableMigration(
|
TableMigration(
|
||||||
table,
|
table,
|
||||||
columnTransformer: {
|
columnTransformer: {
|
||||||
for (final column in entry.value)
|
for (final column in entry.value)
|
||||||
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
|
column: column.modify(const DateTimeModifier.utc()).strftime('%Y-%m-%dT%H:%M:%fZ'),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
from12To13: (m, v13) async {
|
from12To13: (m, v13) async {
|
||||||
await m.create(v13.trashedLocalAssetEntity);
|
await m.create(v13.trashedLocalAssetEntity);
|
||||||
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
|
await m.createIndex(v13.idxTrashedLocalAssetChecksum);
|
||||||
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
|
await m.createIndex(v13.idxTrashedLocalAssetAlbum);
|
||||||
},
|
},
|
||||||
from13To14: (m, v14) async {
|
from13To14: (m, v14) async {
|
||||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
|
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.adjustmentTime);
|
||||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
|
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
|
||||||
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
|
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
|
||||||
},
|
},
|
||||||
from14To15: (m, v15) async {
|
from14To15: (m, v15) async {
|
||||||
await m.alterTable(
|
await m.alterTable(
|
||||||
TableMigration(
|
TableMigration(
|
||||||
v15.trashedLocalAssetEntity,
|
v15.trashedLocalAssetEntity,
|
||||||
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
|
columnTransformer: {v15.trashedLocalAssetEntity.source: Constant(TrashOrigin.localSync.index)},
|
||||||
newColumns: [v15.trashedLocalAssetEntity.source],
|
newColumns: [v15.trashedLocalAssetEntity.source],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
from15To16: (m, v16) async {
|
from15To16: (m, v16) async {
|
||||||
// Add i_cloud_id to local and remote asset tables
|
// Add i_cloud_id to local and remote asset tables
|
||||||
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
|
await m.addColumn(v16.localAssetEntity, v16.localAssetEntity.iCloudId);
|
||||||
await m.createIndex(v16.idxLocalAssetCloudId);
|
await m.createIndex(v16.idxLocalAssetCloudId);
|
||||||
await m.createTable(v16.remoteAssetCloudIdEntity);
|
await m.createTable(v16.remoteAssetCloudIdEntity);
|
||||||
},
|
},
|
||||||
from16To17: (m, v17) async {
|
from16To17: (m, v17) async {
|
||||||
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
|
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
|
||||||
},
|
},
|
||||||
from17To18: (m, v18) async {
|
from17To18: (m, v18) async {
|
||||||
await m.createIndex(v18.idxRemoteAssetCloudId);
|
await m.createIndex(v18.idxRemoteAssetCloudId);
|
||||||
},
|
},
|
||||||
from18To19: (m, v19) async {
|
from18To19: (m, v19) async {
|
||||||
await m.createIndex(v19.idxAssetFacePersonId);
|
await m.createIndex(v19.idxAssetFacePersonId);
|
||||||
await m.createIndex(v19.idxAssetFaceAssetId);
|
await m.createIndex(v19.idxAssetFaceAssetId);
|
||||||
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
|
await m.createIndex(v19.idxLocalAlbumAssetAlbumAsset);
|
||||||
await m.createIndex(v19.idxPartnerSharedWithId);
|
await m.createIndex(v19.idxPartnerSharedWithId);
|
||||||
await m.createIndex(v19.idxPersonOwnerId);
|
await m.createIndex(v19.idxPersonOwnerId);
|
||||||
await m.createIndex(v19.idxRemoteAlbumOwnerId);
|
await m.createIndex(v19.idxRemoteAlbumOwnerId);
|
||||||
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
|
await m.createIndex(v19.idxRemoteAlbumAssetAlbumAsset);
|
||||||
await m.createIndex(v19.idxRemoteAssetStackId);
|
await m.createIndex(v19.idxRemoteAssetStackId);
|
||||||
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
|
await m.createIndex(v19.idxRemoteAssetLocalDateTimeDay);
|
||||||
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
|
await m.createIndex(v19.idxRemoteAssetLocalDateTimeMonth);
|
||||||
await m.createIndex(v19.idxStackPrimaryAssetId);
|
await m.createIndex(v19.idxStackPrimaryAssetId);
|
||||||
},
|
},
|
||||||
from19To20: (m, v20) async {
|
from19To20: (m, v20) async {
|
||||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
|
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
|
||||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
|
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
|
||||||
},
|
},
|
||||||
from20To21: (m, v21) async {
|
from20To21: (m, v21) async {
|
||||||
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
||||||
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
||||||
},
|
},
|
||||||
from21To22: (m, v22) async {
|
from21To22: (m, v22) async {
|
||||||
await m.createTable(v22.assetEditEntity);
|
await m.createTable(v22.assetEditEntity);
|
||||||
await m.createIndex(v22.idxAssetEditAssetId);
|
await m.createIndex(v22.idxAssetEditAssetId);
|
||||||
},
|
},
|
||||||
from22To23: (m, v23) async {
|
from22To23: (m, v23) async {
|
||||||
await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs);
|
await m.renameColumn(v23.localAssetEntity, 'duration_in_seconds', v23.localAssetEntity.durationMs);
|
||||||
await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs);
|
await m.renameColumn(v23.remoteAssetEntity, 'duration_in_seconds', v23.remoteAssetEntity.durationMs);
|
||||||
await m.renameColumn(
|
await m.renameColumn(
|
||||||
v23.trashedLocalAssetEntity,
|
v23.trashedLocalAssetEntity,
|
||||||
'duration_in_seconds',
|
'duration_in_seconds',
|
||||||
v23.trashedLocalAssetEntity.durationMs,
|
v23.trashedLocalAssetEntity.durationMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
await localAssetEntity.update().write(
|
await localAssetEntity.update().write(
|
||||||
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
|
LocalAssetEntityCompanion.custom(durationMs: v23.localAssetEntity.durationMs * const Constant(1000)),
|
||||||
);
|
);
|
||||||
await remoteAssetEntity.update().write(
|
await remoteAssetEntity.update().write(
|
||||||
RemoteAssetEntityCompanion.custom(
|
RemoteAssetEntityCompanion.custom(durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000)),
|
||||||
durationMs: v23.remoteAssetEntity.durationMs * const Constant(1000),
|
);
|
||||||
),
|
await trashedLocalAssetEntity.update().write(
|
||||||
);
|
TrashedLocalAssetEntityCompanion.custom(
|
||||||
await trashedLocalAssetEntity.update().write(
|
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
|
||||||
TrashedLocalAssetEntityCompanion.custom(
|
),
|
||||||
durationMs: v23.trashedLocalAssetEntity.durationMs * const Constant(1000),
|
);
|
||||||
),
|
},
|
||||||
);
|
from23To24: (m, v24) async {
|
||||||
},
|
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
|
||||||
from23To24: (m, v24) async {
|
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
|
||||||
await customStatement('DROP INDEX IF EXISTS idx_remote_album_owner_id');
|
},
|
||||||
await m.alterTable(TableMigration(v24.remoteAlbumEntity));
|
from24To25: (m, v25) async {
|
||||||
},
|
await m.createTable(v25.metadata);
|
||||||
from24To25: (m, v25) async {
|
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
|
||||||
await m.createTable(v25.metadata);
|
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
|
||||||
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum');
|
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
|
||||||
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day');
|
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
|
||||||
await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month');
|
await m.createIndex(v25.idxRemoteExifCity);
|
||||||
await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated);
|
await m.createIndex(v25.idxAssetFaceVisiblePerson);
|
||||||
await m.createIndex(v25.idxRemoteExifCity);
|
},
|
||||||
await m.createIndex(v25.idxAssetFaceVisiblePerson);
|
from25To26: (m, v26) async {
|
||||||
},
|
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
|
||||||
from25To26: (m, v26) async {
|
},
|
||||||
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
|
from26To27: (m, v27) async {
|
||||||
},
|
await customStatement('ALTER TABLE metadata RENAME TO settings');
|
||||||
from26To27: (m, v27) async {
|
},
|
||||||
await customStatement('ALTER TABLE metadata RENAME TO settings');
|
from27To28: (m, v28) async {
|
||||||
},
|
await m.createIndex(v28.idxLocalAssetCreatedAt);
|
||||||
from27To28: (m, v28) async {
|
},
|
||||||
await m.createIndex(v28.idxLocalAssetCreatedAt);
|
from28To29: (m, v29) async {
|
||||||
},
|
await m.createTable(v29.assetOcrEntity);
|
||||||
from28To29: (m, v29) async {
|
await m.createIndex(v29.idxAssetOcrAssetId);
|
||||||
await m.createTable(v29.assetOcrEntity);
|
},
|
||||||
await m.createIndex(v29.idxAssetOcrAssetId);
|
from29To30: (m, v30) async {
|
||||||
},
|
await m.alterTable(TableMigration(v30.settings));
|
||||||
from29To30: (m, v30) async {
|
},
|
||||||
await m.alterTable(TableMigration(v30.settings));
|
),
|
||||||
},
|
);
|
||||||
from30To31: (m, v31) async {
|
|
||||||
await m.createIndex(v31.idxRemoteAssetUploaded);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
// Fail if the migration broke foreign keys
|
// Fail if the migration broke foreign keys
|
||||||
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
|
final wrongFKs = await customSelect('PRAGMA foreign_key_check').get();
|
||||||
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
|
assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}');
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await customStatement('PRAGMA foreign_keys = ON;');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await customStatement('PRAGMA foreign_keys = ON;');
|
||||||
await optimize();
|
await optimize();
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
|||||||
i2.idxRemoteAssetChecksum,
|
i2.idxRemoteAssetChecksum,
|
||||||
i2.idxRemoteAssetStackId,
|
i2.idxRemoteAssetStackId,
|
||||||
i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
|
i2.idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||||
i2.idxRemoteAssetUploaded,
|
|
||||||
authUserEntity,
|
authUserEntity,
|
||||||
userMetadataEntity,
|
userMetadataEntity,
|
||||||
partnerEntity,
|
partnerEntity,
|
||||||
|
|||||||
@@ -15920,592 +15920,6 @@ i1.GeneratedColumn<String> _column_224(String aliasedName) =>
|
|||||||
type: i1.DriftSqlType.string,
|
type: i1.DriftSqlType.string,
|
||||||
$customConstraints: 'NULL',
|
$customConstraints: 'NULL',
|
||||||
);
|
);
|
||||||
|
|
||||||
final class Schema31 extends i0.VersionedSchema {
|
|
||||||
Schema31({required super.database}) : super(version: 31);
|
|
||||||
@override
|
|
||||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
|
||||||
userEntity,
|
|
||||||
remoteAssetEntity,
|
|
||||||
stackEntity,
|
|
||||||
localAssetEntity,
|
|
||||||
remoteAlbumEntity,
|
|
||||||
localAlbumEntity,
|
|
||||||
localAlbumAssetEntity,
|
|
||||||
idxLocalAlbumAssetAlbumAsset,
|
|
||||||
idxLocalAssetChecksum,
|
|
||||||
idxLocalAssetCloudId,
|
|
||||||
idxLocalAssetCreatedAt,
|
|
||||||
idxStackPrimaryAssetId,
|
|
||||||
uQRemoteAssetsOwnerChecksum,
|
|
||||||
uQRemoteAssetsOwnerLibraryChecksum,
|
|
||||||
idxRemoteAssetChecksum,
|
|
||||||
idxRemoteAssetStackId,
|
|
||||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
|
||||||
idxRemoteAssetUploaded,
|
|
||||||
authUserEntity,
|
|
||||||
userMetadataEntity,
|
|
||||||
partnerEntity,
|
|
||||||
remoteExifEntity,
|
|
||||||
remoteAlbumAssetEntity,
|
|
||||||
remoteAlbumUserEntity,
|
|
||||||
remoteAssetCloudIdEntity,
|
|
||||||
memoryEntity,
|
|
||||||
memoryAssetEntity,
|
|
||||||
personEntity,
|
|
||||||
assetFaceEntity,
|
|
||||||
storeEntity,
|
|
||||||
trashedLocalAssetEntity,
|
|
||||||
assetEditEntity,
|
|
||||||
settings,
|
|
||||||
assetOcrEntity,
|
|
||||||
idxPartnerSharedWithId,
|
|
||||||
idxLatLng,
|
|
||||||
idxRemoteExifCity,
|
|
||||||
idxRemoteAlbumAssetAlbumAsset,
|
|
||||||
idxRemoteAssetCloudId,
|
|
||||||
idxPersonOwnerId,
|
|
||||||
idxAssetFacePersonId,
|
|
||||||
idxAssetFaceAssetId,
|
|
||||||
idxAssetFaceVisiblePerson,
|
|
||||||
idxTrashedLocalAssetChecksum,
|
|
||||||
idxTrashedLocalAssetAlbum,
|
|
||||||
idxAssetEditAssetId,
|
|
||||||
idxAssetOcrAssetId,
|
|
||||||
];
|
|
||||||
late final Shape33 userEntity = Shape33(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'user_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_107,
|
|
||||||
_column_108,
|
|
||||||
_column_109,
|
|
||||||
_column_110,
|
|
||||||
_column_111,
|
|
||||||
_column_112,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape50 remoteAssetEntity = Shape50(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_108,
|
|
||||||
_column_113,
|
|
||||||
_column_114,
|
|
||||||
_column_115,
|
|
||||||
_column_116,
|
|
||||||
_column_117,
|
|
||||||
_column_118,
|
|
||||||
_column_107,
|
|
||||||
_column_119,
|
|
||||||
_column_120,
|
|
||||||
_column_121,
|
|
||||||
_column_122,
|
|
||||||
_column_123,
|
|
||||||
_column_124,
|
|
||||||
_column_212,
|
|
||||||
_column_125,
|
|
||||||
_column_126,
|
|
||||||
_column_127,
|
|
||||||
_column_128,
|
|
||||||
_column_129,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape35 stackEntity = Shape35(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'stack_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_107,
|
|
||||||
_column_114,
|
|
||||||
_column_115,
|
|
||||||
_column_121,
|
|
||||||
_column_130,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape36 localAssetEntity = Shape36(
|
|
||||||
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,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape48 remoteAlbumEntity = Shape48(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'remote_album_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_107,
|
|
||||||
_column_108,
|
|
||||||
_column_138,
|
|
||||||
_column_114,
|
|
||||||
_column_115,
|
|
||||||
_column_139,
|
|
||||||
_column_140,
|
|
||||||
_column_141,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape38 localAlbumEntity = Shape38(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'local_album_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_107,
|
|
||||||
_column_108,
|
|
||||||
_column_115,
|
|
||||||
_column_142,
|
|
||||||
_column_143,
|
|
||||||
_column_144,
|
|
||||||
_column_145,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'local_album_asset_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
|
||||||
columns: [_column_146, _column_147, _column_145],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
|
||||||
'idx_local_album_asset_album_asset',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
|
||||||
'idx_local_asset_checksum',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
|
||||||
);
|
|
||||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
|
||||||
'idx_local_asset_cloud_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxLocalAssetCreatedAt = i1.Index(
|
|
||||||
'idx_local_asset_created_at',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
|
||||||
);
|
|
||||||
final i1.Index 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)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetUploaded = i1.Index(
|
|
||||||
'idx_remote_asset_uploaded',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_uploaded ON remote_asset_entity (uploaded_at)',
|
|
||||||
);
|
|
||||||
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_224, _column_115],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
late final Shape51 assetOcrEntity = Shape51(
|
|
||||||
source: i0.VersionedTable(
|
|
||||||
entityName: 'asset_ocr_entity',
|
|
||||||
withoutRowId: true,
|
|
||||||
isStrict: true,
|
|
||||||
tableConstraints: ['PRIMARY KEY(id)'],
|
|
||||||
columns: [
|
|
||||||
_column_107,
|
|
||||||
_column_159,
|
|
||||||
_column_213,
|
|
||||||
_column_214,
|
|
||||||
_column_215,
|
|
||||||
_column_216,
|
|
||||||
_column_217,
|
|
||||||
_column_218,
|
|
||||||
_column_219,
|
|
||||||
_column_220,
|
|
||||||
_column_221,
|
|
||||||
_column_222,
|
|
||||||
_column_223,
|
|
||||||
_column_201,
|
|
||||||
],
|
|
||||||
attachedDatabase: database,
|
|
||||||
),
|
|
||||||
alias: null,
|
|
||||||
);
|
|
||||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
|
||||||
'idx_partner_shared_with_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxLatLng = i1.Index(
|
|
||||||
'idx_lat_lng',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteExifCity = i1.Index(
|
|
||||||
'idx_remote_exif_city',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
|
||||||
'idx_remote_album_asset_album_asset',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
|
||||||
'idx_remote_asset_cloud_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxPersonOwnerId = i1.Index(
|
|
||||||
'idx_person_owner_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
|
||||||
'idx_asset_face_person_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
|
||||||
'idx_asset_face_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
|
||||||
'idx_asset_face_visible_person',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
|
||||||
);
|
|
||||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
|
||||||
'idx_trashed_local_asset_checksum',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
|
||||||
);
|
|
||||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
|
||||||
'idx_trashed_local_asset_album',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
|
||||||
'idx_asset_edit_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
|
||||||
);
|
|
||||||
final i1.Index idxAssetOcrAssetId = i1.Index(
|
|
||||||
'idx_asset_ocr_asset_id',
|
|
||||||
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
@@ -16536,7 +15950,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -16685,11 +16098,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from29To30(migrator, schema);
|
await from29To30(migrator, schema);
|
||||||
return 30;
|
return 30;
|
||||||
case 30:
|
|
||||||
final schema = Schema31(database: database);
|
|
||||||
final migrator = i1.Migrator(database, schema);
|
|
||||||
await from30To31(migrator, schema);
|
|
||||||
return 31;
|
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
@@ -16726,7 +16134,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||||
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
required Future<void> Function(i1.Migrator m, Schema30 schema) from29To30,
|
||||||
required Future<void> Function(i1.Migrator m, Schema31 schema) from30To31,
|
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
@@ -16758,6 +16165,5 @@ i1.OnUpgrade stepByStep({
|
|||||||
from27To28: from27To28,
|
from27To28: from27To28,
|
||||||
from28To29: from28To29,
|
from28To29: from28To29,
|
||||||
from29To30: from29To30,
|
from29To30: from29To30,
|
||||||
from30To31: from30To31,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class StorageRepository {
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(agg23): Unify these methods
|
|
||||||
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
|
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
|
||||||
File? file;
|
File? file;
|
||||||
final log = Logger('StorageRepository');
|
final log = Logger('StorageRepository');
|
||||||
|
|||||||
@@ -509,21 +509,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
return query.map((row) => row.toDto()).get();
|
return query.map((row) => row.toDto()).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a geographic map query that can dynamically filter on changing [TimelineMapOptions]
|
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
|
||||||
/// (most notably the active map bounds)
|
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
|
||||||
TimelineQuery geographicMap(
|
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
|
||||||
List<String> userIds,
|
|
||||||
TimelineMapOptions Function() currentOptions,
|
|
||||||
Stream<TimelineMapOptions> optionsStream,
|
|
||||||
GroupAssetsBy groupBy,
|
|
||||||
) => (
|
|
||||||
bucketSource: () => Stream.value(currentOptions())
|
|
||||||
.followedBy(optionsStream)
|
|
||||||
.switchMap(
|
|
||||||
// Any error would kill the stream for all options; make sure the stream stays alive
|
|
||||||
(options) => _watchMapBucket(userIds, options, groupBy: groupBy).handleError((_) {}),
|
|
||||||
),
|
|
||||||
assetSource: (offset, count) => _getMapBucketAssets(userIds, currentOptions(), offset: offset, count: count),
|
|
||||||
origin: TimelineOrigin.map,
|
origin: TimelineOrigin.map,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -626,7 +614,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
return (
|
return (
|
||||||
bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy),
|
bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy),
|
||||||
assetSource: (offset, count) =>
|
assetSource: (offset, count) =>
|
||||||
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal, sortBy: sortBy),
|
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal),
|
||||||
origin: origin,
|
origin: origin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -663,7 +651,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
required int offset,
|
required int offset,
|
||||||
required int count,
|
required int count,
|
||||||
bool joinLocal = false,
|
bool joinLocal = false,
|
||||||
SortAssetsBy sortBy = SortAssetsBy.taken,
|
|
||||||
}) {
|
}) {
|
||||||
if (joinLocal) {
|
if (joinLocal) {
|
||||||
final query =
|
final query =
|
||||||
@@ -676,11 +663,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
])
|
])
|
||||||
..addColumns([_db.localAssetEntity.id])
|
..addColumns([_db.localAssetEntity.id])
|
||||||
..where(filter(_db.remoteAssetEntity))
|
..where(filter(_db.remoteAssetEntity))
|
||||||
..orderBy([
|
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||||
OrderingTerm.desc(
|
|
||||||
sortBy == SortAssetsBy.uploaded ? _db.remoteAssetEntity.uploadedAt : _db.remoteAssetEntity.createdAt,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
..limit(count, offset: offset);
|
..limit(count, offset: offset);
|
||||||
|
|
||||||
return query
|
return query
|
||||||
@@ -689,7 +672,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
} else {
|
} else {
|
||||||
final query = _db.remoteAssetEntity.select()
|
final query = _db.remoteAssetEntity.select()
|
||||||
..where(filter)
|
..where(filter)
|
||||||
..orderBy([(row) => OrderingTerm.desc(sortBy == SortAssetsBy.uploaded ? row.uploadedAt : row.createdAt)])
|
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
|
||||||
..limit(count, offset: offset);
|
..limit(count, offset: offset);
|
||||||
|
|
||||||
return query.map((row) => row.toDto()).get();
|
return query.map((row) => row.toDto()).get();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart
|
|||||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
|
||||||
import 'package:immich_mobile/providers/feature_message.provider.dart';
|
import 'package:immich_mobile/providers/feature_message.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class MainTimelinePage extends ConsumerStatefulWidget {
|
class MainTimelinePage extends ConsumerStatefulWidget {
|
||||||
@@ -44,6 +45,9 @@ class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
|
|||||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||||
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||||
showStorageIndicator: true,
|
showStorageIndicator: true,
|
||||||
|
onRefresh: () {
|
||||||
|
Logger('MainTimelinePage').info('Pull-to-refresh triggered');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
|||||||
_PropertyItem(label: 'Height', value: asset.height?.toString()),
|
_PropertyItem(label: 'Height', value: asset.height?.toString()),
|
||||||
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
|
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
|
||||||
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
|
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
|
||||||
|
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
|
||||||
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
|
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -150,7 +151,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
|||||||
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
|
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
|
||||||
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
|
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
|
||||||
_PropertyItem(label: 'Stack ID', value: asset.stackId),
|
_PropertyItem(label: 'Stack ID', value: asset.stackId),
|
||||||
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
properties.insertAll(4, additionalProps);
|
properties.insertAll(4, additionalProps);
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AspectRatioButton extends StatelessWidget {
|
class _AspectRatioButton extends StatelessWidget {
|
||||||
final CropAspectRatio ratio;
|
final AspectRatioPreset ratio;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
@@ -162,16 +162,15 @@ class _AspectRatioButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
iconSize: 36,
|
iconSize: 36,
|
||||||
icon: ratio.ratio != null
|
icon: Transform.rotate(
|
||||||
? _AspectRatioRect(ratio: ratio.ratio!, color: color)
|
angle: ratio.iconRotated ? pi / 2 : 0,
|
||||||
: Icon(ratio.icon, color: color),
|
child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
|
||||||
|
),
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
),
|
),
|
||||||
Text(ratio.label, style: context.textTheme.displayMedium),
|
Text(ratio.label, style: context.textTheme.displayMedium),
|
||||||
@@ -180,32 +179,6 @@ class _AspectRatioButton extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AspectRatioRect extends StatelessWidget {
|
|
||||||
final double ratio;
|
|
||||||
final Color? color;
|
|
||||||
|
|
||||||
const _AspectRatioRect({required this.ratio, required this.color});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizedBox(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
child: Center(
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: ratio,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: color ?? Colors.transparent, width: 3),
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AspectRatioSelector extends ConsumerWidget {
|
class _AspectRatioSelector extends ConsumerWidget {
|
||||||
const _AspectRatioSelector();
|
const _AspectRatioSelector();
|
||||||
|
|
||||||
@@ -214,16 +187,22 @@ class _AspectRatioSelector extends ConsumerWidget {
|
|||||||
final editorState = ref.watch(editorStateProvider);
|
final editorState = ref.watch(editorStateProvider);
|
||||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||||
|
|
||||||
|
// the whole crop view is rotated, so we need to swap the aspect ratio when the rotation is 90 or 270 degrees
|
||||||
|
double? selectedAspectRatio = editorState.aspectRatio;
|
||||||
|
if (editorState.rotationAngle % 180 != 0 && selectedAspectRatio != null) {
|
||||||
|
selectedAspectRatio = 1 / selectedAspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: aspectRatioPresets.map((entry) {
|
children: AspectRatioPreset.values.map((entry) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: _AspectRatioButton(
|
child: _AspectRatioButton(
|
||||||
ratio: entry,
|
ratio: entry,
|
||||||
isSelected: editorState.aspectRatio == entry,
|
isSelected: selectedAspectRatio == entry.ratio,
|
||||||
onPressed: () => editorNotifier.setAspectRatio(entry),
|
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
@@ -378,22 +357,8 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
|
|||||||
final editorState = ref.watch(editorStateProvider);
|
final editorState = ref.watch(editorStateProvider);
|
||||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||||
|
|
||||||
ref.listen(editorStateProvider, (previous, current) {
|
ref.listen(editorStateProvider, (_, current) {
|
||||||
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation
|
cropController.aspectRatio = current.aspectRatio;
|
||||||
if (previous?.aspectRatio != current.aspectRatio) {
|
|
||||||
double? ratio;
|
|
||||||
|
|
||||||
ratio = switch (current.aspectRatio) {
|
|
||||||
CropAspectRatio.original => current.originalWidth / current.originalHeight,
|
|
||||||
_ => current.aspectRatio.ratio,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (current.rotationAngle % 180 != 0) {
|
|
||||||
ratio = ratio != null ? 1 / ratio : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
cropController.aspectRatio = ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cropController.crop != current.crop) {
|
if (cropController.crop != current.crop) {
|
||||||
cropController.crop = current.crop;
|
cropController.crop = current.crop;
|
||||||
@@ -421,9 +386,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
|
|||||||
1.0,
|
1.0,
|
||||||
1.0,
|
1.0,
|
||||||
),
|
),
|
||||||
child: AnimatedContainer(
|
child: Container(
|
||||||
duration: editorState.animationDuration,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
||||||
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/aspect_ratios.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/utils/editor.utils.dart';
|
import 'package:immich_mobile/utils/editor.utils.dart';
|
||||||
@@ -61,8 +60,13 @@ class EditorProvider extends Notifier<EditorState> {
|
|||||||
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
|
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAspectRatio(CropAspectRatio preset) {
|
void setAspectRatio(double? aspectRatio) {
|
||||||
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true);
|
if (aspectRatio != null && state.rotationAngle % 180 != 0) {
|
||||||
|
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
|
||||||
|
aspectRatio = 1 / aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(aspectRatio: aspectRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
void resetEdits() {
|
void resetEdits() {
|
||||||
@@ -72,19 +76,19 @@ class EditorProvider extends Notifier<EditorState> {
|
|||||||
flipHorizontal: false,
|
flipHorizontal: false,
|
||||||
flipVertical: false,
|
flipVertical: false,
|
||||||
crop: const Rect.fromLTRB(0, 0, 1, 1),
|
crop: const Rect.fromLTRB(0, 0, 1, 1),
|
||||||
aspectRatio: CropAspectRatio.free,
|
aspectRatio: null,
|
||||||
hasUnsavedEdits: true,
|
hasUnsavedEdits: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void rotateCCW() {
|
void rotateCCW() {
|
||||||
_animateRotation(state.rotationAngle - 90);
|
_animateRotation(state.rotationAngle - 90);
|
||||||
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
|
state = state.copyWith(hasUnsavedEdits: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void rotateCW() {
|
void rotateCW() {
|
||||||
_animateRotation(state.rotationAngle + 90);
|
_animateRotation(state.rotationAngle + 90);
|
||||||
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
|
state = state.copyWith(hasUnsavedEdits: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void flipHorizontally() {
|
void flipHorizontally() {
|
||||||
@@ -113,7 +117,7 @@ class EditorState {
|
|||||||
final bool flipHorizontal;
|
final bool flipHorizontal;
|
||||||
final bool flipVertical;
|
final bool flipVertical;
|
||||||
final Rect crop;
|
final Rect crop;
|
||||||
final CropAspectRatio aspectRatio;
|
final double? aspectRatio;
|
||||||
|
|
||||||
final int originalWidth;
|
final int originalWidth;
|
||||||
final int originalHeight;
|
final int originalHeight;
|
||||||
@@ -128,7 +132,7 @@ class EditorState {
|
|||||||
bool? flipHorizontal,
|
bool? flipHorizontal,
|
||||||
bool? flipVertical,
|
bool? flipVertical,
|
||||||
Rect? crop,
|
Rect? crop,
|
||||||
CropAspectRatio? aspectRatio,
|
this.aspectRatio,
|
||||||
int? originalWidth,
|
int? originalWidth,
|
||||||
int? originalHeight,
|
int? originalHeight,
|
||||||
Duration? animationDuration,
|
Duration? animationDuration,
|
||||||
@@ -141,7 +145,6 @@ class EditorState {
|
|||||||
originalWidth = originalWidth ?? 0,
|
originalWidth = originalWidth ?? 0,
|
||||||
originalHeight = originalHeight ?? 0,
|
originalHeight = originalHeight ?? 0,
|
||||||
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
|
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
|
||||||
aspectRatio = aspectRatio ?? CropAspectRatio.free,
|
|
||||||
hasUnsavedEdits = hasUnsavedEdits ?? false;
|
hasUnsavedEdits = hasUnsavedEdits ?? false;
|
||||||
|
|
||||||
EditorState copyWith({
|
EditorState copyWith({
|
||||||
@@ -149,7 +152,7 @@ class EditorState {
|
|||||||
int? rotationAngle,
|
int? rotationAngle,
|
||||||
bool? flipHorizontal,
|
bool? flipHorizontal,
|
||||||
bool? flipVertical,
|
bool? flipVertical,
|
||||||
CropAspectRatio? aspectRatio,
|
double? aspectRatio = double.infinity,
|
||||||
int? originalWidth,
|
int? originalWidth,
|
||||||
int? originalHeight,
|
int? originalHeight,
|
||||||
Duration? animationDuration,
|
Duration? animationDuration,
|
||||||
@@ -161,7 +164,7 @@ class EditorState {
|
|||||||
rotationAngle: rotationAngle ?? this.rotationAngle,
|
rotationAngle: rotationAngle ?? this.rotationAngle,
|
||||||
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
|
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
|
||||||
flipVertical: flipVertical ?? this.flipVertical,
|
flipVertical: flipVertical ?? this.flipVertical,
|
||||||
aspectRatio: aspectRatio ?? this.aspectRatio,
|
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
|
||||||
animationDuration: animationDuration ?? this.animationDuration,
|
animationDuration: animationDuration ?? this.animationDuration,
|
||||||
originalWidth: originalWidth ?? this.originalWidth,
|
originalWidth: originalWidth ?? this.originalWidth,
|
||||||
originalHeight: originalHeight ?? this.originalHeight,
|
originalHeight: originalHeight ?? this.originalHeight,
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ class ShareActionButton extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -171,6 +173,7 @@ class ShareActionButton extends ConsumerWidget {
|
|||||||
buildContext.pop();
|
buildContext.pop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show download progress with a "Preparing" message
|
||||||
return preparingDialog;
|
return preparingDialog;
|
||||||
},
|
},
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
|
|||||||
@@ -122,14 +122,9 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
final localAsset = await _localPlaybackAsset(videoAsset);
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
|
final file = await StorageRepository().getFileForAsset(id);
|
||||||
if (localAsset != null) {
|
|
||||||
final file = localAsset.isMotionPhoto
|
|
||||||
? await StorageRepository().getMotionFileForAsset(localAsset)
|
|
||||||
: await StorageRepository().getFileForAsset(localAsset.id);
|
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -146,13 +141,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final remoteAsset = videoAsset as RemoteAsset;
|
final remoteId = (videoAsset as RemoteAsset).id;
|
||||||
|
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
|
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
|
||||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||||
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
|
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||||
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl';
|
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||||
|
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||||
|
|
||||||
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -161,43 +157,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LocalAsset?> _localPlaybackAsset(BaseAsset baseAsset) async {
|
|
||||||
if (!baseAsset.hasLocal) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalAsset? localAsset;
|
|
||||||
|
|
||||||
if (baseAsset is LocalAsset) {
|
|
||||||
localAsset = baseAsset;
|
|
||||||
} else {
|
|
||||||
final localId = (baseAsset as RemoteAsset).localId;
|
|
||||||
localAsset = localId != null ? await ref.read(assetServiceProvider).getLocalAsset(localId) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localAsset == null) {
|
|
||||||
_log.severe(
|
|
||||||
'Invariant violation: asset ${baseAsset.name} (${baseAsset.localId}) is marked `hasLocal` but local asset could not be retrieved',
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local
|
|
||||||
// have the same asset (hash), but only the remote properly recognizes it as a motion asset
|
|
||||||
// If this scenario occurs, fall back to using the remote asset
|
|
||||||
if (baseAsset.isMotionPhoto && !localAsset.isMotionPhoto) {
|
|
||||||
// Platform mismatch for motion photo, use remote instead
|
|
||||||
_log.warning(
|
|
||||||
'Mismatched local and remote motion states on ${baseAsset.name} (${baseAsset.localId}), local = ${localAsset.isMotionPhoto}, remote = ${baseAsset.isMotionPhoto}',
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return localAsset;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPlaybackReady() async {
|
void _onPlaybackReady() async {
|
||||||
if (!mounted || !widget.isCurrent) {
|
if (!mounted || !widget.isCurrent) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class _ScopedMapTimeline extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
timelineServiceProvider.overrideWith((ref) {
|
timelineServiceProvider.overrideWith((ref) {
|
||||||
@@ -43,16 +44,13 @@ class _ScopedMapTimeline extends StatelessWidget {
|
|||||||
throw Exception('User must be logged in to access archive');
|
throw Exception('User must be logged in to access archive');
|
||||||
}
|
}
|
||||||
|
|
||||||
final withPartners = ref.watch(mapStateProvider.select((s) => s.withPartners));
|
final users = ref.watch(mapStateProvider).withPartners
|
||||||
final users = withPartners ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id];
|
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||||
|
: [user.id];
|
||||||
|
|
||||||
final timelineService = ref
|
final timelineService = ref
|
||||||
.watch(timelineFactoryProvider)
|
.watch(timelineFactoryProvider)
|
||||||
.geographicMap(
|
.map(users, ref.watch(mapStateProvider).toOptions());
|
||||||
users,
|
|
||||||
() => ref.read(mapStateProvider).toOptions(),
|
|
||||||
ref.read(mapStateProvider.notifier).optionsStream,
|
|
||||||
);
|
|
||||||
ref.onDispose(timelineService.dispose);
|
ref.onDispose(timelineService.dispose);
|
||||||
return timelineService;
|
return timelineService;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -297,7 +297,8 @@ class _AssetTypeIcons extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isLivePhoto = asset.isMotionPhoto;
|
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
|
||||||
|
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
@@ -65,16 +63,11 @@ class MapState {
|
|||||||
class MapStateNotifier extends Notifier<MapState> {
|
class MapStateNotifier extends Notifier<MapState> {
|
||||||
MapStateNotifier();
|
MapStateNotifier();
|
||||||
|
|
||||||
final StreamController<TimelineMapOptions> _optionsController = StreamController.broadcast();
|
|
||||||
|
|
||||||
Stream<TimelineMapOptions> get optionsStream => _optionsController.stream;
|
|
||||||
|
|
||||||
bool setBounds(LatLngBounds bounds) {
|
bool setBounds(LatLngBounds bounds) {
|
||||||
if (state.bounds == bounds) {
|
if (state.bounds == bounds) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
state = state.copyWith(bounds: bounds);
|
state = state.copyWith(bounds: bounds);
|
||||||
_optionsController.add(state.toOptions());
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,14 +82,12 @@ class MapStateNotifier extends Notifier<MapState> {
|
|||||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||||
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
|
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
|
||||||
state = state.copyWith(onlyFavorites: isFavoriteOnly);
|
state = state.copyWith(onlyFavorites: isFavoriteOnly);
|
||||||
_optionsController.add(state.toOptions());
|
|
||||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
void switchIncludeArchived(bool isIncludeArchived) {
|
void switchIncludeArchived(bool isIncludeArchived) {
|
||||||
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
|
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
|
||||||
state = state.copyWith(includeArchived: isIncludeArchived);
|
state = state.copyWith(includeArchived: isIncludeArchived);
|
||||||
_optionsController.add(state.toOptions());
|
|
||||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,13 +100,11 @@ class MapStateNotifier extends Notifier<MapState> {
|
|||||||
void setRelativeTime(int relativeDays) {
|
void setRelativeTime(int relativeDays) {
|
||||||
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
|
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
|
||||||
state = state.copyWith(relativeDays: relativeDays);
|
state = state.copyWith(relativeDays: relativeDays);
|
||||||
_optionsController.add(state.toOptions());
|
|
||||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MapState build() {
|
MapState build() {
|
||||||
ref.onDispose(_optionsController.close);
|
|
||||||
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
||||||
return MapState(
|
return MapState(
|
||||||
themeMode: mapConfig.themeMode,
|
themeMode: mapConfig.themeMode,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
|
|||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class FixedSegment extends Segment {
|
class FixedSegment extends Segment {
|
||||||
final double tileHeight;
|
final double tileHeight;
|
||||||
@@ -41,8 +42,7 @@ class FixedSegment extends Segment {
|
|||||||
required super.headerExtent,
|
required super.headerExtent,
|
||||||
required super.spacing,
|
required super.spacing,
|
||||||
required super.header,
|
required super.header,
|
||||||
}) : assert(tileHeight != 0),
|
}) : mainAxisExtend = tileHeight + spacing;
|
||||||
mainAxisExtend = tileHeight + spacing;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double indexToLayoutOffset(int index) {
|
double indexToLayoutOffset(int index) {
|
||||||
@@ -90,6 +90,7 @@ class FixedSegment extends Segment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FixedSegmentRow extends ConsumerWidget {
|
class _FixedSegmentRow extends ConsumerWidget {
|
||||||
|
static final Logger _log = Logger('TimelineRow');
|
||||||
final int assetIndex;
|
final int assetIndex;
|
||||||
final int assetCount;
|
final int assetCount;
|
||||||
final double tileHeight;
|
final double tileHeight;
|
||||||
@@ -109,8 +110,20 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
|
||||||
|
final inRange = timelineService.hasRange(assetIndex, assetCount);
|
||||||
|
|
||||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
if (assetIndex == 0) {
|
||||||
|
_log.info(
|
||||||
|
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
|
||||||
|
'branch=${inRange
|
||||||
|
? "assets"
|
||||||
|
: isScrubbing
|
||||||
|
? "placeholder(scrubbing)"
|
||||||
|
: "future(load)"}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inRange) {
|
||||||
return _buildAssetRow(
|
return _buildAssetRow(
|
||||||
context,
|
context,
|
||||||
timelineService.getAssets(assetIndex, assetCount),
|
timelineService.getAssets(assetIndex, assetCount),
|
||||||
@@ -129,6 +142,13 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return _buildPlaceholder(context);
|
return _buildPlaceholder(context);
|
||||||
}
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
_log.warning(
|
||||||
|
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
|
||||||
|
snapshot.error,
|
||||||
|
snapshot.stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
|||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
import 'package:intl/intl.dart' hide TextDirection;
|
import 'package:intl/intl.dart' hide TextDirection;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||||
/// for quick navigation of the BoxScrollView.
|
/// for quick navigation of the BoxScrollView.
|
||||||
@@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
|
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
|
||||||
|
static final Logger _log = Logger('Scrubber');
|
||||||
String? _lastLabel;
|
String? _lastLabel;
|
||||||
double _thumbTopOffset = 0.0;
|
double _thumbTopOffset = 0.0;
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_log.info('Scrubber initState');
|
||||||
_isDragging = false;
|
_isDragging = false;
|
||||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||||
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
|
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
|
||||||
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
void didUpdateWidget(covariant Scrubber oldWidget) {
|
void didUpdateWidget(covariant Scrubber oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
|
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
|
||||||
|
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
|
||||||
|
if (oldEnd != newEnd) {
|
||||||
|
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
|
||||||
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
|
||||||
_monthCount = getMonthCount();
|
_monthCount = getMonthCount();
|
||||||
}
|
}
|
||||||
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
|
||||||
|
_log.warning(
|
||||||
|
'Scrubber dispose mid-scrub '
|
||||||
|
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
|
||||||
|
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.info('Scrubber dispose');
|
||||||
|
}
|
||||||
_thumbAnimationController.dispose();
|
_thumbAnimationController.dispose();
|
||||||
_labelAnimationController.dispose();
|
_labelAnimationController.dispose();
|
||||||
_fadeOutTimer?.cancel();
|
_fadeOutTimer?.cancel();
|
||||||
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragStart(DragStartDetails _) {
|
void _onDragStart(DragStartDetails _) {
|
||||||
|
_log.info('scrub dragStart');
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
_labelAnimationController.forward();
|
_labelAnimationController.forward();
|
||||||
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_scrubberHeight <= 0) {
|
if (_scrubberHeight <= 0) {
|
||||||
|
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
|
||||||
|
if (maxScrollExtent <= 0) {
|
||||||
|
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
|
||||||
|
}
|
||||||
|
|
||||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||||
_thumbAnimationController.forward();
|
_thumbAnimationController.forward();
|
||||||
}
|
}
|
||||||
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDragEnd(DragEndDetails _) {
|
void _onDragEnd(DragEndDetails _) {
|
||||||
|
_log.info('scrub dragEnd -> setScrubbing(false)');
|
||||||
_labelAnimationController.reverse();
|
_labelAnimationController.reverse();
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = false;
|
_isDragging = false;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
|
|||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class TimelineArgs {
|
class TimelineArgs {
|
||||||
final double maxWidth;
|
final double maxWidth;
|
||||||
@@ -71,14 +72,27 @@ class TimelineState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TimelineStateNotifier extends Notifier<TimelineState> {
|
class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||||
|
static final Logger _log = Logger('TimelineState');
|
||||||
|
|
||||||
void setScrubbing(bool isScrubbing) {
|
void setScrubbing(bool isScrubbing) {
|
||||||
|
if (state.isScrubbing != isScrubbing) {
|
||||||
|
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
|
||||||
|
}
|
||||||
state = state.copyWith(isScrubbing: isScrubbing);
|
state = state.copyWith(isScrubbing: isScrubbing);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setScrolling(bool isScrolling) {
|
void setScrolling(bool isScrolling) {
|
||||||
|
if (state.isScrolling != isScrolling) {
|
||||||
|
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
|
||||||
|
}
|
||||||
state = state.copyWith(isScrolling: isScrolling);
|
state = state.copyWith(isScrolling: isScrolling);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _callSite() {
|
||||||
|
final frames = StackTrace.current.toString().split('\n');
|
||||||
|
return frames.length > 2 ? frames[2].trim() : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
|
||||||
}
|
}
|
||||||
@@ -86,16 +100,29 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
|||||||
// This provider watches the buckets from the timeline service & args and serves the segments.
|
// This provider watches the buckets from the timeline service & args and serves the segments.
|
||||||
// It should be used only after the timeline service and timeline args provider is overridden
|
// It should be used only after the timeline service and timeline args provider is overridden
|
||||||
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
|
||||||
|
final log = Logger('TimelineSegmentProvider');
|
||||||
|
|
||||||
final args = ref.watch(timelineArgsProvider);
|
final args = ref.watch(timelineArgsProvider);
|
||||||
final columnCount = args.columnCount;
|
final columnCount = args.columnCount;
|
||||||
final spacing = args.spacing;
|
final spacing = args.spacing;
|
||||||
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
|
||||||
|
log.info(
|
||||||
|
'timelineSegmentProvider: maxWidth=${args.maxWidth}, spacing=$spacing, availableTileWidth=$availableTileWidth, columnCount=$columnCount',
|
||||||
|
);
|
||||||
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
final tileExtent = math.max(0, availableTileWidth) / columnCount;
|
||||||
|
log.info(
|
||||||
|
'timelineSegmentProvider: tileExtent=$tileExtent (availableTileWidth=$availableTileWidth / columnCount=$columnCount)',
|
||||||
|
);
|
||||||
|
|
||||||
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy));
|
||||||
|
|
||||||
final timelineService = ref.watch(timelineServiceProvider);
|
final timelineService = ref.watch(timelineServiceProvider);
|
||||||
yield* timelineService.watchBuckets().map((buckets) {
|
yield* timelineService.watchBuckets().map((buckets) {
|
||||||
|
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||||
|
Logger('TimelineService').info(
|
||||||
|
'[${timelineService.origin}] segment layout: '
|
||||||
|
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
|
||||||
|
);
|
||||||
return FixedSegmentBuilder(
|
return FixedSegmentBuilder(
|
||||||
buckets: buckets,
|
buckets: buckets,
|
||||||
tileHeight: tileExtent,
|
tileHeight: tileExtent,
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|||||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class Timeline extends StatelessWidget {
|
class Timeline extends StatefulWidget {
|
||||||
const Timeline({
|
const Timeline({
|
||||||
super.key,
|
super.key,
|
||||||
this.topSliverWidget,
|
this.topSliverWidget,
|
||||||
@@ -45,6 +46,7 @@ class Timeline extends StatelessWidget {
|
|||||||
this.readOnly = false,
|
this.readOnly = false,
|
||||||
this.persistentBottomBar = false,
|
this.persistentBottomBar = false,
|
||||||
this.loadingWidget,
|
this.loadingWidget,
|
||||||
|
this.onRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -60,37 +62,63 @@ class Timeline extends StatelessWidget {
|
|||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
final bool persistentBottomBar;
|
final bool persistentBottomBar;
|
||||||
final Widget? loadingWidget;
|
final Widget? loadingWidget;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Timeline> createState() => _TimelineState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineState extends State<Timeline> {
|
||||||
|
bool _rebuildTrigger = false;
|
||||||
|
|
||||||
|
Future<void> _handleRefresh() async {
|
||||||
|
if (widget.onRefresh == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onRefresh?.call();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _rebuildTrigger = !_rebuildTrigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (_, constraints) => ProviderScope(
|
builder: (_, constraints) {
|
||||||
overrides: [
|
if (constraints.maxWidth <= 0) {
|
||||||
timelineArgsProvider.overrideWith(
|
return widget.loadingWidget ?? const SizedBox.shrink();
|
||||||
(ref) => TimelineArgs(
|
}
|
||||||
maxWidth: constraints.maxWidth,
|
return ProviderScope(
|
||||||
maxHeight: constraints.maxHeight,
|
key: ValueKey(_rebuildTrigger),
|
||||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
overrides: [
|
||||||
showStorageIndicator: showStorageIndicator,
|
timelineArgsProvider.overrideWith(
|
||||||
withStack: withStack,
|
(ref) => TimelineArgs(
|
||||||
groupBy: groupBy,
|
maxWidth: constraints.maxWidth,
|
||||||
|
maxHeight: constraints.maxHeight,
|
||||||
|
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||||
|
showStorageIndicator: widget.showStorageIndicator,
|
||||||
|
withStack: widget.withStack,
|
||||||
|
groupBy: widget.groupBy,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
||||||
|
],
|
||||||
|
child: _SliverTimeline(
|
||||||
|
topSliverWidget: widget.topSliverWidget,
|
||||||
|
topSliverWidgetHeight: widget.topSliverWidgetHeight,
|
||||||
|
bottomSliverWidget: widget.bottomSliverWidget,
|
||||||
|
appBar: widget.appBar,
|
||||||
|
bottomSheet: widget.bottomSheet,
|
||||||
|
withScrubber: widget.withScrubber,
|
||||||
|
persistentBottomBar: widget.persistentBottomBar,
|
||||||
|
snapToMonth: widget.snapToMonth,
|
||||||
|
maxWidth: constraints.maxWidth,
|
||||||
|
loadingWidget: widget.loadingWidget,
|
||||||
|
onRefresh: widget.onRefresh == null ? null : _handleRefresh,
|
||||||
),
|
),
|
||||||
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
|
);
|
||||||
],
|
},
|
||||||
child: _SliverTimeline(
|
|
||||||
topSliverWidget: topSliverWidget,
|
|
||||||
topSliverWidgetHeight: topSliverWidgetHeight,
|
|
||||||
bottomSliverWidget: bottomSliverWidget,
|
|
||||||
appBar: appBar,
|
|
||||||
bottomSheet: bottomSheet,
|
|
||||||
withScrubber: withScrubber,
|
|
||||||
persistentBottomBar: persistentBottomBar,
|
|
||||||
snapToMonth: snapToMonth,
|
|
||||||
maxWidth: constraints.maxWidth,
|
|
||||||
loadingWidget: loadingWidget,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +146,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
this.snapToMonth = true,
|
this.snapToMonth = true,
|
||||||
this.maxWidth,
|
this.maxWidth,
|
||||||
this.loadingWidget,
|
this.loadingWidget,
|
||||||
|
this.onRefresh,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -130,12 +159,14 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
final bool snapToMonth;
|
final bool snapToMonth;
|
||||||
final double? maxWidth;
|
final double? maxWidth;
|
||||||
final Widget? loadingWidget;
|
final Widget? loadingWidget;
|
||||||
|
final Future<void> Function()? onRefresh;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SliverTimelineState();
|
ConsumerState createState() => _SliverTimelineState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBindingObserver {
|
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
|
static final Logger _log = Logger('Timeline');
|
||||||
late final ScrollController _scrollController;
|
late final ScrollController _scrollController;
|
||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
|
|
||||||
@@ -153,7 +184,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
_log.info('SliverTimeline initState');
|
||||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||||
|
|
||||||
@@ -179,14 +210,21 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture iOS status bar tap
|
|
||||||
@override
|
|
||||||
void handleStatusBarTap() => _scrollToTop();
|
|
||||||
|
|
||||||
void _onEvent(Event event) {
|
void _onEvent(Event event) {
|
||||||
|
_log.info('event ${event.runtimeType}');
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case ScrollToTopEvent():
|
case ScrollToTopEvent():
|
||||||
_scrollToTop();
|
{
|
||||||
|
final timelineState = ref.read(timelineStateProvider.notifier);
|
||||||
|
timelineState.setScrubbing(true);
|
||||||
|
_scrollController
|
||||||
|
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
||||||
|
.whenComplete(() {
|
||||||
|
_log.info('ScrollToTop animation done -> setScrubbing(false)');
|
||||||
|
timelineState.setScrubbing(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case ScrollToDateEvent scrollToDateEvent:
|
case ScrollToDateEvent scrollToDateEvent:
|
||||||
_scrollToDate(scrollToDateEvent.date);
|
_scrollToDate(scrollToDateEvent.date);
|
||||||
case TimelineReloadEvent():
|
case TimelineReloadEvent():
|
||||||
@@ -244,24 +282,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
_log.info('SliverTimeline dispose');
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_eventSubscription?.cancel();
|
_eventSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToTop() {
|
|
||||||
if (!_scrollController.hasClients) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final timelineState = ref.read(timelineStateProvider.notifier);
|
|
||||||
timelineState.setScrubbing(true);
|
|
||||||
_scrollController
|
|
||||||
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
|
|
||||||
.whenComplete(() => timelineState.setScrubbing(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scrollToDate(DateTime date) {
|
void _scrollToDate(DateTime date) {
|
||||||
final timelineState = ref.read(timelineStateProvider.notifier);
|
final timelineState = ref.read(timelineStateProvider.notifier);
|
||||||
final asyncSegments = ref.read(timelineSegmentProvider);
|
final asyncSegments = ref.read(timelineSegmentProvider);
|
||||||
@@ -297,8 +323,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
)
|
)
|
||||||
.whenComplete(() => timelineState.setScrubbing(false));
|
.whenComplete(() {
|
||||||
|
_log.info('ScrollToDate animation done -> setScrubbing(false)');
|
||||||
|
timelineState.setScrubbing(false);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
|
||||||
timelineState.setScrubbing(false);
|
timelineState.setScrubbing(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -392,9 +422,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
child: PrimaryScrollController(
|
child: PrimaryScrollController(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
// This removes the built in Scaffold `handleStatusBarTap` implementation, preventing duplicate
|
|
||||||
// events when we provide our own
|
|
||||||
primary: false,
|
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
body: asyncSegments.widgetWhen(
|
body: asyncSegments.widgetWhen(
|
||||||
@@ -439,7 +466,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
final Widget timeline;
|
Widget timeline;
|
||||||
if (widget.withScrubber) {
|
if (widget.withScrubber) {
|
||||||
timeline = Scrubber(
|
timeline = Scrubber(
|
||||||
snapToMonth: widget.snapToMonth,
|
snapToMonth: widget.snapToMonth,
|
||||||
@@ -454,6 +481,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
|||||||
} else {
|
} else {
|
||||||
timeline = grid;
|
timeline = grid;
|
||||||
}
|
}
|
||||||
|
if (widget.onRefresh != null) {
|
||||||
|
timeline = RefreshIndicator(onRefresh: widget.onRefresh!, child: timeline);
|
||||||
|
}
|
||||||
|
|
||||||
return RawGestureDetector(
|
return RawGestureDetector(
|
||||||
gestures: {
|
gestures: {
|
||||||
|
|||||||
@@ -101,8 +101,20 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelSync() async {
|
||||||
|
final backgroundManager = _ref.read(backgroundSyncProvider);
|
||||||
|
final nativeSync = _ref.read(nativeSyncApiProvider);
|
||||||
|
await Future.wait([
|
||||||
|
nativeSync.cancelSync(),
|
||||||
|
nativeSync.cancelHashing(),
|
||||||
|
backgroundManager.cancel(),
|
||||||
|
]).timeout(const Duration(seconds: 5), onTimeout: () => const <void>[]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleBetaTimelineResume() async {
|
Future<void> _handleBetaTimelineResume() async {
|
||||||
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
|
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
|
||||||
|
_log.info("Handling beta timeline resume");
|
||||||
|
await _cancelSync();
|
||||||
|
|
||||||
// Give isolates time to complete any ongoing database transactions
|
// Give isolates time to complete any ongoing database transactions
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
@@ -196,6 +208,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
|||||||
Future<void> _performPause() {
|
Future<void> _performPause() {
|
||||||
if (_ref.read(authProvider).isAuthenticated) {
|
if (_ref.read(authProvider).isAuthenticated) {
|
||||||
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
|
||||||
|
unawaited(_cancelSync());
|
||||||
|
|
||||||
_ref.read(websocketProvider.notifier).disconnect();
|
_ref.read(websocketProvider.notifier).disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
|
|||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('TimelineProvider');
|
||||||
|
|
||||||
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
|
||||||
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
|
||||||
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
|
|||||||
(ref) {
|
(ref) {
|
||||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||||
ref.onDispose(timelineService.dispose);
|
_log.info('main TimelineService built users=$timelineUsers');
|
||||||
|
ref.onDispose(() {
|
||||||
|
_log.info('main TimelineService disposed');
|
||||||
|
timelineService.dispose();
|
||||||
|
});
|
||||||
return timelineService;
|
return timelineService;
|
||||||
},
|
},
|
||||||
// Empty dependencies to inform the framework that this provider
|
// Empty dependencies to inform the framework that this provider
|
||||||
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
|
|||||||
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||||
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
|
||||||
if (currentUserId == null) {
|
if (currentUserId == null) {
|
||||||
|
_log.info('timelineUsers: currentUserId=null -> []');
|
||||||
return Stream.value([]);
|
return Stream.value([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
|
||||||
|
_log.info('timelineUsers emission: $users');
|
||||||
|
return users;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import 'package:immich_mobile/repositories/upload.repository.dart';
|
|||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart' as api;
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
final backgroundUploadServiceProvider = Provider((ref) {
|
final backgroundUploadServiceProvider = Provider((ref) {
|
||||||
@@ -336,8 +335,7 @@ class BackgroundUploadService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
|
final fields = {'livePhotoVideoId': livePhotoVideoId};
|
||||||
final fields = {'livePhotoVideoId': livePhotoVideoId, 'visibility': api.AssetVisibility.hidden.value};
|
|
||||||
|
|
||||||
final requiresWiFi = _shouldRequireWiFi(asset);
|
final requiresWiFi = _shouldRequireWiFi(asset);
|
||||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||||
@@ -19,7 +19,6 @@ import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
|||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||||
|
|
||||||
@@ -339,8 +338,7 @@ class ForegroundUploadService {
|
|||||||
final livePhotoResult = await _uploadRepository.uploadFile(
|
final livePhotoResult = await _uploadRepository.uploadFile(
|
||||||
file: livePhotoFile,
|
file: livePhotoFile,
|
||||||
originalFileName: livePhotoTitle,
|
originalFileName: livePhotoTitle,
|
||||||
// Visibility hidden on upload to prevent the server from running regular jobs on the live photo asset
|
fields: fields,
|
||||||
fields: {...fields, 'visibility': AssetVisibility.hidden.value},
|
|
||||||
cancelToken: cancelToken,
|
cancelToken: cancelToken,
|
||||||
onProgress: onProgress != null
|
onProgress: onProgress != null
|
||||||
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
|
? (bytes, totalBytes) => onProgress(asset.localId!, livePhotoTitle, bytes, totalBytes)
|
||||||
|
|||||||
Generated
-1
@@ -452,7 +452,6 @@ Class | Method | HTTP request | Description
|
|||||||
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
|
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
|
||||||
- [FoldersResponse](doc//FoldersResponse.md)
|
- [FoldersResponse](doc//FoldersResponse.md)
|
||||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||||
- [HlsVideoResolution](doc//HlsVideoResolution.md)
|
|
||||||
- [ImageFormat](doc//ImageFormat.md)
|
- [ImageFormat](doc//ImageFormat.md)
|
||||||
- [IntegrityReport](doc//IntegrityReport.md)
|
- [IntegrityReport](doc//IntegrityReport.md)
|
||||||
- [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md)
|
- [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md)
|
||||||
|
|||||||
Generated
-1
@@ -173,7 +173,6 @@ part 'model/face_dto.dart';
|
|||||||
part 'model/facial_recognition_config.dart';
|
part 'model/facial_recognition_config.dart';
|
||||||
part 'model/folders_response.dart';
|
part 'model/folders_response.dart';
|
||||||
part 'model/folders_update.dart';
|
part 'model/folders_update.dart';
|
||||||
part 'model/hls_video_resolution.dart';
|
|
||||||
part 'model/image_format.dart';
|
part 'model/image_format.dart';
|
||||||
part 'model/integrity_report.dart';
|
part 'model/integrity_report.dart';
|
||||||
part 'model/integrity_report_response_dto.dart';
|
part 'model/integrity_report_response_dto.dart';
|
||||||
|
|||||||
Generated
-2
@@ -391,8 +391,6 @@ class ApiClient {
|
|||||||
return FoldersResponse.fromJson(value);
|
return FoldersResponse.fromJson(value);
|
||||||
case 'FoldersUpdate':
|
case 'FoldersUpdate':
|
||||||
return FoldersUpdate.fromJson(value);
|
return FoldersUpdate.fromJson(value);
|
||||||
case 'HlsVideoResolution':
|
|
||||||
return HlsVideoResolutionTypeTransformer().decode(value);
|
|
||||||
case 'ImageFormat':
|
case 'ImageFormat':
|
||||||
return ImageFormatTypeTransformer().decode(value);
|
return ImageFormatTypeTransformer().decode(value);
|
||||||
case 'IntegrityReport':
|
case 'IntegrityReport':
|
||||||
|
|||||||
Generated
-3
@@ -106,9 +106,6 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is Colorspace) {
|
if (value is Colorspace) {
|
||||||
return ColorspaceTypeTransformer().encode(value).toString();
|
return ColorspaceTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
if (value is HlsVideoResolution) {
|
|
||||||
return HlsVideoResolutionTypeTransformer().encode(value).toString();
|
|
||||||
}
|
|
||||||
if (value is ImageFormat) {
|
if (value is ImageFormat) {
|
||||||
return ImageFormatTypeTransformer().encode(value).toString();
|
return ImageFormatTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
|||||||
-94
@@ -1,94 +0,0 @@
|
|||||||
//
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
/// HLS video resolution
|
|
||||||
class HlsVideoResolution {
|
|
||||||
/// Instantiate a new enum with the provided [value].
|
|
||||||
const HlsVideoResolution._(this.value);
|
|
||||||
|
|
||||||
/// The underlying value of this enum member.
|
|
||||||
final int value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => value.toString();
|
|
||||||
|
|
||||||
int toJson() => value;
|
|
||||||
|
|
||||||
static const number480 = HlsVideoResolution._(480);
|
|
||||||
static const number720 = HlsVideoResolution._(720);
|
|
||||||
static const number1080 = HlsVideoResolution._(1080);
|
|
||||||
static const number1440 = HlsVideoResolution._(1440);
|
|
||||||
static const number2160 = HlsVideoResolution._(2160);
|
|
||||||
|
|
||||||
/// List of all possible values in this [enum][HlsVideoResolution].
|
|
||||||
static const values = <HlsVideoResolution>[
|
|
||||||
number480,
|
|
||||||
number720,
|
|
||||||
number1080,
|
|
||||||
number1440,
|
|
||||||
number2160,
|
|
||||||
];
|
|
||||||
|
|
||||||
static HlsVideoResolution? fromJson(dynamic value) => HlsVideoResolutionTypeTransformer().decode(value);
|
|
||||||
|
|
||||||
static List<HlsVideoResolution> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <HlsVideoResolution>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = HlsVideoResolution.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transformation class that can [encode] an instance of [HlsVideoResolution] to int,
|
|
||||||
/// and [decode] dynamic data back to [HlsVideoResolution].
|
|
||||||
class HlsVideoResolutionTypeTransformer {
|
|
||||||
factory HlsVideoResolutionTypeTransformer() => _instance ??= const HlsVideoResolutionTypeTransformer._();
|
|
||||||
|
|
||||||
const HlsVideoResolutionTypeTransformer._();
|
|
||||||
|
|
||||||
int encode(HlsVideoResolution data) => data.value;
|
|
||||||
|
|
||||||
/// Decodes a [dynamic value][data] to a HlsVideoResolution.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
HlsVideoResolution? decode(dynamic data, {bool allowNull = true}) {
|
|
||||||
if (data != null) {
|
|
||||||
switch (data) {
|
|
||||||
case 480: return HlsVideoResolution.number480;
|
|
||||||
case 720: return HlsVideoResolution.number720;
|
|
||||||
case 1080: return HlsVideoResolution.number1080;
|
|
||||||
case 1440: return HlsVideoResolution.number1440;
|
|
||||||
case 2160: return HlsVideoResolution.number2160;
|
|
||||||
default:
|
|
||||||
if (!allowNull) {
|
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Singleton [HlsVideoResolutionTypeTransformer] instance.
|
|
||||||
static HlsVideoResolutionTypeTransformer? _instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -14,40 +14,26 @@ class SystemConfigFFmpegRealtimeDto {
|
|||||||
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
|
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
|
||||||
SystemConfigFFmpegRealtimeDto({
|
SystemConfigFFmpegRealtimeDto({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
this.resolutions = const [],
|
|
||||||
this.videoCodecs = const [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Enable real-time HLS transcoding (alpha)
|
/// Enable real-time HLS transcoding (alpha)
|
||||||
bool enabled;
|
bool enabled;
|
||||||
|
|
||||||
/// Resolutions to use for real-time HLS transcoding
|
|
||||||
List<HlsVideoResolution> resolutions;
|
|
||||||
|
|
||||||
/// Video codecs to use for real-time HLS transcoding
|
|
||||||
List<VideoCodec> videoCodecs;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
|
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled;
|
||||||
_deepEquality.equals(other.resolutions, resolutions) &&
|
|
||||||
_deepEquality.equals(other.videoCodecs, videoCodecs);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(enabled.hashCode) +
|
(enabled.hashCode);
|
||||||
(resolutions.hashCode) +
|
|
||||||
(videoCodecs.hashCode);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled, resolutions=$resolutions, videoCodecs=$videoCodecs]';
|
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'enabled'] = this.enabled;
|
json[r'enabled'] = this.enabled;
|
||||||
json[r'resolutions'] = this.resolutions;
|
|
||||||
json[r'videoCodecs'] = this.videoCodecs;
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +47,6 @@ class SystemConfigFFmpegRealtimeDto {
|
|||||||
|
|
||||||
return SystemConfigFFmpegRealtimeDto(
|
return SystemConfigFFmpegRealtimeDto(
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||||
resolutions: HlsVideoResolution.listFromJson(json[r'resolutions']),
|
|
||||||
videoCodecs: VideoCodec.listFromJson(json[r'videoCodecs']),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -111,8 +95,6 @@ class SystemConfigFFmpegRealtimeDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'enabled',
|
'enabled',
|
||||||
'resolutions',
|
|
||||||
'videoCodecs',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ class SystemConfigIntegrityChecksumJob {
|
|||||||
/// Percentage limit of the integrity checksum job
|
/// Percentage limit of the integrity checksum job
|
||||||
///
|
///
|
||||||
/// Minimum value: 0
|
/// Minimum value: 0
|
||||||
/// Maximum value: 1
|
/// Maximum value: 9007199254740991
|
||||||
double percentageLimit;
|
int percentageLimit;
|
||||||
|
|
||||||
/// How long the integrity checksum job may run for
|
/// How long the integrity checksum job may run for
|
||||||
///
|
///
|
||||||
@@ -75,7 +75,7 @@ class SystemConfigIntegrityChecksumJob {
|
|||||||
return SystemConfigIntegrityChecksumJob(
|
return SystemConfigIntegrityChecksumJob(
|
||||||
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
|
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||||
percentageLimit: mapValueOfType<double>(json, r'percentageLimit')!,
|
percentageLimit: mapValueOfType<int>(json, r'percentageLimit')!,
|
||||||
timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
|
timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -366,18 +366,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: drift
|
name: drift
|
||||||
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
|
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.33.0"
|
version: "2.34.0"
|
||||||
drift_dev:
|
drift_dev:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: drift_dev
|
name: drift_dev
|
||||||
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
|
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.33.0"
|
version: "2.34.0"
|
||||||
drift_sqlite_async:
|
drift_sqlite_async:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ dependencies:
|
|||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
device_info_plus: ^12.4.0
|
device_info_plus: ^12.4.0
|
||||||
diacritic: ^0.1.6
|
diacritic: ^0.1.6
|
||||||
drift: ^2.32.1
|
drift: ^2.34.0
|
||||||
drift_sqlite_async: 0.3.1
|
drift_sqlite_async: 0.3.1
|
||||||
dynamic_color: ^1.8.1
|
dynamic_color: ^1.8.1
|
||||||
easy_localization: ^3.0.8
|
easy_localization: ^3.0.8
|
||||||
@@ -96,7 +96,7 @@ dev_dependencies:
|
|||||||
auto_route_generator: ^10.5.0
|
auto_route_generator: ^10.5.0
|
||||||
build_runner: ^2.13.1
|
build_runner: ^2.13.1
|
||||||
# Drift generator
|
# Drift generator
|
||||||
drift_dev: ^2.32.1
|
drift_dev: ^2.34.0
|
||||||
fake_async: ^1.3.3
|
fake_async: ^1.3.3
|
||||||
file: ^7.0.1 # for MemoryFileSystem
|
file: ^7.0.1 # for MemoryFileSystem
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|||||||
-4
@@ -34,7 +34,6 @@ import 'schema_v27.dart' as v27;
|
|||||||
import 'schema_v28.dart' as v28;
|
import 'schema_v28.dart' as v28;
|
||||||
import 'schema_v29.dart' as v29;
|
import 'schema_v29.dart' as v29;
|
||||||
import 'schema_v30.dart' as v30;
|
import 'schema_v30.dart' as v30;
|
||||||
import 'schema_v31.dart' as v31;
|
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -100,8 +99,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
return v29.DatabaseAtV29(db);
|
return v29.DatabaseAtV29(db);
|
||||||
case 30:
|
case 30:
|
||||||
return v30.DatabaseAtV30(db);
|
return v30.DatabaseAtV30(db);
|
||||||
case 31:
|
|
||||||
return v31.DatabaseAtV31(db);
|
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
@@ -138,6 +135,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
28,
|
28,
|
||||||
29,
|
29,
|
||||||
30,
|
30,
|
||||||
31,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
-10032
File diff suppressed because it is too large
Load Diff
@@ -70,36 +70,4 @@ void main() {
|
|||||||
expect((assets.first as RemoteAsset).id, asset.id);
|
expect((assets.first as RemoteAsset).id, asset.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('live photos', () {
|
|
||||||
test('remote-only live photo contains livePhotoVideoId and is marked as a motion photo', () async {
|
|
||||||
final user = await ctx.newUser();
|
|
||||||
final asset = await ctx.newRemoteAsset(ownerId: user.id, livePhotoVideoId: 'motion-photo-1');
|
|
||||||
|
|
||||||
final assets = await sut.main([user.id], .day).assetSource(0, 10);
|
|
||||||
|
|
||||||
expect(assets, hasLength(1));
|
|
||||||
final remote = assets.single as RemoteAsset;
|
|
||||||
expect(remote.id, asset.id);
|
|
||||||
expect(remote.livePhotoVideoId, 'motion-photo-1');
|
|
||||||
expect(remote.isMotionPhoto, isTrue);
|
|
||||||
expect(remote.localId, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('merged live photo resolves localId and is marked as a motion photo', () async {
|
|
||||||
final user = await ctx.newUser();
|
|
||||||
const checksum = 'shared-live-photo-checksum';
|
|
||||||
final asset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum, livePhotoVideoId: 'motion-photo-2');
|
|
||||||
final local = await ctx.newLocalAsset(checksum: checksum);
|
|
||||||
|
|
||||||
final assets = await sut.main([user.id], .day).assetSource(0, 10);
|
|
||||||
|
|
||||||
expect(assets, hasLength(1));
|
|
||||||
final remote = assets.single as RemoteAsset;
|
|
||||||
expect(remote.id, asset.id);
|
|
||||||
expect(remote.livePhotoVideoId, 'motion-photo-2');
|
|
||||||
expect(remote.isMotionPhoto, isTrue);
|
|
||||||
expect(remote.localId, local.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ void main() {
|
|||||||
expect(task, isNotNull);
|
expect(task, isNotNull);
|
||||||
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
|
expect(task!.fields['filename'], equals('OriginalLivePhoto.HEIC'));
|
||||||
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
|
expect(task.fields['livePhotoVideoId'], equals('video-id-123'));
|
||||||
expect(task.fields['visibility'], equals('hidden'));
|
|
||||||
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
verify(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,7 +333,6 @@ void main() {
|
|||||||
expect(task, isNotNull);
|
expect(task, isNotNull);
|
||||||
expect(task!.fields.containsKey('metadata'), isTrue);
|
expect(task!.fields.containsKey('metadata'), isTrue);
|
||||||
expect(task.fields['livePhotoVideoId'], equals('video-123'));
|
expect(task.fields['livePhotoVideoId'], equals('video-123'));
|
||||||
expect(task.fields['visibility'], equals('hidden'));
|
|
||||||
|
|
||||||
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
final metadata = jsonDecode(task.fields['metadata']!) as List;
|
||||||
expect(metadata, hasLength(1));
|
expect(metadata, hasLength(1));
|
||||||
|
|||||||
@@ -19120,17 +19120,6 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"HlsVideoResolution": {
|
|
||||||
"description": "HLS video resolution",
|
|
||||||
"enum": [
|
|
||||||
480,
|
|
||||||
720,
|
|
||||||
1080,
|
|
||||||
1440,
|
|
||||||
2160
|
|
||||||
],
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"ImageFormat": {
|
"ImageFormat": {
|
||||||
"description": "Image format",
|
"description": "Image format",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -25765,26 +25754,10 @@
|
|||||||
"enabled": {
|
"enabled": {
|
||||||
"description": "Enable real-time HLS transcoding (alpha)",
|
"description": "Enable real-time HLS transcoding (alpha)",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
|
||||||
"resolutions": {
|
|
||||||
"description": "Resolutions to use for real-time HLS transcoding",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/HlsVideoResolution"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"videoCodecs": {
|
|
||||||
"description": "Video codecs to use for real-time HLS transcoding",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/VideoCodec"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled"
|
||||||
"resolutions",
|
|
||||||
"videoCodecs"
|
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@@ -25917,10 +25890,9 @@
|
|||||||
},
|
},
|
||||||
"percentageLimit": {
|
"percentageLimit": {
|
||||||
"description": "Percentage limit of the integrity checksum job",
|
"description": "Percentage limit of the integrity checksum job",
|
||||||
"format": "double",
|
"maximum": 9007199254740991,
|
||||||
"maximum": 1,
|
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "number"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"timeLimit": {
|
"timeLimit": {
|
||||||
"description": "How long the integrity checksum job may run for",
|
"description": "How long the integrity checksum job may run for",
|
||||||
|
|||||||
@@ -151,14 +151,14 @@ const methods = wrapper<Manifest>({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
webhook: ({ config, data, functions, type, trigger }) => {
|
webhook: ({ config, data, functions, type, trigger }) => {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
if (config.headerName && config.headerValue) {
|
if (config.headerName && config.headerValue) {
|
||||||
headers[config.headerName] = config.headerValue;
|
headers[config.headerName] = config.headerValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
|
|
||||||
functions.httpRequest(config.url, {
|
functions.httpRequest(config.url, {
|
||||||
method: config.method ?? 'POST',
|
method: config.method ?? 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -2302,10 +2302,6 @@ export type SystemConfigBackupsDto = {
|
|||||||
export type SystemConfigFFmpegRealtimeDto = {
|
export type SystemConfigFFmpegRealtimeDto = {
|
||||||
/** Enable real-time HLS transcoding (alpha) */
|
/** Enable real-time HLS transcoding (alpha) */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Resolutions to use for real-time HLS transcoding */
|
|
||||||
resolutions: HlsVideoResolution[];
|
|
||||||
/** Video codecs to use for real-time HLS transcoding */
|
|
||||||
videoCodecs: VideoCodec[];
|
|
||||||
};
|
};
|
||||||
export type SystemConfigFFmpegDto = {
|
export type SystemConfigFFmpegDto = {
|
||||||
accel: TranscodeHWAccel;
|
accel: TranscodeHWAccel;
|
||||||
@@ -7633,13 +7629,6 @@ export enum CQMode {
|
|||||||
Cqp = "cqp",
|
Cqp = "cqp",
|
||||||
Icq = "icq"
|
Icq = "icq"
|
||||||
}
|
}
|
||||||
export enum HlsVideoResolution {
|
|
||||||
$480 = 480,
|
|
||||||
$720 = 720,
|
|
||||||
$1080 = 1080,
|
|
||||||
$1440 = 1440,
|
|
||||||
$2160 = 2160
|
|
||||||
}
|
|
||||||
export enum ToneMapping {
|
export enum ToneMapping {
|
||||||
Hable = "hable",
|
Hable = "hable",
|
||||||
Mobius = "mobius",
|
Mobius = "mobius",
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<a href="README_pt_BR.md">Português Brasileiro</a>
|
<a href="README_pt_BR.md">Português Brasileiro</a>
|
||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<br/>
|
|
||||||
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: AGPLv3"></a>
|
|
||||||
<a href="https://discord.immich.app">
|
|
||||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
|
||||||
</a>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Immich">
|
|
||||||
</p>
|
|
||||||
<h3 align="center">ഫോട്ടോകളും വീഡിയോകളും കൈകാര്യം ചെയ്യുന്നതിനുള്ള ഉയർന്ന കാര്യക്ഷമതയുള്ള സെൽഫ്-ഹോസ്റ്റഡ് ആപ്ലിക്കേഷൻ</h3>
|
|
||||||
<br/>
|
|
||||||
<a href="https://immich.app">
|
|
||||||
<img src="../design/immich-screenshots.png" title="പ്രധാന സ്ക്രീൻഷോട്ട്">
|
|
||||||
</a>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="../README.md">English</a>
|
|
||||||
<a href="README_ca_ES.md">Català</a>
|
|
||||||
<a href="README_es_ES.md">Español</a>
|
|
||||||
<a href="README_fr_FR.md">Français</a>
|
|
||||||
<a href="README_it_IT.md">Italiano</a>
|
|
||||||
<a href="README_ja_JP.md">日本語</a>
|
|
||||||
<a href="README_ko_KR.md">한국어</a>
|
|
||||||
<a href="README_de_DE.md">Deutsch</a>
|
|
||||||
<a href="README_nl_NL.md">Nederlands</a>
|
|
||||||
<a href="README_tr_TR.md">Türkçe</a>
|
|
||||||
<a href="README_zh_CN.md">简体中文</a>
|
|
||||||
<a href="README_zh_TW.md">正體中文</a>
|
|
||||||
<a href="README_uk_UA.md">Українська</a>
|
|
||||||
<a href="README_ru_RU.md">Русский</a>
|
|
||||||
<a href="README_pt_BR.md">Português Brasileiro</a>
|
|
||||||
<a href="README_sv_SE.md">Svenska</a>
|
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> ⚠️ നിങ്ങളുടെ വിലയേറിയ ഫോട്ടോകൾക്കും വീഡിയോകൾക്കും എല്ലായ്പ്പോഴും [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) ബാക്കപ്പ് പ്ലാൻ പിന്തുടരുക!
|
|
||||||
>
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> ഇൻസ്റ്റാളേഷൻ ഗൈഡുകൾ ഉൾപ്പെടെയുള്ള പ്രധാന ഡോക്യുമെന്റേഷൻ https://immich.app/ എന്ന വെബ്സൈറ്റിൽ നിങ്ങൾക്ക് കണ്ടെത്താം.
|
|
||||||
|
|
||||||
## ലിങ്കുകൾ
|
|
||||||
|
|
||||||
- [ഡോക്യുമെന്റേഷൻ](https://docs.immich.app/)
|
|
||||||
- [വിവരങ്ങൾ](https://docs.immich.app/overview/introduction)
|
|
||||||
- [ഇൻസ്റ്റാളേഷൻ](https://docs.immich.app/install/requirements)
|
|
||||||
- [റോഡ്മാപ്പ്](https://immich.app/roadmap)
|
|
||||||
- [ഡെമോ](#ഡെമോ)
|
|
||||||
- [സവിശേഷതകൾ](#സവിശേഷതകൾ)
|
|
||||||
- [വിവർത്തനങ്ങൾ](https://docs.immich.app/developer/translations)
|
|
||||||
- [സംഭാവന നൽകൽ](https://docs.immich.app/overview/support-the-project)
|
|
||||||
|
|
||||||
## ഡെമോ
|
|
||||||
|
|
||||||
ഡെമോ വെബ്സൈറ്റ് [ഇവിടെ](https://demo.immich.app) ആക്സസ് ചെയ്യാം. മൊബൈൽ ആപ്പിന്റെ ഡെമോ കാണാൻ ആപ്പ് ഇൻസ്റ്റാൾ ചെയ്ത ശേഷം, `Server Endpoint URL` എന്ന സെറ്റിങ്ങിൽ നിങ്ങൾക്ക് `https://demo.immich.app` ഉപയോഗിക്കാം.
|
|
||||||
|
|
||||||
### ലോഗിൻ വിവരങ്ങൾ
|
|
||||||
|
|
||||||
| ഇമെയിൽ | പാസ്വേഡ് |
|
|
||||||
| --------------- | -------- |
|
|
||||||
| demo@immich.app | demo |
|
|
||||||
|
|
||||||
## സവിശേഷതകൾ
|
|
||||||
|
|
||||||
| സവിശേഷതകൾ | മൊബൈൽ | വെബ് |
|
|
||||||
| :-------------------------------------------------- | ------ | --- |
|
|
||||||
| ഫോട്ടോകളും വീഡിയോകളും അപ്ലോഡ് ചെയ്യാനും കാണാനും സാധിക്കും | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ആപ്പ് തുറക്കുമ്പോൾ ഓട്ടോമാറ്റിക് ബാക്കപ്പ് | ഉണ്ട് | N/A |
|
|
||||||
| ഫയലുകളുടെ ഇരട്ടിപ്പ് (duplication) തടയുന്നു | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ബാക്കപ്പിനായി നിർദ്ദിഷ്ട ആൽബങ്ങൾ തിരഞ്ഞെടുക്കാം | ഉണ്ട് | N/A |
|
|
||||||
| ഫോട്ടോകളും വീഡിയോകളും ലോക്കൽ ഡിവൈസിലേക്ക് ഡൗൺലോഡ് ചെയ്യാം | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ഒന്നിലധികം ഉപയോക്താക്കൾക്കുള്ള പിന്തുണ (Multi-user) | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ആൽബങ്ങളും പങ്കിട്ട ആൽബങ്ങളും | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| സ്ക്രബ്ബ് ചെയ്യാവുന്ന/വലിച്ചിഴക്കാവുന്ന സ്ക്രോൾബാർ | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| റോ (RAW) ഫോർമാറ്റുകൾക്കുള്ള പിന്തുണ | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| മെറ്റാഡാറ്റ കാഴ്ച (EXIF, മാപ്പ്) | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| മെറ്റാഡാറ്റ, ഒബ്ജക്റ്റുകൾ, മുഖങ്ങൾ, CLIP എന്നിവ ഉപയോഗിച്ച് തിരയാം | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| അഡ്മിനിസ്ട്രേറ്റീവ് പ്രവർത്തനങ്ങൾ (യൂസർ മാനേജ്മെന്റ്) | ഇല്ല | ഉണ്ട് |
|
|
||||||
| ബാക്ക്ഗ്രൗണ്ട് ബാക്കപ്പ് | ഉണ്ട് | N/A |
|
|
||||||
| വിർച്വൽ സ്ക്രോൾ | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| OAuth സപ്പോർട്ട് | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| API കീകൾ | N/A | ഉണ്ട് |
|
|
||||||
| ലൈവ് ഫോട്ടോ/മോഷൻ ഫോട്ടോ ബാക്കപ്പും പ്ലേബാക്കും | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| 360 ഡിഗ്രി ഇമേജ് ഡിസ്പ്ലേ പിന്തുണ | ഇല്ല | ഉണ്ട് |
|
|
||||||
| ഉപയോക്താവ് നിർവചിക്കുന്ന സ്റ്റോറേജ് ഘടന | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| പബ്ലിക് ഷെയറിംഗ് | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ആർക്കൈവും പ്രിയപ്പെട്ടവയും (Favorites) | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ആഗോള മാപ്പ് (Global Map) | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| പങ്കാളി പങ്കിടൽ (Partner Sharing) | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| മുഖം തിരിച്ചറിയലും ക്ലസ്റ്ററിംഗും | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ഓർമ്മകൾ (Memories - x വർഷങ്ങൾക്ക് മുമ്പ്) | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ഓഫ്ലൈൻ പിന്തുണ | ഉണ്ട് | ഇല്ല |
|
|
||||||
| റീഡ്-ഒൺലി ഗാലറി | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| അടുക്കിവെച്ച ഫോട്ടോകൾ (Stacked Photos) | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
| ടാഗുകൾ | ഇല്ല | ഉണ്ട് |
|
|
||||||
| ഫോൾഡർ കാഴ്ച | ഉണ്ട് | ഉണ്ട് |
|
|
||||||
|
|
||||||
## വിവർത്തനങ്ങൾ
|
|
||||||
|
|
||||||
വിവർത്തനങ്ങളെക്കുറിച്ച് കൂടുതൽ [ഇവിടെ](https://docs.immich.app/developer/translations) വായിക്കാം.
|
|
||||||
|
|
||||||
<a href="https://hosted.weblate.org/engage/immich/">
|
|
||||||
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Translation status" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## റെപ്പോസിറ്ററി പ്രവർത്തനം
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## സ്റ്റാർ ചരിത്രം
|
|
||||||
|
|
||||||
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## സംഭാവന ചെയ്തവർ
|
|
||||||
|
|
||||||
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
|
||||||
</a>
|
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
<a href="README_pt_BR.md">Português Brasileiro</a>
|
<a href="README_pt_BR.md">Português Brasileiro</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<a href="README_sv_SE.md">Svenska</a>
|
<a href="README_sv_SE.md">Svenska</a>
|
||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
<a href="README_ar_JO.md">العربية</a>
|
<a href="README_ar_JO.md">العربية</a>
|
||||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||||
<a href="README_ml_IN.md">മലയാളം</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
@@ -56,7 +56,7 @@ FROM builder AS plugins
|
|||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./mise.toml ./mise.toml
|
COPY ./mise.toml ./mise.toml
|
||||||
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
mise //:plugins
|
mise //:plugins
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7
|
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
|
||||||
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
AudioCodec,
|
AudioCodec,
|
||||||
Colorspace,
|
Colorspace,
|
||||||
CQMode,
|
CQMode,
|
||||||
HlsVideoResolution,
|
|
||||||
ImageFormat,
|
ImageFormat,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
OAuthTokenEndpointAuthMethod,
|
OAuthTokenEndpointAuthMethod,
|
||||||
@@ -49,8 +48,6 @@ export type SystemConfig = {
|
|||||||
tonemap: ToneMapping;
|
tonemap: ToneMapping;
|
||||||
realtime: {
|
realtime: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
videoCodecs: VideoCodec[];
|
|
||||||
resolutions: HlsVideoResolution[];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
integrityChecks: {
|
integrityChecks: {
|
||||||
@@ -250,8 +247,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
accelDecode: true,
|
accelDecode: true,
|
||||||
realtime: {
|
realtime: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
|
|
||||||
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
integrityChecks: {
|
integrityChecks: {
|
||||||
|
|||||||
+9
-60
@@ -235,65 +235,14 @@ export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
|
|||||||
export const HLS_SEGMENT_DURATION = 2;
|
export const HLS_SEGMENT_DURATION = 2;
|
||||||
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
|
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
|
||||||
export const HLS_VARIANTS = [
|
export const HLS_VARIANTS = [
|
||||||
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000 },
|
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' },
|
||||||
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000 },
|
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' },
|
||||||
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000 },
|
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' },
|
||||||
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000 },
|
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' },
|
||||||
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000 },
|
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' },
|
||||||
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000 },
|
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' },
|
||||||
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000 },
|
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' },
|
||||||
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000 },
|
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' },
|
||||||
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000 },
|
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' },
|
||||||
{ resolution: 1440, codec: VideoCodec.Av1, bitrate: 7_000_000 },
|
|
||||||
{ resolution: 1440, codec: VideoCodec.Hevc, bitrate: 8_000_000 },
|
|
||||||
{ resolution: 1440, codec: VideoCodec.H264, bitrate: 14_000_000 },
|
|
||||||
{ resolution: 2160, codec: VideoCodec.Av1, bitrate: 12_000_000 },
|
|
||||||
{ resolution: 2160, codec: VideoCodec.Hevc, bitrate: 14_000_000 },
|
|
||||||
{ resolution: 2160, codec: VideoCodec.H264, bitrate: 25_000_000 },
|
|
||||||
];
|
];
|
||||||
export const HLS_VERSION = 7;
|
export const HLS_VERSION = 7;
|
||||||
|
|
||||||
export type CodecLevel = { maxFrame: number; maxRate: number; token: string };
|
|
||||||
|
|
||||||
// H.264 High profile: token is the hex level_idc.
|
|
||||||
export const H264_LEVELS: CodecLevel[] = [
|
|
||||||
{ maxFrame: 1620, maxRate: 40_500, token: '1e' }, // 3.0
|
|
||||||
{ maxFrame: 3600, maxRate: 108_000, token: '1f' }, // 3.1
|
|
||||||
{ maxFrame: 5120, maxRate: 216_000, token: '20' }, // 3.2
|
|
||||||
{ maxFrame: 8192, maxRate: 245_760, token: '28' }, // 4.0
|
|
||||||
{ maxFrame: 8704, maxRate: 522_240, token: '2a' }, // 4.2
|
|
||||||
{ maxFrame: 22_080, maxRate: 589_824, token: '32' }, // 5.0
|
|
||||||
{ maxFrame: 36_864, maxRate: 983_040, token: '33' }, // 5.1
|
|
||||||
{ maxFrame: 36_864, maxRate: 2_073_600, token: '34' }, // 5.2
|
|
||||||
{ maxFrame: 139_264, maxRate: 4_177_920, token: '3c' }, // 6.0
|
|
||||||
{ maxFrame: 139_264, maxRate: 8_355_840, token: '3d' }, // 6.1
|
|
||||||
{ maxFrame: 139_264, maxRate: 16_711_680, token: '3e' }, // 6.2
|
|
||||||
];
|
|
||||||
|
|
||||||
// HEVC Main profile, Main tier: token is `L` + level_idc (level × 30).
|
|
||||||
export const HEVC_LEVELS: CodecLevel[] = [
|
|
||||||
{ maxFrame: 552_960, maxRate: 16_588_800, token: 'L90' }, // 3.0
|
|
||||||
{ maxFrame: 983_040, maxRate: 33_177_600, token: 'L93' }, // 3.1
|
|
||||||
{ maxFrame: 2_228_224, maxRate: 66_846_720, token: 'L120' }, // 4.0
|
|
||||||
{ maxFrame: 2_228_224, maxRate: 133_693_440, token: 'L123' }, // 4.1
|
|
||||||
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: 'L150' }, // 5.0
|
|
||||||
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: 'L153' }, // 5.1
|
|
||||||
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: 'L156' }, // 5.2
|
|
||||||
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: 'L180' }, // 6.0
|
|
||||||
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: 'L183' }, // 6.1
|
|
||||||
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: 'L186' }, // 6.2
|
|
||||||
];
|
|
||||||
|
|
||||||
// AV1 Main profile (0), Main tier (M): token is the two-digit seq_level_idx + `M`.
|
|
||||||
export const AV1_LEVELS: CodecLevel[] = [
|
|
||||||
{ maxFrame: 665_856, maxRate: 19_975_168, token: '04M' }, // 3.0
|
|
||||||
{ maxFrame: 1_065_024, maxRate: 31_950_336, token: '05M' }, // 3.1
|
|
||||||
{ maxFrame: 2_359_296, maxRate: 70_778_880, token: '08M' }, // 4.0
|
|
||||||
{ maxFrame: 2_359_296, maxRate: 141_557_760, token: '09M' }, // 4.1
|
|
||||||
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: '12M' }, // 5.0
|
|
||||||
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: '13M' }, // 5.1
|
|
||||||
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: '14M' }, // 5.2
|
|
||||||
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: '16M' }, // 6.0
|
|
||||||
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: '17M' }, // 6.1
|
|
||||||
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: '18M' }, // 6.2
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
AudioCodecSchema,
|
AudioCodecSchema,
|
||||||
ColorspaceSchema,
|
ColorspaceSchema,
|
||||||
CQModeSchema,
|
CQModeSchema,
|
||||||
HlsVideoResolutionSchema,
|
|
||||||
ImageFormatSchema,
|
ImageFormatSchema,
|
||||||
LogLevelSchema,
|
LogLevelSchema,
|
||||||
OAuthTokenEndpointAuthMethodSchema,
|
OAuthTokenEndpointAuthMethodSchema,
|
||||||
@@ -74,12 +73,7 @@ const SystemConfigIntegrityJobSchema = z
|
|||||||
|
|
||||||
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
|
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
|
||||||
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
|
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
|
||||||
percentageLimit: z
|
percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
|
||||||
.float32()
|
|
||||||
.nonnegative()
|
|
||||||
.max(1)
|
|
||||||
.describe('Percentage limit of the integrity checksum job')
|
|
||||||
.meta({ format: 'double' }),
|
|
||||||
})
|
})
|
||||||
.describe('Integrity checksum job config')
|
.describe('Integrity checksum job config')
|
||||||
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
|
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
|
||||||
@@ -121,8 +115,6 @@ const SystemConfigFFmpegSchema = z
|
|||||||
realtime: z
|
realtime: z
|
||||||
.object({
|
.object({
|
||||||
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
|
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
|
||||||
videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'),
|
|
||||||
resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'),
|
|
||||||
})
|
})
|
||||||
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
|
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -529,19 +529,6 @@ export enum CQMode {
|
|||||||
|
|
||||||
export const CQModeSchema = z.enum(CQMode).describe('CQ mode').meta({ id: 'CQMode' });
|
export const CQModeSchema = z.enum(CQMode).describe('CQ mode').meta({ id: 'CQMode' });
|
||||||
|
|
||||||
export enum HlsVideoResolution {
|
|
||||||
p480 = 480,
|
|
||||||
p720 = 720,
|
|
||||||
p1080 = 1080,
|
|
||||||
p1440 = 1440,
|
|
||||||
p2160 = 2160,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HlsVideoResolutionSchema = z
|
|
||||||
.enum(HlsVideoResolution)
|
|
||||||
.describe('HLS video resolution')
|
|
||||||
.meta({ id: 'HlsVideoResolution', type: 'integer' });
|
|
||||||
|
|
||||||
export enum Colorspace {
|
export enum Colorspace {
|
||||||
Srgb = 'srgb',
|
Srgb = 'srgb',
|
||||||
P3 = 'p3',
|
P3 = 'p3',
|
||||||
|
|||||||
@@ -130,9 +130,10 @@ from
|
|||||||
where
|
where
|
||||||
"asset"."deletedAt" is null
|
"asset"."deletedAt" is null
|
||||||
and "asset"."isExternal" = false
|
and "asset"."isExternal" = false
|
||||||
and "asset"."createdAt" >= $2
|
and "integrity_report"."createdAt" >= $2
|
||||||
|
and "integrity_report"."createdAt" <= $3
|
||||||
order by
|
order by
|
||||||
"asset"."createdAt" asc
|
"integrity_report"."createdAt" asc
|
||||||
|
|
||||||
-- IntegrityRepository.streamIntegrityReports
|
-- IntegrityRepository.streamIntegrityReports
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ export class IntegrityRepository {
|
|||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
@GenerateSql({ params: [DummyValue.DATE, DummyValue.DATE], stream: true })
|
||||||
streamAssetChecksums(startMarker?: Date) {
|
streamAssetChecksums(startMarker?: Date, endMarker?: Date) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.where('asset.deletedAt', 'is', null)
|
.where('asset.deletedAt', 'is', null)
|
||||||
@@ -178,8 +178,9 @@ export class IntegrityRepository {
|
|||||||
'integrity_report.id as reportId',
|
'integrity_report.id as reportId',
|
||||||
])
|
])
|
||||||
.where('asset.isExternal', '=', sql.lit(false))
|
.where('asset.isExternal', '=', sql.lit(false))
|
||||||
.$if(startMarker !== undefined, (qb) => qb.where('asset.createdAt', '>=', startMarker!))
|
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
|
||||||
.orderBy('asset.createdAt', 'asc')
|
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
|
||||||
|
.orderBy('integrity_report.createdAt', 'asc')
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ export class AuthService extends BaseService {
|
|||||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.session && sid) {
|
if (auth.session) {
|
||||||
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
|
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export class DatabaseBackupService {
|
|||||||
progressCb?.('restore', progress);
|
progressCb?.('restore', progress);
|
||||||
});
|
});
|
||||||
|
|
||||||
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink);
|
await pipeline(sqlStream, progressSource, psql, progressSink);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
progressCb?.('migrations', 0.9);
|
progressCb?.('migrations', 0.9);
|
||||||
@@ -572,69 +572,3 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
|
|||||||
|
|
||||||
return [source, sink];
|
return [source, sink];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSqlOwnerTransformStream(databaseUsername: string) {
|
|
||||||
const OWNER_MARKER_START = new TextEncoder().encode('OWNER TO ');
|
|
||||||
const DATA_MARKER_START = new TextEncoder().encode('FROM stdin');
|
|
||||||
const LINE_END = new TextEncoder().encode(';');
|
|
||||||
|
|
||||||
const owner = new TextEncoder().encode(databaseUsername);
|
|
||||||
|
|
||||||
let ownerSequenceIndex = 0;
|
|
||||||
|
|
||||||
let replacingOwnerIndex = 0;
|
|
||||||
let replacingOwner = false;
|
|
||||||
|
|
||||||
let readingDataIndex = 0;
|
|
||||||
|
|
||||||
let dataPart = false;
|
|
||||||
|
|
||||||
return new PassThrough({
|
|
||||||
transform(chunk, _encoding, callback) {
|
|
||||||
let result = chunk;
|
|
||||||
if (!dataPart) {
|
|
||||||
for (let index = 0; index < result.length; index++) {
|
|
||||||
if (replacingOwner) {
|
|
||||||
if (result[index] === LINE_END[0]) {
|
|
||||||
result = Buffer.concat([result.slice(0, index), owner.slice(replacingOwnerIndex), result.slice(index)]);
|
|
||||||
replacingOwnerIndex = owner.length;
|
|
||||||
} else {
|
|
||||||
result[index] = owner[replacingOwnerIndex];
|
|
||||||
replacingOwnerIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replacingOwnerIndex === owner.length) {
|
|
||||||
replacingOwner = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result[index] === OWNER_MARKER_START[ownerSequenceIndex]) {
|
|
||||||
ownerSequenceIndex++;
|
|
||||||
} else {
|
|
||||||
ownerSequenceIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ownerSequenceIndex === OWNER_MARKER_START.length) {
|
|
||||||
ownerSequenceIndex = 0;
|
|
||||||
replacingOwner = true;
|
|
||||||
replacingOwnerIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result[index] === DATA_MARKER_START[readingDataIndex]) {
|
|
||||||
readingDataIndex++;
|
|
||||||
} else {
|
|
||||||
readingDataIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (readingDataIndex === DATA_MARKER_START.length) {
|
|
||||||
dataPart = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.push(result);
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { HlsVideoResolution, VideoCodec } from 'src/enum';
|
import { TranscodeHardwareAcceleration } from 'src/enum';
|
||||||
import { HlsService } from 'src/services/hls.service';
|
import { HlsService } from 'src/services/hls.service';
|
||||||
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
@@ -96,79 +96,67 @@ seg_10.m4s
|
|||||||
|
|
||||||
const sessionId = '00000000-0000-0000-0000-000000000000';
|
const sessionId = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
const eiffelExpectedMasterAv1 = `#EXTM3U
|
const eiffelExpectedMasterDisabled = `#EXTM3U
|
||||||
#EXT-X-VERSION:7
|
#EXT-X-VERSION:7
|
||||||
#EXT-X-INDEPENDENT-SEGMENTS
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/0/playlist.m3u8
|
${sessionId}/0/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/1/playlist.m3u8
|
${sessionId}/1/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/2/playlist.m3u8
|
${sessionId}/2/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/3/playlist.m3u8
|
${sessionId}/3/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/4/playlist.m3u8
|
${sessionId}/4/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/5/playlist.m3u8
|
${sessionId}/5/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/6/playlist.m3u8
|
${sessionId}/6/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/7/playlist.m3u8
|
${sessionId}/7/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/8/playlist.m3u8
|
${sessionId}/8/playlist.m3u8
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const eiffelExpectedMasterNoAv1 = `#EXTM3U
|
const eiffelExpectedMasterRkmpp = `#EXTM3U
|
||||||
#EXT-X-VERSION:7
|
#EXT-X-VERSION:7
|
||||||
#EXT-X-INDEPENDENT-SEGMENTS
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/1/playlist.m3u8
|
${sessionId}/1/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/2/playlist.m3u8
|
${sessionId}/2/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/4/playlist.m3u8
|
${sessionId}/4/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/5/playlist.m3u8
|
${sessionId}/5/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/7/playlist.m3u8
|
${sessionId}/7/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||||
${sessionId}/8/playlist.m3u8
|
${sessionId}/8/playlist.m3u8
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const waterfallExpectedMasterAv1 = `#EXTM3U
|
const waterfallExpectedMasterDisabled = `#EXTM3U
|
||||||
#EXT-X-VERSION:7
|
#EXT-X-VERSION:7
|
||||||
#EXT-X-INDEPENDENT-SEGMENTS
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/0/playlist.m3u8
|
${sessionId}/0/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/1/playlist.m3u8
|
${sessionId}/1/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/2/playlist.m3u8
|
${sessionId}/2/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/3/playlist.m3u8
|
${sessionId}/3/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/4/playlist.m3u8
|
${sessionId}/4/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/5/playlist.m3u8
|
${sessionId}/5/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/6/playlist.m3u8
|
${sessionId}/6/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/7/playlist.m3u8
|
${sessionId}/7/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||||
${sessionId}/8/playlist.m3u8
|
${sessionId}/8/playlist.m3u8
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=9450000,RESOLUTION=1440x2560,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
||||||
${sessionId}/9/playlist.m3u8
|
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1440x2560,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
||||||
${sessionId}/10/playlist.m3u8
|
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=1440x2560,CODECS="avc1.640032,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
||||||
${sessionId}/11/playlist.m3u8
|
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=16200000,RESOLUTION=2160x3840,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
||||||
${sessionId}/12/playlist.m3u8
|
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=2160x3840,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
||||||
${sessionId}/13/playlist.m3u8
|
|
||||||
#EXT-X-STREAM-INF:BANDWIDTH=33750000,RESOLUTION=2160x3840,CODECS="avc1.640033,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
||||||
${sessionId}/14/playlist.m3u8
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
describe(HlsService.name, () => {
|
describe(HlsService.name, () => {
|
||||||
@@ -183,24 +171,9 @@ describe(HlsService.name, () => {
|
|||||||
const auth = factory.auth();
|
const auth = factory.auth();
|
||||||
const assetId = 'asset-1';
|
const assetId = 'asset-1';
|
||||||
|
|
||||||
const allCodecs = [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264];
|
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
|
||||||
const allResolutions = [
|
|
||||||
HlsVideoResolution.p480,
|
|
||||||
HlsVideoResolution.p720,
|
|
||||||
HlsVideoResolution.p1080,
|
|
||||||
HlsVideoResolution.p1440,
|
|
||||||
HlsVideoResolution.p2160,
|
|
||||||
];
|
|
||||||
|
|
||||||
const setup = (
|
|
||||||
asset: typeof eiffelTower | typeof waterfall,
|
|
||||||
videoCodecs?: VideoCodec[],
|
|
||||||
resolutions?: HlsVideoResolution[],
|
|
||||||
) => {
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
|
||||||
ffmpeg: { realtime: { enabled: true, videoCodecs, resolutions } },
|
|
||||||
});
|
|
||||||
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
|
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
|
||||||
mocks.crypto.randomUUID.mockReturnValue(sessionId);
|
mocks.crypto.randomUUID.mockReturnValue(sessionId);
|
||||||
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
|
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
|
||||||
@@ -211,19 +184,19 @@ describe(HlsService.name, () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
it('offers AV1, HEVC, and H.264 when AV1 is configured and the accelerator supports it', async () => {
|
it('returns main playlist for eiffel-tower (1080p portrait, no acceleration)', async () => {
|
||||||
setup(eiffelTower, allCodecs);
|
setup(eiffelTower, TranscodeHardwareAcceleration.Disabled);
|
||||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterAv1);
|
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterDisabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits AV1 when it is not in the configured codecs', async () => {
|
it('returns main playlist for eiffel-tower with RKMPP (no AV1 variants)', async () => {
|
||||||
setup(eiffelTower);
|
setup(eiffelTower, TranscodeHardwareAcceleration.Rkmpp);
|
||||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterNoAv1);
|
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterRkmpp);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers every resolution up to the source and derives 4K codec levels (waterfall, 4K, 29.83fps)', async () => {
|
it('returns main playlist for waterfall (4K landscape) with no acceleration', async () => {
|
||||||
setup(waterfall, allCodecs, allResolutions);
|
setup(waterfall, TranscodeHardwareAcceleration.Disabled);
|
||||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterAv1);
|
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterDisabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws BadRequestException when realtime transcoding is disabled', async () => {
|
it('throws BadRequestException when realtime transcoding is disabled', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VERSION } from 'src/constants';
|
import {
|
||||||
|
HLS_SEGMENT_DURATION,
|
||||||
|
HLS_SEGMENT_FILENAME_REGEX,
|
||||||
|
HLS_VARIANTS,
|
||||||
|
HLS_VERSION,
|
||||||
|
SUPPORTED_HWA_CODECS,
|
||||||
|
} from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
@@ -12,7 +18,7 @@ import { BaseService } from 'src/services/base.service';
|
|||||||
import { VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
import { VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||||
import { PendingEvents } from 'src/utils/event';
|
import { PendingEvents } from 'src/utils/event';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
import { getCodecString, getOutputSize } from 'src/utils/media';
|
import { getOutputSize } from 'src/utils/media';
|
||||||
|
|
||||||
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
|
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
|
||||||
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
|
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
|
||||||
@@ -125,21 +131,18 @@ export class HlsService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
|
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
|
||||||
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
|
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3);
|
||||||
const roundedFps = fps.toFixed(3);
|
|
||||||
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
|
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
|
||||||
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
|
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
|
||||||
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
|
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
|
||||||
const { videoCodecs, resolutions } = ffmpeg.realtime;
|
|
||||||
for (let i = 0; i < HLS_VARIANTS.length; i++) {
|
for (let i = 0; i < HLS_VARIANTS.length; i++) {
|
||||||
const { resolution, bitrate, codec } = HLS_VARIANTS[i];
|
const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i];
|
||||||
if (resolution > targetResolution || !videoCodecs.includes(codec) || !resolutions.includes(resolution)) {
|
if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const { width, height } = getOutputSize(asset.videoStream, resolution);
|
const { width, height } = getOutputSize(asset.videoStream, resolution);
|
||||||
const codecString = getCodecString(codec, width, height, fps);
|
|
||||||
lines.push(
|
lines.push(
|
||||||
`#EXT-X-STREAM-INF:BANDWIDTH=${Math.round(bitrate * 1.35)},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${roundedFps}`,
|
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
|
||||||
`${sessionId}/${i}/playlist.m3u8`,
|
`${sessionId}/${i}/playlist.m3u8`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user