Compare commits

..

11 Commits

Author SHA1 Message Date
Alex a435591662 Merge branch 'main' of github.com:immich-app/immich into debug/blank-timeline-fix 2026-07-05 16:02:34 -05:00
shenlong-tanwen 247fdd87f0 fix: blank timeline 2026-07-04 17:15:33 +05:30
shenlong-tanwen f4136f6487 feat: add pull to refresh 2026-07-03 03:23:32 +05:30
shenlong-tanwen 2217d2e679 add timeline state logs 2026-07-03 02:50:56 +05:30
Alex 18fec31c1e Merge branch 'main' of github.com:immich-app/immich into debug/blank-timeline-fix 2026-06-29 09:09:32 -05:00
shenlong-tanwen 57eee0148c log isolate hashcode 2026-06-27 00:50:17 +05:30
shenlong-tanwen 880f8e2e4c chore: cancel native operations - lifecycle change 2026-06-27 00:23:11 +05:30
shenlong-tanwen 3f67c400d2 chore: pump drift 2026-06-27 00:23:11 +05:30
shenlong-tanwen 5f5ce12041 debug: full local sync 2026-06-27 00:23:11 +05:30
shenlong-tanwen 1c3266e8f2 debug: more logs
# Conflicts:
#	mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
#	mobile/lib/presentation/widgets/timeline/timeline.widget.dart
2026-06-27 00:23:11 +05:30
shenlong-tanwen 912484dcdd debug: blank timeline rendering 2026-06-27 00:23:11 +05:30
71 changed files with 357 additions and 724 deletions
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -75,7 +75,7 @@ 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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+1 -1
View File
@@ -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 }}
-1
View File
@@ -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>
-14
View File
@@ -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.
+4 -8
View File
@@ -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/
-6
View File
@@ -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. A variant is only offered when its resolution is no larger than the source. 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
View File
@@ -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"
-20
View File
@@ -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"
@@ -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
@@ -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");
+4 -2
View File
@@ -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);
@@ -90,6 +91,7 @@ class TimelineFactory {
} }
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;
@@ -105,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;
@@ -164,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);
} }
@@ -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');
},
); );
} }
} }
@@ -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,
@@ -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> { class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
static final Logger _log = Logger('Timeline');
late final ScrollController _scrollController; late final ScrollController _scrollController;
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
@@ -153,6 +184,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_log.info('SliverTimeline initState');
_scrollController = ScrollController(onAttach: _restoreAssetPosition); _scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
@@ -179,6 +211,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} }
void _onEvent(Event event) { void _onEvent(Event event) {
_log.info('event ${event.runtimeType}');
switch (event) { switch (event) {
case ScrollToTopEvent(): case ScrollToTopEvent():
{ {
@@ -186,7 +219,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineState.setScrubbing(true); timelineState.setScrubbing(true);
_scrollController _scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut) .animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false)); .whenComplete(() {
_log.info('ScrollToTop animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
} }
case ScrollToDateEvent scrollToDateEvent: case ScrollToDateEvent scrollToDateEvent:
@@ -246,6 +282,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void dispose() { void dispose() {
_log.info('SliverTimeline dispose');
_scrollController.dispose(); _scrollController.dispose();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
super.dispose(); super.dispose();
@@ -286,8 +323,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
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);
} }
}); });
@@ -425,7 +466,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
], ],
); );
final Widget timeline; Widget timeline;
if (widget.withScrubber) { if (widget.withScrubber) {
timeline = Scrubber( timeline = Scrubber(
snapToMonth: widget.snapToMonth, snapToMonth: widget.snapToMonth,
@@ -440,6 +481,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} 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;
});
}); });
-1
View File
@@ -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)
-1
View File
@@ -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';
-2
View File
@@ -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':
-3
View File
@@ -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
View File
@@ -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',
}; };
} }
+4 -4
View File
@@ -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
View File
@@ -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
+1 -28
View File
@@ -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"
}, },
-11
View File
@@ -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",
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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>
-133
View File
@@ -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>
## റെപ്പോസിറ്ററി പ്രവർത്തനം
![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
## സ്റ്റാർ ചരിത്രം
<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>
-1
View File
@@ -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]
-1
View File
@@ -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>
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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>
-1
View File
@@ -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]
-1
View File
@@ -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]
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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 \
+2 -2
View File
@@ -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 && \
-5
View File
@@ -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
View File
@@ -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
];
-3
View File
@@ -11,7 +11,6 @@ import {
AudioCodecSchema, AudioCodecSchema,
ColorspaceSchema, ColorspaceSchema,
CQModeSchema, CQModeSchema,
HlsVideoResolutionSchema,
ImageFormatSchema, ImageFormatSchema,
LogLevelSchema, LogLevelSchema,
OAuthTokenEndpointAuthMethodSchema, OAuthTokenEndpointAuthMethodSchema,
@@ -116,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' }),
}) })
-13
View File
@@ -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',
+39 -66
View File
@@ -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 () => {
+12 -9
View File
@@ -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`,
); );
} }
@@ -5,7 +5,6 @@ import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
CQMode, CQMode,
HlsVideoResolution,
ImageFormat, ImageFormat,
LogLevel, LogLevel,
OAuthTokenEndpointAuthMethod, OAuthTokenEndpointAuthMethod,
@@ -77,8 +76,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
tonemap: ToneMapping.Hable, tonemap: ToneMapping.Hable,
realtime: { realtime: {
enabled: false, enabled: false,
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
}, },
}, },
integrityChecks: { integrityChecks: {
+1 -24
View File
@@ -1,4 +1,4 @@
import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants'; import { AUDIO_ENCODER, SUPPORTED_HWA_CODECS } from 'src/constants';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { import {
ColorMatrix, ColorMatrix,
@@ -36,29 +36,6 @@ export const getOutputSize = (videoStream: VideoStreamInfo, targetRes: number) =
return isVideoVertical(videoStream) ? { width: targetRes, height: larger } : { width: larger, height: targetRes }; return isVideoVertical(videoStream) ? { width: targetRes, height: larger } : { width: larger, height: targetRes };
}; };
const pickLevel = (levels: CodecLevel[], frame: number, rate: number) =>
levels.find((level) => frame <= level.maxFrame && rate <= level.maxRate) ?? levels.at(-1)!;
export const getCodecString = (codec: VideoCodec, width: number, height: number, fps: number): string => {
switch (codec) {
case VideoCodec.H264: {
const macroblocks = Math.ceil(width / 16) * Math.ceil(height / 16);
return `avc1.6400${pickLevel(H264_LEVELS, macroblocks, macroblocks * fps).token}`;
}
case VideoCodec.Hevc: {
const samples = width * height;
return `hvc1.1.6.${pickLevel(HEVC_LEVELS, samples, samples * fps).token}.B0`;
}
case VideoCodec.Av1: {
const samples = width * height;
return `av01.0.${pickLevel(AV1_LEVELS, samples, samples * fps).token}.08`;
}
default: {
throw new Error(`Codec '${codec}' does not support HLS codec strings`);
}
}
};
export class BaseConfig implements VideoCodecSWConfig { export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor( protected constructor(
@@ -107,6 +107,6 @@
<Input bind:value={() => getValue<string>(), setValue} /> <Input bind:value={() => getValue<string>(), setValue} />
</Field> </Field>
{:else} {:else}
<Text>{$t('unknown_schema')}</Text> <Text>Unknown schema</Text>
<CodeBlock code={JSON.stringify(schema, null, 2)} /> <CodeBlock code={JSON.stringify(schema, null, 2)} />
{/if} {/if}
@@ -37,18 +37,18 @@
</div> </div>
{#await valuePromise} {#await valuePromise}
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="0"> <div class="relative mx-auto font-mono text-2xl font-medium">
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros()}</span> <span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros()}</span>
</div> </div>
{:then data} {:then data}
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="{data.value} {data.unit ?? ''}"> <div class="relative mx-auto font-mono text-2xl font-medium">
<span class="text-gray-300 dark:text-gray-600">{zeros(data)}</span><span>{data.value}</span> <span class="text-gray-300 dark:text-gray-600">{zeros(data)}</span><span>{data.value}</span>
{#if data.unit} {#if data.unit}
<code class="font-mono text-base font-normal">{data.unit}</code> <code class="font-mono text-base font-normal">{data.unit}</code>
{/if} {/if}
</div> </div>
{:catch _} {:catch _}
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="0"> <div class="relative mx-auto font-mono text-2xl font-medium">
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span> <span class="text-gray-300 dark:text-gray-600">{zeros()}</span>
</div> </div>
{/await} {/await}
-9
View File
@@ -58,14 +58,5 @@ describe('i18n', () => {
expect(getClosestAvailableLocale(['sr_Cyrl'], allLocales)).toBe('sr_Cyrl'); expect(getClosestAvailableLocale(['sr_Cyrl'], allLocales)).toBe('sr_Cyrl');
expect(getClosestAvailableLocale(['zh_Hant'], allLocales)).toBe('zh_Hant'); expect(getClosestAvailableLocale(['zh_Hant'], allLocales)).toBe('zh_Hant');
}); });
it('should handle language aliases', () => {
const allLocales = ['zh-Hans', 'zh-Hant'];
expect(getClosestAvailableLocale(['zh-CN'], allLocales)).toBe('zh-Hans');
expect(getClosestAvailableLocale(['zh-HK'], allLocales)).toBe('zh-Hant');
expect(getClosestAvailableLocale(['zh-MO'], allLocales)).toBe('zh-Hant');
expect(getClosestAvailableLocale(['zh-SG'], allLocales)).toBe('zh-Hans');
expect(getClosestAvailableLocale(['zh-TW'], allLocales)).toBe('zh-Hant');
});
}); });
}); });
+1 -1
View File
@@ -122,7 +122,7 @@
options={[ options={[
{ label: $t('role_editor'), value: AlbumUserRole.Editor }, { label: $t('role_editor'), value: AlbumUserRole.Editor },
{ label: $t('role_viewer'), value: AlbumUserRole.Viewer }, { label: $t('role_viewer'), value: AlbumUserRole.Viewer },
{ label: $t('owner'), value: AlbumUserRole.Owner, disabled: true }, { label: $t('owner'), value: AlbumUserRole.Owner },
{ label: $t('remove_user'), value: 'none' }, { label: $t('remove_user'), value: 'none' },
] as SelectOption<AlbumUserRole | 'none'>[]} ] as SelectOption<AlbumUserRole | 'none'>[]}
onChange={(value) => handleRoleSelect(user, value)} onChange={(value) => handleRoleSelect(user, value)}
+3 -6
View File
@@ -134,13 +134,10 @@ const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: strin
const successCount = results.filter(({ success }) => success).length; const successCount = results.filter(({ success }) => success).length;
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length; const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } });
if (successCount > 0) {
if (duplicateCount === assetIds.length) {
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
} else if (successCount === assetIds.length) {
description = $t('assets_added_to_album_count', { values: { count: successCount } }); description = $t('assets_added_to_album_count', { values: { count: successCount } });
} else if (successCount > 0) { } else if (duplicateCount > 0) {
description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } }); description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
} }
toastManager.primary( toastManager.primary(
+1 -11
View File
@@ -13,14 +13,6 @@ export const getFormatter = async () => {
return get(t); return get(t);
}; };
const aliases: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-HK': 'zh-Hant',
'zh-MO': 'zh-Hant',
'zh-SG': 'zh-Hans',
'zh-TW': 'zh-Hant',
};
const modules = import.meta.glob('$i18n/*.json'); const modules = import.meta.glob('$i18n/*.json');
const fileCodes = Object.keys(modules) const fileCodes = Object.keys(modules)
@@ -41,9 +33,7 @@ const getSubLocales = (locale: string) => {
export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => { export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => {
const allLocalesSet = new Set(allLocales.map((locale) => convertBCP47(locale))); const allLocalesSet = new Set(allLocales.map((locale) => convertBCP47(locale)));
return locales return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
.map((locale) => aliases[locale] ?? locale)
.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
}; };
export const getPreferredLocale = () => getClosestAvailableLocale(navigator.languages, langCodes); export const getPreferredLocale = () => getClosestAvailableLocale(navigator.languages, langCodes);
@@ -3,7 +3,6 @@
import { getFileSize } from '$lib/utils/asset-utils'; import { getFileSize } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -26,9 +25,7 @@
<Thumbnail asset={toTimelineAsset(asset)} readonly onClick={() => onViewAsset(asset)} thumbnailSize={boxWidth} /> <Thumbnail asset={toTimelineAsset(asset)} readonly onClick={() => onViewAsset(asset)} thumbnailSize={boxWidth} />
{#if !!asset.libraryId} {#if !!asset.libraryId}
<div class="absolute inset-e-3 bottom-1 rounded-xl bg-red-500 px-4 py-1 text-xs transition-colors"> <div class="absolute inset-e-3 bottom-1 rounded-xl bg-red-500 px-4 py-1 text-xs transition-colors">External</div>
{$t('external')}
</div>
{/if} {/if}
</div> </div>
<div class="mt-4 truncate px-4 text-center text-sm font-normal" title={asset.originalFileName}> <div class="mt-4 truncate px-4 text-center text-sm font-normal" title={asset.originalFileName}>
@@ -12,7 +12,6 @@
import { import {
AudioCodec, AudioCodec,
CQMode, CQMode,
HlsVideoResolution,
ToneMapping, ToneMapping,
TranscodeHWAccel, TranscodeHWAccel,
TranscodePolicy, TranscodePolicy,
@@ -402,45 +401,9 @@
title={$t('admin.transcoding_realtime_enabled')} title={$t('admin.transcoding_realtime_enabled')}
subtitle={$t('admin.transcoding_realtime_enabled_description')} subtitle={$t('admin.transcoding_realtime_enabled_description')}
bind:checked={configToEdit.ffmpeg.realtime.enabled} bind:checked={configToEdit.ffmpeg.realtime.enabled}
isEdited={configToEdit.ffmpeg.realtime.enabled !== config.ffmpeg.realtime.enabled} isEdited={configToEdit.ffmpeg.realtime.enabled !== configToEdit.ffmpeg.realtime.enabled}
{disabled} {disabled}
/> />
<SettingCheckboxes
label={$t('admin.transcoding_realtime_video_codecs')}
desc={$t('admin.transcoding_realtime_video_codecs_description')}
disabled={disabled || !configToEdit.ffmpeg.realtime.enabled}
bind:value={configToEdit.ffmpeg.realtime.videoCodecs}
name="realtimeVideoCodecs"
options={[
{ value: VideoCodec.H264, text: 'H.264' },
{ value: VideoCodec.Hevc, text: 'HEVC' },
{ value: VideoCodec.Av1, text: 'AV1' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.realtime.videoCodecs),
sortBy(config.ffmpeg.realtime.videoCodecs),
)}
/>
<SettingCheckboxes
label={$t('admin.transcoding_realtime_resolutions')}
desc={$t('admin.transcoding_realtime_resolutions_description')}
disabled={disabled || !configToEdit.ffmpeg.realtime.enabled}
bind:value={configToEdit.ffmpeg.realtime.resolutions}
name="realtimeResolutions"
options={[
{ value: HlsVideoResolution.$480, text: '480p' },
{ value: HlsVideoResolution.$720, text: '720p' },
{ value: HlsVideoResolution.$1080, text: '1080p' },
{ value: HlsVideoResolution.$1440, text: '1440p' },
{ value: HlsVideoResolution.$2160, text: '2160p' },
]}
isEdited={!isEqual(
sortBy(configToEdit.ffmpeg.realtime.resolutions),
sortBy(config.ffmpeg.realtime.resolutions),
)}
/>
</div> </div>
</SettingAccordion> </SettingAccordion>
</div> </div>
@@ -1,4 +1,4 @@
<script lang="ts" generics="T extends string | number"> <script lang="ts" generics="T extends string">
import { Checkbox, Label } from '@immich/ui'; import { Checkbox, Label } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';