mirror of
https://github.com/immich-app/immich.git
synced 2026-07-07 13:07:04 -07:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d52c89b85 | |||
| 58b1c5e8fb | |||
| 6f45521610 | |||
| 69d568f289 | |||
| a6f0253544 | |||
| 32352fa375 | |||
| 6aa5651032 | |||
| 4e25db2989 | |||
| 4f5e0d3505 | |||
| b3abe0bb5d | |||
| c04acc8951 | |||
| af32a8ca7c | |||
| ed83fb3f5a | |||
| 628de417e9 | |||
| be02c00afd | |||
| 43abb9a92f | |||
| 103585ceb9 | |||
| 6da890ee37 | |||
| e7ba8d3632 | |||
| 5645149e09 | |||
| 5a104f9dbe | |||
| 13d17b3a11 | |||
| f77c8a4699 | |||
| bf4b020856 | |||
| f73796597c |
@@ -103,14 +103,14 @@ jobs:
|
||||
working-directory: ./mobile
|
||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
id: cache-gradle-restore
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
- name: Save Gradle Cache
|
||||
id: cache-gradle-save
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
path: |
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
run: flutter build ios --config-only --no-codesign
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
|
||||
uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for breaking API changes
|
||||
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
|
||||
uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<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_th_TH.md">ภาษาไทย</a>
|
||||
<a href="readme_i18n/README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@@ -7,30 +7,37 @@ backend = "aqua:opentofu/opentofu"
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
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"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
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"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
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"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
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"]
|
||||
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
||||
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"]
|
||||
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
||||
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"]
|
||||
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
||||
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]]
|
||||
version = "1.0.3"
|
||||
@@ -39,27 +46,34 @@ backend = "aqua:gruntwork-io/terragrunt"
|
||||
[tools.terragrunt."platforms.linux-arm64"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
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"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
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"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
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"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
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"]
|
||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||
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"]
|
||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||
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"]
|
||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||
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**
|
||||
2. Click **Create job** in the top right
|
||||
3. Select **Create Database Backup** and click **Confirm**
|
||||
3. Select **Create Database Dump** and click **Confirm**
|
||||
|
||||
The backup will appear in `UPLOAD_LOCATION/backups` and counts toward your retention limit.
|
||||
|
||||
|
||||
@@ -28,11 +28,15 @@ docker image prune
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
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.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
|
||||
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`.
|
||||
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`. These metatags do not follow release candidates.
|
||||
|
||||
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
|
||||
Switching back to an earlier version, even within the same minor release tag, is not supported.
|
||||
The mobile app is typically compatible with the current and prior major version. However, the server is only compatible with the matching major version.
|
||||
Thus, we recommend upgrading all mobile clients before upgrading the server to ensure compatibility.
|
||||
|
||||
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/
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v3.0.0",
|
||||
"url": "https://docs.v3.0.0.archive.immich.app"
|
||||
"label": "v3.0.1",
|
||||
"url": "https://docs.v3.0.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -62,14 +62,4 @@ export const setupMemoryMockApiRoutes = async (
|
||||
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await context.route('**/api/memories/statistics*', async (route) => {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: {
|
||||
total: memories.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { expect, Page } from '@playwright/test';
|
||||
|
||||
function getAssetIdFromUrl(url: URL): string | null {
|
||||
const pathMatch = url.pathname.match(/\/memories\/photos\/([^/]+)/);
|
||||
const pathMatch = url.pathname.match(/\/memory\/photos\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
return pathMatch[1];
|
||||
}
|
||||
@@ -20,12 +20,12 @@ export const memoryViewerUtils = {
|
||||
},
|
||||
|
||||
async openMemoryPage(page: Page) {
|
||||
await page.goto('/memories');
|
||||
await page.goto('/memory');
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
|
||||
async openMemoryPageWithAsset(page: Page, assetId: string) {
|
||||
await page.goto(`/memories?id=${assetId}`);
|
||||
await page.goto(`/memory?id=${assetId}`);
|
||||
await this.waitForMemoryLoad(page);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -431,6 +431,10 @@
|
||||
"transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.",
|
||||
"transcoding_realtime_enabled": "Enable real-time transcoding",
|
||||
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
|
||||
"transcoding_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_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",
|
||||
@@ -593,6 +597,7 @@
|
||||
"asset_viewer_settings_title": "Asset Viewer",
|
||||
"assets": "Assets",
|
||||
"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_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",
|
||||
@@ -2150,6 +2155,7 @@
|
||||
"unknown": "Unknown",
|
||||
"unknown_country": "Unknown Country",
|
||||
"unknown_date": "Unknown date",
|
||||
"unknown_schema": "Unknown schema",
|
||||
"unknown_year": "Unknown Year",
|
||||
"unlimited": "Unlimited",
|
||||
"unlink_motion_video": "Unlink motion video",
|
||||
|
||||
+21
-14
@@ -5,38 +5,38 @@ version = "3.11.15"
|
||||
backend = "core:python"
|
||||
|
||||
[tools.python."platforms.linux-arm64"]
|
||||
checksum = "sha256:cbce0660e88cd9c56be7aaf2a2df92bea51f359388a521838b6b01817d728df0"
|
||||
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"
|
||||
checksum = "sha256:9ac18c9a761e91e6c6452bc0ef0082922a00a3fdec734555635d57c3169309b7"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:a55ea44225ee3741d4157c383f3d5c3e8eee5f9665e2ea069233486b4275d928"
|
||||
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"
|
||||
checksum = "sha256:bcd414f2e9d8cc5d4ac7fab2c8f912e30379c150fa6b94da19d64daf85cfc5d4"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64"]
|
||||
checksum = "sha256:67a5b22f796e96f4d7fa628f95866d5fd1d524d0588f74e4601facd82b66792b"
|
||||
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"
|
||||
checksum = "sha256:0604cd029b142dc223e131f17f5941c0c8d2d5074997c8178b515b19eea2a6c2"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:5a8544aa4303da3ca4b7505c98dd8453b671157039d25cd551e55abea0f83a60"
|
||||
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"
|
||||
checksum = "sha256:45bffdb320cf4a2c2dbf183b634aeb7e28f9cb0646f715b2f4117c11084d839a"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-arm64"]
|
||||
checksum = "sha256:8c56f1f59142e0f9f8861ad897bdfd97fd84403afa7b3d8b0f33b208ec471355"
|
||||
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"
|
||||
checksum = "sha256:2318799eaf104f8a29bc09a93b0851b05dbbcb4ce9a5f045ddea169c0c7ff3a5"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.macos-x64"]
|
||||
checksum = "sha256:8cd3878c656ba1698314cbcb65f78df4c37b7c8eabff958558115c6db11adb3d"
|
||||
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"
|
||||
checksum = "sha256:4925e5aaa9bc77c85302d350b36c1d9def2002996a6bcfa55c88ba6eb318de29"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.python."platforms.windows-x64"]
|
||||
checksum = "sha256:f081a733b4e7ba0e5e5e12d533b3c795dbef3ecbebf92f0b4202e5329bf7c8ab"
|
||||
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"
|
||||
checksum = "sha256:6589ca6d63f520bec4096d62b3ab91da3d0a80b16b594c99a6b677e335814683"
|
||||
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"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.uv]]
|
||||
@@ -46,27 +46,34 @@ backend = "aqua:astral-sh/uv"
|
||||
[tools.uv."platforms.linux-arm64"]
|
||||
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
|
||||
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"]
|
||||
checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2"
|
||||
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"]
|
||||
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
|
||||
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"]
|
||||
checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea"
|
||||
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"]
|
||||
checksum = "sha256:103367962c5cb00bf7370d84cbaa3fec5a9807be9cc833ea9d8eea400c119fa2"
|
||||
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"]
|
||||
checksum = "sha256:2bbef70982e97dfc36454de173f35ec1a5e83ae11e3885df6a50db3fd76171cb"
|
||||
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"]
|
||||
checksum = "sha256:459d95892a5cc5c21779532f4f41b9238594b79e312a5142da2148ecfa10e705"
|
||||
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -974,7 +974,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0"
|
||||
version = "3.0.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -184,33 +184,6 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
|
||||
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
|
||||
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
|
||||
|
||||
[[tools.java]]
|
||||
version = "21.0.2"
|
||||
backend = "core:java"
|
||||
|
||||
[tools.java.options]
|
||||
shorthand_vendor = "openjdk"
|
||||
|
||||
[tools.java."platforms.linux-arm64"]
|
||||
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.linux-x64"]
|
||||
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.macos-arm64"]
|
||||
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.macos-x64"]
|
||||
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
|
||||
|
||||
[tools.java."platforms.windows-x64"]
|
||||
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
|
||||
|
||||
[[tools.node]]
|
||||
version = "24.15.0"
|
||||
backend = "core:node"
|
||||
@@ -254,30 +227,37 @@ backend = "aqua:opentofu/opentofu"
|
||||
[tools.opentofu."platforms.linux-arm64"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
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"]
|
||||
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850"
|
||||
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"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
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"]
|
||||
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e"
|
||||
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"]
|
||||
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1"
|
||||
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"]
|
||||
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6"
|
||||
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"]
|
||||
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e"
|
||||
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]]
|
||||
version = "11.6.0"
|
||||
@@ -286,31 +266,37 @@ backend = "aqua:pnpm/pnpm"
|
||||
[tools.pnpm."platforms.linux-arm64"]
|
||||
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
|
||||
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"
|
||||
|
||||
[tools.pnpm."platforms.linux-arm64-musl"]
|
||||
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
|
||||
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"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64"]
|
||||
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
|
||||
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"
|
||||
|
||||
[tools.pnpm."platforms.linux-x64-musl"]
|
||||
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
|
||||
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"
|
||||
|
||||
[tools.pnpm."platforms.macos-arm64"]
|
||||
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
|
||||
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"
|
||||
|
||||
[tools.pnpm."platforms.windows-x64"]
|
||||
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
|
||||
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"
|
||||
|
||||
[[tools.terragrunt]]
|
||||
@@ -320,27 +306,34 @@ backend = "aqua:gruntwork-io/terragrunt"
|
||||
[tools.terragrunt."platforms.linux-arm64"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
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"]
|
||||
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe"
|
||||
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"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
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"]
|
||||
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073"
|
||||
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"]
|
||||
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa"
|
||||
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"]
|
||||
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b"
|
||||
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"]
|
||||
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db"
|
||||
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"
|
||||
|
||||
@@ -23,7 +23,7 @@ opentofu = "1.11.6"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
"github:webassembly/binaryen" = "version_124"
|
||||
"github:extism/js-pdk" = "1.6.0"
|
||||
java = "21.0.2"
|
||||
java = "21.0.11+10.0.LTS"
|
||||
|
||||
[tools."github:jellyfin/jellyfin-ffmpeg"]
|
||||
version = "7.1.3-6"
|
||||
|
||||
@@ -107,3 +107,67 @@ Java_app_alextran_immich_NativeImage_rotate(
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
||||
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
|
||||
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
|
||||
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
||||
for (int y = 0; y < h; y++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
||||
uint32_t *dstRow = dst + (size_t) y * w;
|
||||
for (int x = 0; x < w; x++) {
|
||||
uint32_t px = srcRow[x];
|
||||
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t a = ((px >> 30) & 0x3) * 85u;
|
||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w = (int) info.width;
|
||||
int h = (int) info.height;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {w, h, w * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,14 @@ object NativeImage {
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
|
||||
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
|
||||
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
|
||||
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
|
||||
* caller can fall back to a Skia copy.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
}
|
||||
|
||||
@@ -46,22 +46,47 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
||||
}
|
||||
|
||||
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
val size = width * height * 4
|
||||
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
|
||||
// colors when copied verbatim. Convert those straight into the output buffer in native code -
|
||||
// one pass, no intermediate ARGB_8888 bitmap.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.convert1010102(this, info)
|
||||
if (pointer != 0L) {
|
||||
recycle()
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
}
|
||||
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
|
||||
}
|
||||
// Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
|
||||
val bitmap = if (config != Bitmap.Config.ARGB_8888) {
|
||||
val converted = copy(Bitmap.Config.ARGB_8888, false)
|
||||
recycle()
|
||||
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
|
||||
} else {
|
||||
this
|
||||
}
|
||||
val size = bitmap.width * bitmap.height * 4
|
||||
val pointer = NativeBuffer.allocate(size)
|
||||
try {
|
||||
val buffer = NativeBuffer.wrap(pointer, size)
|
||||
copyPixelsToBuffer(buffer)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to width.toLong(),
|
||||
"height" to height.toLong(),
|
||||
"rowBytes" to (width * 4).toLong()
|
||||
"width" to bitmap.width.toLong(),
|
||||
"height" to bitmap.height.toLong(),
|
||||
"rowBytes" to (bitmap.width * 4).toLong()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
NativeBuffer.free(pointer)
|
||||
throw e
|
||||
} finally {
|
||||
recycle()
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
@@ -22,6 +24,7 @@ import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
|
||||
@@ -36,12 +39,16 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
|
||||
companion object {
|
||||
val CANCELLED = Result.success<Map<String, Long>?>(null)
|
||||
|
||||
// Shared, process-lifetime pool: RemoteImagesImpl is re-created per FlutterEngine, so a
|
||||
// per-instance pool would leak threads across engine restarts.
|
||||
private val decodeExecutor = Executors.newFixedThreadPool(2)
|
||||
}
|
||||
|
||||
override fun requestImage(
|
||||
url: String,
|
||||
requestId: Long,
|
||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||
preferEncoded: Boolean,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
@@ -51,12 +58,51 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
url,
|
||||
signal,
|
||||
onSuccess = { buffer ->
|
||||
requestMap.remove(requestId)
|
||||
if (signal.isCanceled) {
|
||||
NativeBuffer.free(buffer.pointer)
|
||||
requestMap.remove(requestId)
|
||||
buffer.free()
|
||||
return@fetch callback(CANCELLED)
|
||||
}
|
||||
|
||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
||||
// decode failure (including OOM on huge originals), hand Flutter the encoded bytes as
|
||||
// before.
|
||||
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
decodeExecutor.execute {
|
||||
val res = if (signal.isCanceled) null else try {
|
||||
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
|
||||
source.decodeBitmap().toNativeBuffer()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
requestMap.remove(requestId)
|
||||
when {
|
||||
// Deliver even if the request was cancelled meanwhile: re-checking here would orphan
|
||||
// res's malloc, and Dart frees the buffer itself when it sees the cancel.
|
||||
res != null -> {
|
||||
buffer.free()
|
||||
callback(Result.success(res))
|
||||
}
|
||||
signal.isCanceled -> {
|
||||
buffer.free()
|
||||
callback(CANCELLED)
|
||||
}
|
||||
else -> callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
"pointer" to buffer.pointer,
|
||||
"length" to buffer.offset.toLong()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return@fetch
|
||||
}
|
||||
|
||||
requestMap.remove(requestId)
|
||||
callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
|
||||
@@ -22,8 +22,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3053,
|
||||
"android.injected.version.name" => "3.0.0",
|
||||
"android.injected.version.code" => 3054,
|
||||
"android.injected.version.name" => "3.0.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3053,
|
||||
"android.injected.version.name" => "3.0.0",
|
||||
"android.injected.version.code" => 3054,
|
||||
"android.injected.version.name" => "3.0.1",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.0.0</string>
|
||||
<string>3.0.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -13,10 +13,6 @@ class DriftMemoryService {
|
||||
return _repository.getAll(ownerId);
|
||||
}
|
||||
|
||||
Future<List<DriftMemory>> getAllMemories(String ownerId) {
|
||||
return _repository.getAll(ownerId, onlyToday: false);
|
||||
}
|
||||
|
||||
Future<DriftMemory?> get(String memoryId) {
|
||||
return _repository.get(memoryId);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ enum SyncMigrationTask {
|
||||
v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations.
|
||||
v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets.
|
||||
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
|
||||
v20260701_ResetAlbumsV1, // Album user migration dropped the owner. Sync fresh albums from the server to re-populate them.
|
||||
}
|
||||
|
||||
class SyncStreamService {
|
||||
@@ -103,6 +104,12 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
Future<void> _runPreSyncTasks(List<String> migrations, SemVer semVer) async {
|
||||
if (!migrations.contains(SyncMigrationTask.v20260701_ResetAlbumsV1.name)) {
|
||||
_logger.info("Running pre-sync task: v20260701_ResetAlbumsV1");
|
||||
await _syncApiRepository.deleteSyncAck([SyncEntityType.albumV1]);
|
||||
migrations.add(SyncMigrationTask.v20260701_ResetAlbumsV1.name);
|
||||
}
|
||||
|
||||
if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) {
|
||||
_logger.info("Running pre-sync task: v20260128_ResetExifV1");
|
||||
await _syncApiRepository.deleteSyncAck([
|
||||
|
||||
@@ -12,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||
|
||||
@@ -9,7 +9,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftMemoryRepository(this._db) : super(_db);
|
||||
|
||||
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true}) async {
|
||||
Future<List<DriftMemory>> getAll(String ownerId) async {
|
||||
final now = DateTime.now();
|
||||
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
||||
|
||||
final query =
|
||||
_db.select(_db.memoryEntity).join([
|
||||
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
|
||||
@@ -21,17 +24,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
|
||||
),
|
||||
])
|
||||
..where(_db.memoryEntity.ownerId.equals(ownerId))
|
||||
..where(_db.memoryEntity.deletedAt.isNull());
|
||||
|
||||
if (onlyToday) {
|
||||
final now = DateTime.now();
|
||||
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
|
||||
|
||||
query.where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc));
|
||||
query.where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc));
|
||||
}
|
||||
|
||||
query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
||||
..where(_db.memoryEntity.deletedAt.isNull())
|
||||
..where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc))
|
||||
..where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc))
|
||||
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
|
||||
|
||||
final rows = await query.get();
|
||||
if (rows.isEmpty) {
|
||||
|
||||
@@ -112,7 +112,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
|
||||
}
|
||||
|
||||
if (index == kPhotoTabIndex) {
|
||||
ref.invalidate(driftMemoryLaneProvider);
|
||||
ref.invalidate(driftMemoryFutureProvider);
|
||||
}
|
||||
|
||||
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
|
||||
|
||||
@@ -39,7 +39,7 @@ class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
return Timeline(
|
||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||
topSliverWidgetHeight: hasMemories ? 200 : 0,
|
||||
|
||||
@@ -7,11 +7,9 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
@@ -136,12 +134,7 @@ class _CollectionCards extends StatelessWidget {
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_PeopleCollectionCard(),
|
||||
_PlacesCollectionCard(),
|
||||
_LocalAlbumsCollectionCard(),
|
||||
_MemoriesCollectionCard(),
|
||||
],
|
||||
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -335,76 +328,6 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoriesCollectionCard extends ConsumerWidget {
|
||||
const _MemoriesCollectionCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final memories = ref.watch(driftAllMemoriesProvider);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isTablet = constraints.maxWidth > 600;
|
||||
final widthFactor = isTablet ? 0.25 : 0.5;
|
||||
final size = context.width * widthFactor - 20.0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushRoute(const DriftMemoryListRoute()),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: size,
|
||||
width: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
gradient: LinearGradient(
|
||||
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: memories.widgetWhen(
|
||||
onLoading: () => const Center(child: CircularProgressIndicator()),
|
||||
onData: (memories) {
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
padding: const EdgeInsets.all(12),
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: memories.take(4).map((memory) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: Thumbnail.remote(
|
||||
remoteId: memory.assets[0].id,
|
||||
thumbhash: memory.assets[0].thumbHash ?? "",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'memories'.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftMemoryListPage extends ConsumerStatefulWidget {
|
||||
const DriftMemoryListPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftMemoryListPage> createState() => _DriftMemoryListPageState();
|
||||
}
|
||||
|
||||
class _DriftMemoryListPageState extends ConsumerState<DriftMemoryListPage> {
|
||||
bool _onlyFavorites = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final memories = ref.watch(driftAllMemoriesProvider);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('memories'.tr()),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline),
|
||||
onPressed: () {
|
||||
setState(() => _onlyFavorites = !_onlyFavorites);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: memories.when(
|
||||
data: (memories) {
|
||||
if (_onlyFavorites) {
|
||||
memories = memories.where((memory) => memory.isSaved).toList();
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: constraints.maxWidth > 600 ? 4 : 2,
|
||||
childAspectRatio: 0.5625,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: memories.length,
|
||||
itemBuilder: (context, index) => GestureDetector(
|
||||
onTap: () {
|
||||
if (memories[index].assets.isNotEmpty) {
|
||||
DriftMemoryPage.setMemory(ref, memories[index]);
|
||||
}
|
||||
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
|
||||
child: AbsorbPointer(
|
||||
child: Thumbnail.remote(
|
||||
remoteId: memories[index].assets[0].id,
|
||||
thumbhash: memories[index].assets[0].thumbHash ?? "",
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
child: Text(
|
||||
DateFormat.yMMMMd().format(memories[index].memoryAt),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15),
|
||||
),
|
||||
),
|
||||
if (memories[index].isSaved)
|
||||
const Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: Icon(Icons.favorite, color: Colors.white, size: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, stack) => const Text("Error loading memories"),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -159,8 +159,6 @@ class ShareActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
@@ -173,7 +171,6 @@ class ShareActionButton extends ConsumerWidget {
|
||||
buildContext.pop();
|
||||
});
|
||||
|
||||
// Show download progress with a "Preparing" message
|
||||
return preparingDialog;
|
||||
},
|
||||
barrierDismissible: false,
|
||||
|
||||
@@ -14,7 +14,7 @@ class DriftMemoryLane extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final memoryLaneProvider = ref.watch(driftMemoryLaneProvider);
|
||||
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
|
||||
final memories = memoryLaneProvider.value ?? const [];
|
||||
if (memories.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
|
||||
@@ -116,7 +116,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||
_safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
|
||||
_safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
|
||||
]);
|
||||
_ref.invalidate(driftAllMemoriesProvider);
|
||||
_ref.invalidate(driftMemoryFutureProvider);
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
_safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
|
||||
|
||||
@@ -15,7 +15,7 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
|
||||
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
|
||||
);
|
||||
|
||||
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
||||
if (userId == null || !enabled) {
|
||||
return const [];
|
||||
@@ -29,13 +29,3 @@ final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((r
|
||||
final service = ref.watch(driftMemoryServiceProvider);
|
||||
return service.getMemoryLane(userId);
|
||||
});
|
||||
|
||||
final driftAllMemoriesProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
|
||||
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
|
||||
if (userId == null || !enabled) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final service = ref.watch(driftMemoryServiceProvider);
|
||||
return service.getAllMemories(userId);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,6 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_map.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_memory_list.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
|
||||
@@ -194,7 +193,6 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftMemoryListRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
RedirectRoute(path: '*', redirectTo: '/'),
|
||||
|
||||
@@ -754,22 +754,6 @@ class DriftMapRouteArgs {
|
||||
int get hashCode => key.hashCode ^ initialLocation.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftMemoryListPage]
|
||||
class DriftMemoryListRoute extends PageRouteInfo<void> {
|
||||
const DriftMemoryListRoute({List<PageRouteInfo>? children})
|
||||
: super(DriftMemoryListRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DriftMemoryListRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const DriftMemoryListPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftMemoryPage]
|
||||
class DriftMemoryRoute extends PageRouteInfo<DriftMemoryRouteArgs> {
|
||||
|
||||
Generated
+2
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 3.0.0
|
||||
- API version: 3.0.1
|
||||
- Generator version: 7.22.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
@@ -452,6 +452,7 @@ Class | Method | HTTP request | Description
|
||||
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
|
||||
- [FoldersResponse](doc//FoldersResponse.md)
|
||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||
- [HlsVideoResolution](doc//HlsVideoResolution.md)
|
||||
- [ImageFormat](doc//ImageFormat.md)
|
||||
- [IntegrityReport](doc//IntegrityReport.md)
|
||||
- [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md)
|
||||
|
||||
Generated
+1
@@ -173,6 +173,7 @@ part 'model/face_dto.dart';
|
||||
part 'model/facial_recognition_config.dart';
|
||||
part 'model/folders_response.dart';
|
||||
part 'model/folders_update.dart';
|
||||
part 'model/hls_video_resolution.dart';
|
||||
part 'model/image_format.dart';
|
||||
part 'model/integrity_report.dart';
|
||||
part 'model/integrity_report_response_dto.dart';
|
||||
|
||||
Generated
+6
-24
@@ -265,14 +265,11 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories/statistics';
|
||||
|
||||
@@ -295,9 +292,6 @@ class MemoriesApi {
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (page != null) {
|
||||
queryParams.addAll(_queryParams('', 'page', page));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
@@ -337,15 +331,12 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -443,14 +434,11 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories';
|
||||
|
||||
@@ -473,9 +461,6 @@ class MemoriesApi {
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (page != null) {
|
||||
queryParams.addAll(_queryParams('', 'page', page));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
@@ -515,15 +500,12 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo(for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
Generated
+2
@@ -391,6 +391,8 @@ class ApiClient {
|
||||
return FoldersResponse.fromJson(value);
|
||||
case 'FoldersUpdate':
|
||||
return FoldersUpdate.fromJson(value);
|
||||
case 'HlsVideoResolution':
|
||||
return HlsVideoResolutionTypeTransformer().decode(value);
|
||||
case 'ImageFormat':
|
||||
return ImageFormatTypeTransformer().decode(value);
|
||||
case 'IntegrityReport':
|
||||
|
||||
Generated
+3
@@ -106,6 +106,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is Colorspace) {
|
||||
return ColorspaceTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is HlsVideoResolution) {
|
||||
return HlsVideoResolutionTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ImageFormat) {
|
||||
return ImageFormatTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// 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,26 +14,40 @@ class SystemConfigFFmpegRealtimeDto {
|
||||
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
|
||||
SystemConfigFFmpegRealtimeDto({
|
||||
required this.enabled,
|
||||
this.resolutions = const [],
|
||||
this.videoCodecs = const [],
|
||||
});
|
||||
|
||||
/// Enable real-time HLS transcoding (alpha)
|
||||
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
|
||||
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
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode);
|
||||
(enabled.hashCode) +
|
||||
(resolutions.hashCode) +
|
||||
(videoCodecs.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
|
||||
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled, resolutions=$resolutions, videoCodecs=$videoCodecs]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'resolutions'] = this.resolutions;
|
||||
json[r'videoCodecs'] = this.videoCodecs;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -47,6 +61,8 @@ class SystemConfigFFmpegRealtimeDto {
|
||||
|
||||
return SystemConfigFFmpegRealtimeDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
resolutions: HlsVideoResolution.listFromJson(json[r'resolutions']),
|
||||
videoCodecs: VideoCodec.listFromJson(json[r'videoCodecs']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -95,6 +111,8 @@ class SystemConfigFFmpegRealtimeDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'resolutions',
|
||||
'videoCodecs',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.0.0+3053
|
||||
version: 3.0.1+3054
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -44,11 +44,11 @@ void main() {
|
||||
when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty());
|
||||
});
|
||||
|
||||
group('driftMemoryLaneProvider', () {
|
||||
group('driftMemoryFutureProvider', () {
|
||||
test('re-queries after local midnight', () {
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||
container.listen(driftMemoryFutureProvider, (_, __) {});
|
||||
async.flushMicrotasks();
|
||||
|
||||
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
||||
@@ -66,7 +66,7 @@ void main() {
|
||||
test('cancels the midnight timer when disposed', () {
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
final subscription = container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||
final subscription = container.listen(driftMemoryFutureProvider, (_, __) {});
|
||||
async.flushMicrotasks();
|
||||
verify(() => memoryService.getMemoryLane('user-1')).called(1);
|
||||
|
||||
@@ -83,7 +83,7 @@ void main() {
|
||||
|
||||
fakeAsync((async) {
|
||||
final container = makeContainer();
|
||||
container.listen(driftMemoryLaneProvider, (_, __) {});
|
||||
container.listen(driftMemoryFutureProvider, (_, __) {});
|
||||
async.flushMicrotasks();
|
||||
|
||||
async.elapse(const Duration(hours: 25));
|
||||
|
||||
@@ -7171,17 +7171,6 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -7351,17 +7340,6 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -16228,7 +16206,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
@@ -19142,6 +19120,17 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HlsVideoResolution": {
|
||||
"description": "HLS video resolution",
|
||||
"enum": [
|
||||
480,
|
||||
720,
|
||||
1080,
|
||||
1440,
|
||||
2160
|
||||
],
|
||||
"type": "integer"
|
||||
},
|
||||
"ImageFormat": {
|
||||
"description": "Image format",
|
||||
"enum": [
|
||||
@@ -25776,10 +25765,26 @@
|
||||
"enabled": {
|
||||
"description": "Enable real-time HLS transcoding (alpha)",
|
||||
"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": [
|
||||
"enabled"
|
||||
"enabled",
|
||||
"resolutions",
|
||||
"videoCodecs"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 3.0.0
|
||||
* 3.0.1
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -2302,6 +2302,10 @@ export type SystemConfigBackupsDto = {
|
||||
export type SystemConfigFFmpegRealtimeDto = {
|
||||
/** Enable real-time HLS transcoding (alpha) */
|
||||
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 = {
|
||||
accel: TranscodeHWAccel;
|
||||
@@ -4980,12 +4984,11 @@ export function reverseGeocode({ lat, lon }: {
|
||||
/**
|
||||
* Retrieve memories
|
||||
*/
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -4997,7 +5000,6 @@ export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $t
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
@@ -5022,12 +5024,11 @@ export function createMemory({ memoryCreateDto }: {
|
||||
/**
|
||||
* Retrieve memories statistics
|
||||
*/
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -5039,7 +5040,6 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
@@ -7633,6 +7633,13 @@ export enum CQMode {
|
||||
Cqp = "cqp",
|
||||
Icq = "icq"
|
||||
}
|
||||
export enum HlsVideoResolution {
|
||||
$480 = 480,
|
||||
$720 = 720,
|
||||
$1080 = 1080,
|
||||
$1440 = 1440,
|
||||
$2160 = 2160
|
||||
}
|
||||
export enum ToneMapping {
|
||||
Hable = "hable",
|
||||
Mobius = "mobius",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a href="README_pt_BR.md">Português Brasileiro</a>
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<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>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<p align="center">
|
||||
<br/>
|
||||
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: AGPLv3"></a>
|
||||
<a href="https://discord.immich.app">
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Immich">
|
||||
</p>
|
||||
<h3 align="center">ഫോട്ടോകളും വീഡിയോകളും കൈകാര്യം ചെയ്യുന്നതിനുള്ള ഉയർന്ന കാര്യക്ഷമതയുള്ള സെൽഫ്-ഹോസ്റ്റഡ് ആപ്ലിക്കേഷൻ</h3>
|
||||
<br/>
|
||||
<a href="https://immich.app">
|
||||
<img src="../design/immich-screenshots.png" title="പ്രധാന സ്ക്രീൻഷോട്ട്">
|
||||
</a>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md">English</a>
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_es_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_it_IT.md">Italiano</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
<a href="README_ko_KR.md">한국어</a>
|
||||
<a href="README_de_DE.md">Deutsch</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
<a href="README_tr_TR.md">Türkçe</a>
|
||||
<a href="README_zh_CN.md">简体中文</a>
|
||||
<a href="README_zh_TW.md">正體中文</a>
|
||||
<a href="README_uk_UA.md">Українська</a>
|
||||
<a href="README_ru_RU.md">Русский</a>
|
||||
<a href="README_pt_BR.md">Português Brasileiro</a>
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_vi_VN.md">Tiếng Việt</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
|
||||
> [!WARNING]
|
||||
> ⚠️ നിങ്ങളുടെ വിലയേറിയ ഫോട്ടോകൾക്കും വീഡിയോകൾക്കും എല്ലായ്പ്പോഴും [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) ബാക്കപ്പ് പ്ലാൻ പിന്തുടരുക!
|
||||
>
|
||||
|
||||
> [!NOTE]
|
||||
> ഇൻസ്റ്റാളേഷൻ ഗൈഡുകൾ ഉൾപ്പെടെയുള്ള പ്രധാന ഡോക്യുമെന്റേഷൻ https://immich.app/ എന്ന വെബ്സൈറ്റിൽ നിങ്ങൾക്ക് കണ്ടെത്താം.
|
||||
|
||||
## ലിങ്കുകൾ
|
||||
|
||||
- [ഡോക്യുമെന്റേഷൻ](https://docs.immich.app/)
|
||||
- [വിവരങ്ങൾ](https://docs.immich.app/overview/introduction)
|
||||
- [ഇൻസ്റ്റാളേഷൻ](https://docs.immich.app/install/requirements)
|
||||
- [റോഡ്മാപ്പ്](https://immich.app/roadmap)
|
||||
- [ഡെമോ](#ഡെമോ)
|
||||
- [സവിശേഷതകൾ](#സവിശേഷതകൾ)
|
||||
- [വിവർത്തനങ്ങൾ](https://docs.immich.app/developer/translations)
|
||||
- [സംഭാവന നൽകൽ](https://docs.immich.app/overview/support-the-project)
|
||||
|
||||
## ഡെമോ
|
||||
|
||||
ഡെമോ വെബ്സൈറ്റ് [ഇവിടെ](https://demo.immich.app) ആക്സസ് ചെയ്യാം. മൊബൈൽ ആപ്പിന്റെ ഡെമോ കാണാൻ ആപ്പ് ഇൻസ്റ്റാൾ ചെയ്ത ശേഷം, `Server Endpoint URL` എന്ന സെറ്റിങ്ങിൽ നിങ്ങൾക്ക് `https://demo.immich.app` ഉപയോഗിക്കാം.
|
||||
|
||||
### ലോഗിൻ വിവരങ്ങൾ
|
||||
|
||||
| ഇമെയിൽ | പാസ്വേഡ് |
|
||||
| --------------- | -------- |
|
||||
| demo@immich.app | demo |
|
||||
|
||||
## സവിശേഷതകൾ
|
||||
|
||||
| സവിശേഷതകൾ | മൊബൈൽ | വെബ് |
|
||||
| :-------------------------------------------------- | ------ | --- |
|
||||
| ഫോട്ടോകളും വീഡിയോകളും അപ്ലോഡ് ചെയ്യാനും കാണാനും സാധിക്കും | ഉണ്ട് | ഉണ്ട് |
|
||||
| ആപ്പ് തുറക്കുമ്പോൾ ഓട്ടോമാറ്റിക് ബാക്കപ്പ് | ഉണ്ട് | N/A |
|
||||
| ഫയലുകളുടെ ഇരട്ടിപ്പ് (duplication) തടയുന്നു | ഉണ്ട് | ഉണ്ട് |
|
||||
| ബാക്കപ്പിനായി നിർദ്ദിഷ്ട ആൽബങ്ങൾ തിരഞ്ഞെടുക്കാം | ഉണ്ട് | N/A |
|
||||
| ഫോട്ടോകളും വീഡിയോകളും ലോക്കൽ ഡിവൈസിലേക്ക് ഡൗൺലോഡ് ചെയ്യാം | ഉണ്ട് | ഉണ്ട് |
|
||||
| ഒന്നിലധികം ഉപയോക്താക്കൾക്കുള്ള പിന്തുണ (Multi-user) | ഉണ്ട് | ഉണ്ട് |
|
||||
| ആൽബങ്ങളും പങ്കിട്ട ആൽബങ്ങളും | ഉണ്ട് | ഉണ്ട് |
|
||||
| സ്ക്രബ്ബ് ചെയ്യാവുന്ന/വലിച്ചിഴക്കാവുന്ന സ്ക്രോൾബാർ | ഉണ്ട് | ഉണ്ട് |
|
||||
| റോ (RAW) ഫോർമാറ്റുകൾക്കുള്ള പിന്തുണ | ഉണ്ട് | ഉണ്ട് |
|
||||
| മെറ്റാഡാറ്റ കാഴ്ച (EXIF, മാപ്പ്) | ഉണ്ട് | ഉണ്ട് |
|
||||
| മെറ്റാഡാറ്റ, ഒബ്ജക്റ്റുകൾ, മുഖങ്ങൾ, CLIP എന്നിവ ഉപയോഗിച്ച് തിരയാം | ഉണ്ട് | ഉണ്ട് |
|
||||
| അഡ്മിനിസ്ട്രേറ്റീവ് പ്രവർത്തനങ്ങൾ (യൂസർ മാനേജ്മെന്റ്) | ഇല്ല | ഉണ്ട് |
|
||||
| ബാക്ക്ഗ്രൗണ്ട് ബാക്കപ്പ് | ഉണ്ട് | N/A |
|
||||
| വിർച്വൽ സ്ക്രോൾ | ഉണ്ട് | ഉണ്ട് |
|
||||
| OAuth സപ്പോർട്ട് | ഉണ്ട് | ഉണ്ട് |
|
||||
| API കീകൾ | N/A | ഉണ്ട് |
|
||||
| ലൈവ് ഫോട്ടോ/മോഷൻ ഫോട്ടോ ബാക്കപ്പും പ്ലേബാക്കും | ഉണ്ട് | ഉണ്ട് |
|
||||
| 360 ഡിഗ്രി ഇമേജ് ഡിസ്പ്ലേ പിന്തുണ | ഇല്ല | ഉണ്ട് |
|
||||
| ഉപയോക്താവ് നിർവചിക്കുന്ന സ്റ്റോറേജ് ഘടന | ഉണ്ട് | ഉണ്ട് |
|
||||
| പബ്ലിക് ഷെയറിംഗ് | ഉണ്ട് | ഉണ്ട് |
|
||||
| ആർക്കൈവും പ്രിയപ്പെട്ടവയും (Favorites) | ഉണ്ട് | ഉണ്ട് |
|
||||
| ആഗോള മാപ്പ് (Global Map) | ഉണ്ട് | ഉണ്ട് |
|
||||
| പങ്കാളി പങ്കിടൽ (Partner Sharing) | ഉണ്ട് | ഉണ്ട് |
|
||||
| മുഖം തിരിച്ചറിയലും ക്ലസ്റ്ററിംഗും | ഉണ്ട് | ഉണ്ട് |
|
||||
| ഓർമ്മകൾ (Memories - x വർഷങ്ങൾക്ക് മുമ്പ്) | ഉണ്ട് | ഉണ്ട് |
|
||||
| ഓഫ്ലൈൻ പിന്തുണ | ഉണ്ട് | ഇല്ല |
|
||||
| റീഡ്-ഒൺലി ഗാലറി | ഉണ്ട് | ഉണ്ട് |
|
||||
| അടുക്കിവെച്ച ഫോട്ടോകൾ (Stacked Photos) | ഉണ്ട് | ഉണ്ട് |
|
||||
| ടാഗുകൾ | ഇല്ല | ഉണ്ട് |
|
||||
| ഫോൾഡർ കാഴ്ച | ഉണ്ട് | ഉണ്ട് |
|
||||
|
||||
## വിവർത്തനങ്ങൾ
|
||||
|
||||
വിവർത്തനങ്ങളെക്കുറിച്ച് കൂടുതൽ [ഇവിടെ](https://docs.immich.app/developer/translations) വായിക്കാം.
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/immich/">
|
||||
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
## റെപ്പോസിറ്ററി പ്രവർത്തനം
|
||||
|
||||

|
||||
|
||||
## സ്റ്റാർ ചരിത്രം
|
||||
|
||||
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## സംഭാവന ചെയ്തവർ
|
||||
|
||||
<a href="https://github.com/immich-app/immich/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||
</a>
|
||||
@@ -35,6 +35,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<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>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<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>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a href="README_pt_BR.md">Português Brasileiro</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<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_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<a href="README_sv_SE.md">Svenska</a>
|
||||
<a href="README_ar_JO.md">العربية</a>
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<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>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<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>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<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>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<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>
|
||||
<a href="README_ml_IN.md">മലയാളം</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -56,7 +56,7 @@ FROM builder AS plugins
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
|
||||
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
WORKDIR /app
|
||||
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 \
|
||||
mise //:plugins
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
|
||||
FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev
|
||||
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
|
||||
COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
CQMode,
|
||||
HlsVideoResolution,
|
||||
ImageFormat,
|
||||
LogLevel,
|
||||
OAuthTokenEndpointAuthMethod,
|
||||
@@ -48,6 +49,8 @@ export type SystemConfig = {
|
||||
tonemap: ToneMapping;
|
||||
realtime: {
|
||||
enabled: boolean;
|
||||
videoCodecs: VideoCodec[];
|
||||
resolutions: HlsVideoResolution[];
|
||||
};
|
||||
};
|
||||
integrityChecks: {
|
||||
@@ -247,6 +250,8 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
accelDecode: true,
|
||||
realtime: {
|
||||
enabled: false,
|
||||
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
|
||||
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
|
||||
},
|
||||
},
|
||||
integrityChecks: {
|
||||
|
||||
+60
-9
@@ -235,14 +235,65 @@ export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
|
||||
export const HLS_SEGMENT_DURATION = 2;
|
||||
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
|
||||
export const HLS_VARIANTS = [
|
||||
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' },
|
||||
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' },
|
||||
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' },
|
||||
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' },
|
||||
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' },
|
||||
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' },
|
||||
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' },
|
||||
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' },
|
||||
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' },
|
||||
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000 },
|
||||
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000 },
|
||||
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000 },
|
||||
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000 },
|
||||
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000 },
|
||||
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000 },
|
||||
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000 },
|
||||
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000 },
|
||||
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000 },
|
||||
{ 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 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
|
||||
];
|
||||
|
||||
@@ -14,7 +14,6 @@ const MemorySearchSchema = z
|
||||
isTrashed: stringToBool.optional().describe('Include trashed memories'),
|
||||
isSaved: stringToBool.optional().describe('Filter by saved status'),
|
||||
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
|
||||
page: z.coerce.number().int().min(1).optional().describe('Page number'),
|
||||
order: AssetOrderWithRandomSchema.optional(),
|
||||
})
|
||||
.meta({ id: 'MemorySearchDto' });
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AudioCodecSchema,
|
||||
ColorspaceSchema,
|
||||
CQModeSchema,
|
||||
HlsVideoResolutionSchema,
|
||||
ImageFormatSchema,
|
||||
LogLevelSchema,
|
||||
OAuthTokenEndpointAuthMethodSchema,
|
||||
@@ -115,6 +116,8 @@ const SystemConfigFFmpegSchema = z
|
||||
realtime: z
|
||||
.object({
|
||||
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' }),
|
||||
})
|
||||
|
||||
@@ -529,6 +529,19 @@ export enum 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 {
|
||||
Srgb = 'srgb',
|
||||
P3 = 'p3',
|
||||
|
||||
@@ -90,7 +90,6 @@ export class MemoryRepository implements IBulkAsset {
|
||||
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
|
||||
)
|
||||
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
|
||||
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { TranscodeHardwareAcceleration } from 'src/enum';
|
||||
import { HlsVideoResolution, VideoCodec } from 'src/enum';
|
||||
import { HlsService } from 'src/services/hls.service';
|
||||
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
@@ -96,67 +96,79 @@ seg_10.m4s
|
||||
|
||||
const sessionId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const eiffelExpectedMasterDisabled = `#EXTM3U
|
||||
const eiffelExpectedMasterAv1 = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/0/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/3/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/6/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/8/playlist.m3u8
|
||||
`;
|
||||
|
||||
const eiffelExpectedMasterRkmpp = `#EXTM3U
|
||||
const eiffelExpectedMasterNoAv1 = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/8/playlist.m3u8
|
||||
`;
|
||||
|
||||
const waterfallExpectedMasterDisabled = `#EXTM3U
|
||||
const waterfallExpectedMasterAv1 = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/0/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/3/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/6/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${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, () => {
|
||||
@@ -171,9 +183,24 @@ describe(HlsService.name, () => {
|
||||
const auth = factory.auth();
|
||||
const assetId = 'asset-1';
|
||||
|
||||
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
|
||||
const allCodecs = [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264];
|
||||
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.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { realtime: { enabled: true, videoCodecs, resolutions } },
|
||||
});
|
||||
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
|
||||
mocks.crypto.randomUUID.mockReturnValue(sessionId);
|
||||
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
|
||||
@@ -184,19 +211,19 @@ describe(HlsService.name, () => {
|
||||
});
|
||||
};
|
||||
|
||||
it('returns main playlist for eiffel-tower (1080p portrait, no acceleration)', async () => {
|
||||
setup(eiffelTower, TranscodeHardwareAcceleration.Disabled);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterDisabled);
|
||||
it('offers AV1, HEVC, and H.264 when AV1 is configured and the accelerator supports it', async () => {
|
||||
setup(eiffelTower, allCodecs);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterAv1);
|
||||
});
|
||||
|
||||
it('returns main playlist for eiffel-tower with RKMPP (no AV1 variants)', async () => {
|
||||
setup(eiffelTower, TranscodeHardwareAcceleration.Rkmpp);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterRkmpp);
|
||||
it('omits AV1 when it is not in the configured codecs', async () => {
|
||||
setup(eiffelTower);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterNoAv1);
|
||||
});
|
||||
|
||||
it('returns main playlist for waterfall (4K landscape) with no acceleration', async () => {
|
||||
setup(waterfall, TranscodeHardwareAcceleration.Disabled);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterDisabled);
|
||||
it('offers every resolution up to the source and derives 4K codec levels (waterfall, 4K, 29.83fps)', async () => {
|
||||
setup(waterfall, allCodecs, allResolutions);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterAv1);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when realtime transcoding is disabled', async () => {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { constants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
HLS_SEGMENT_DURATION,
|
||||
HLS_SEGMENT_FILENAME_REGEX,
|
||||
HLS_VARIANTS,
|
||||
HLS_VERSION,
|
||||
SUPPORTED_HWA_CODECS,
|
||||
} from 'src/constants';
|
||||
import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VERSION } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -18,7 +12,7 @@ import { BaseService } from 'src/services/base.service';
|
||||
import { VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
import { PendingEvents } from 'src/utils/event';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { getOutputSize } from 'src/utils/media';
|
||||
import { getCodecString, getOutputSize } from 'src/utils/media';
|
||||
|
||||
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
|
||||
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
|
||||
@@ -131,18 +125,21 @@ export class HlsService extends BaseService {
|
||||
}
|
||||
|
||||
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
|
||||
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3);
|
||||
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
|
||||
const roundedFps = fps.toFixed(3);
|
||||
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
|
||||
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
|
||||
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++) {
|
||||
const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i];
|
||||
if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) {
|
||||
const { resolution, bitrate, codec } = HLS_VARIANTS[i];
|
||||
if (resolution > targetResolution || !videoCodecs.includes(codec) || !resolutions.includes(resolution)) {
|
||||
continue;
|
||||
}
|
||||
const { width, height } = getOutputSize(asset.videoStream, resolution);
|
||||
const codecString = getCodecString(codec, width, height, fps);
|
||||
lines.push(
|
||||
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
|
||||
`#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}`,
|
||||
`${sessionId}/${i}/playlist.m3u8`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ describe(MemoryService.name, () => {
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
||||
mocks.memory.statistics.mockResolvedValue({ total: 2 });
|
||||
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -45,8 +44,6 @@ describe(MemoryService.name, () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
mocks.memory.search.mockResolvedValue([]);
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should map empty result', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
CQMode,
|
||||
HlsVideoResolution,
|
||||
ImageFormat,
|
||||
LogLevel,
|
||||
OAuthTokenEndpointAuthMethod,
|
||||
@@ -76,6 +77,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
tonemap: ToneMapping.Hable,
|
||||
realtime: {
|
||||
enabled: false,
|
||||
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
|
||||
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
|
||||
},
|
||||
},
|
||||
integrityChecks: {
|
||||
|
||||
@@ -381,6 +381,8 @@ describe(TranscodingService.name, () => {
|
||||
'50',
|
||||
'-keyint_min',
|
||||
'50',
|
||||
'-ac',
|
||||
'2',
|
||||
'-copyts',
|
||||
'-r',
|
||||
'50130000/2012441',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AUDIO_ENCODER, SUPPORTED_HWA_CODECS } from 'src/constants';
|
||||
import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
ColorMatrix,
|
||||
@@ -36,6 +36,29 @@ export const getOutputSize = (videoStream: VideoStreamInfo, targetRes: number) =
|
||||
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 {
|
||||
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||
protected constructor(
|
||||
@@ -200,6 +223,11 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
const options = ['-c:v', videoCodec, '-c:a', audioCodec, '-map', `0:${videoStream.index}`, '-map_metadata', '-1'];
|
||||
if (audioStream) {
|
||||
options.push('-map', `0:${audioStream.index}`);
|
||||
// If there are more than 2 channels sometimes the channel config is broken when re-encoded
|
||||
// TODO: Store the number of channels in the db and then set it during the transcoding: -channel_layout 5.1
|
||||
if ([TranscodeTarget.All, TranscodeTarget.Audio].includes(target)) {
|
||||
options.push('-ac', '2');
|
||||
}
|
||||
}
|
||||
if (this.getBFrames() > -1) {
|
||||
options.push('-bf', `${this.getBFrames()}`);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.1",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -107,6 +107,6 @@
|
||||
<Input bind:value={() => getValue<string>(), setValue} />
|
||||
</Field>
|
||||
{:else}
|
||||
<Text>Unknown schema</Text>
|
||||
<Text>{$t('unknown_schema')}</Text>
|
||||
<CodeBlock code={JSON.stringify(schema, null, 2)} />
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte';
|
||||
import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store';
|
||||
import { getAssetHlsSessionUrl, getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
@@ -150,6 +151,15 @@
|
||||
},
|
||||
},
|
||||
useMediaCapabilities: false,
|
||||
xhrSetup: (xhr: XMLHttpRequest, url: string) => {
|
||||
const authenticatedUrl = new URL(url, globalThis.location.origin);
|
||||
for (const [key, value] of Object.entries(authManager.params)) {
|
||||
if (value) {
|
||||
authenticatedUrl.searchParams.set(key, value as string);
|
||||
}
|
||||
}
|
||||
xhr.open('GET', authenticatedUrl.toString());
|
||||
},
|
||||
};
|
||||
|
||||
const releaseSession = () => {
|
||||
|
||||
@@ -37,18 +37,18 @@
|
||||
</div>
|
||||
|
||||
{#await valuePromise}
|
||||
<div class="relative mx-auto font-mono text-2xl font-medium">
|
||||
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="0">
|
||||
<span class="shimmer-text text-gray-300 dark:text-gray-600">{zeros()}</span>
|
||||
</div>
|
||||
{:then data}
|
||||
<div class="relative mx-auto font-mono text-2xl font-medium">
|
||||
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="{data.value} {data.unit ?? ''}">
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros(data)}</span><span>{data.value}</span>
|
||||
{#if data.unit}
|
||||
<code class="font-mono text-base font-normal">{data.unit}</code>
|
||||
{/if}
|
||||
</div>
|
||||
{:catch _}
|
||||
<div class="relative mx-auto font-mono text-2xl font-medium">
|
||||
<div class="relative mx-auto font-mono text-2xl font-medium" aria-label="0">
|
||||
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span>
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
@@ -101,7 +101,9 @@
|
||||
{description}
|
||||
</p>
|
||||
{:else}
|
||||
{@render descriptionSnippet?.()}
|
||||
<div class="pb-2">
|
||||
{@render descriptionSnippet?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if inputType !== SettingInputFieldType.PASSWORD}
|
||||
|
||||
@@ -58,5 +58,14 @@ describe('i18n', () => {
|
||||
expect(getClosestAvailableLocale(['sr_Cyrl'], allLocales)).toBe('sr_Cyrl');
|
||||
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,17 +1,9 @@
|
||||
import {
|
||||
deleteMemory,
|
||||
type MemoryResponseDto,
|
||||
removeMemoryAssets,
|
||||
searchMemories,
|
||||
updateMemory,
|
||||
MemorySearchOrder,
|
||||
MemoryType,
|
||||
memoriesStatistics,
|
||||
} from '@immich/sdk';
|
||||
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
|
||||
type MemoryIndex = {
|
||||
@@ -28,31 +20,10 @@ export type MemoryAsset = MemoryIndex & {
|
||||
nextMemory?: MemoryResponseDto;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 250;
|
||||
|
||||
class MemoryManager {
|
||||
#loading: Promise<void> | undefined;
|
||||
#filters:
|
||||
| {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}
|
||||
| undefined;
|
||||
#hasNextPage: boolean;
|
||||
#page: number;
|
||||
#total: number | undefined;
|
||||
|
||||
constructor() {
|
||||
this.#filters = undefined;
|
||||
this.#hasNextPage = true;
|
||||
this.#page = 1;
|
||||
this.#total = $state(undefined);
|
||||
|
||||
eventManager.on({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
@@ -66,16 +37,6 @@ class MemoryManager {
|
||||
this.scheduleHourlyRefresh();
|
||||
}
|
||||
|
||||
get filters() {
|
||||
return this.#filters;
|
||||
}
|
||||
|
||||
set filters(filters) {
|
||||
this.#filters = filters;
|
||||
this.clearCache();
|
||||
void this.loadNextPage();
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.initialize();
|
||||
}
|
||||
@@ -156,52 +117,22 @@ class MemoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
loadNextPage() {
|
||||
if (this.#hasNextPage) {
|
||||
if (this.#loading === undefined) {
|
||||
this.#loading = this.load(this.#page++);
|
||||
} else {
|
||||
void this.#loading.then(() => (this.#loading = this.load(this.#page++)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get hasNextPage() {
|
||||
return this.#hasNextPage;
|
||||
}
|
||||
|
||||
get total() {
|
||||
return this.#total;
|
||||
}
|
||||
|
||||
private clearCache() {
|
||||
this.#loading = undefined;
|
||||
this.#hasNextPage = true;
|
||||
this.#page = 1;
|
||||
this.#total = undefined;
|
||||
this.memories = [];
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
if (!this.#loading) {
|
||||
this.#loading = this.load(this.#page++);
|
||||
this.#loading = this.load();
|
||||
}
|
||||
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private async load(page: number) {
|
||||
if (this.#filters !== undefined) {
|
||||
const items = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page });
|
||||
this.memories.push(...items);
|
||||
|
||||
if (this.#total === undefined) {
|
||||
const { total } = await memoriesStatistics(this.#filters);
|
||||
this.#total = total;
|
||||
}
|
||||
|
||||
this.#hasNextPage = this.memories.length < this.#total;
|
||||
}
|
||||
private async load() {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
}
|
||||
|
||||
private scheduleHourlyRefresh() {
|
||||
@@ -215,18 +146,12 @@ class MemoryManager {
|
||||
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.#page <= 2) {
|
||||
this.clearCache();
|
||||
this.loadNextPage();
|
||||
}
|
||||
this.#loading = this.load();
|
||||
|
||||
// Schedule subsequent events hourly
|
||||
setInterval(
|
||||
() => {
|
||||
if (this.#page <= 2) {
|
||||
this.clearCache();
|
||||
this.loadNextPage();
|
||||
}
|
||||
this.#loading = this.load();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
options={[
|
||||
{ label: $t('role_editor'), value: AlbumUserRole.Editor },
|
||||
{ label: $t('role_viewer'), value: AlbumUserRole.Viewer },
|
||||
{ label: $t('owner'), value: AlbumUserRole.Owner },
|
||||
{ label: $t('owner'), value: AlbumUserRole.Owner, disabled: true },
|
||||
{ label: $t('remove_user'), value: 'none' },
|
||||
] as SelectOption<AlbumUserRole | 'none'>[]}
|
||||
onChange={(value) => handleRoleSelect(user, value)}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const Route = {
|
||||
'/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''),
|
||||
|
||||
// memories
|
||||
memories: (params?: { id?: string }) => '/memories' + asQueryString(params),
|
||||
memories: (params?: { id?: string }) => '/memory' + asQueryString(params),
|
||||
|
||||
// partners
|
||||
viewPartner: ({ id }: { id: string }) => `/partners/${id}`,
|
||||
|
||||
@@ -134,10 +134,13 @@ const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: strin
|
||||
const successCount = results.filter(({ success }) => success).length;
|
||||
const duplicateCount = results.filter(({ error }) => error === 'duplicate').length;
|
||||
let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } });
|
||||
if (successCount > 0) {
|
||||
description = $t('assets_added_to_album_count', { values: { count: successCount } });
|
||||
} else if (duplicateCount > 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 } });
|
||||
} else if (successCount > 0) {
|
||||
description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } });
|
||||
}
|
||||
|
||||
toastManager.primary(
|
||||
|
||||
@@ -20,13 +20,12 @@ import {
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { init, register, t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { defaultLang, locales } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store';
|
||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { convertBCP47, langs } from '$lib/utils/i18n';
|
||||
@@ -367,13 +366,9 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
|
||||
|
||||
export const memoryLaneTitle = derived(t, ($t) => {
|
||||
return (memory: MemoryResponseDto) => {
|
||||
const now = new Date();
|
||||
if (memory.type === MemoryType.OnThisDay) {
|
||||
const now = new Date();
|
||||
const memoryDate = new Date(memory.memoryAt);
|
||||
|
||||
return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth()
|
||||
? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } })
|
||||
: DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
|
||||
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
|
||||
}
|
||||
|
||||
return $t('unknown');
|
||||
|
||||
@@ -25,7 +25,7 @@ export class GCastDestination implements ICastDestination {
|
||||
private currentUrl: string | null = null;
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
if (!authManager.authenticated || authManager.preferences.cast.gCastEnabled) {
|
||||
if (!authManager.authenticated || !authManager.preferences.cast.gCastEnabled) {
|
||||
this.isAvailable = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ export const getFormatter = async () => {
|
||||
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 fileCodes = Object.keys(modules)
|
||||
@@ -33,7 +41,9 @@ const getSubLocales = (locale: string) => {
|
||||
|
||||
export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => {
|
||||
const allLocalesSet = new Set(allLocales.map((locale) => convertBCP47(locale)));
|
||||
return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
|
||||
return locales
|
||||
.map((locale) => aliases[locale] ?? locale)
|
||||
.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale)));
|
||||
};
|
||||
|
||||
export const getPreferredLocale = () => getClosestAvailableLocale(navigator.languages, langCodes);
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Icon, ImageCarousel } from '@immich/ui';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -28,22 +28,13 @@
|
||||
return targetField?.items || [];
|
||||
};
|
||||
|
||||
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
|
||||
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
|
||||
let recents = $derived(
|
||||
getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
);
|
||||
let people = $state(data.people.people);
|
||||
let memories = $derived(
|
||||
data.memories.map((memory) => ({
|
||||
id: memory.id,
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: Route.memories({ id: memory.assets[0].id }),
|
||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
})),
|
||||
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
);
|
||||
let people = $state(data.response.people);
|
||||
|
||||
let hasPeople = $derived(data.people.total > 0);
|
||||
let hasPeople = $derived(data.response.total > 0);
|
||||
|
||||
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||
for (const person of people) {
|
||||
@@ -133,20 +124,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if memories.length > 0}
|
||||
<div class="mt-2 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('memories')}</p>
|
||||
<a
|
||||
href={Route.memories()}
|
||||
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
|
||||
draggable="false">{$t('view_all')}</a
|
||||
>
|
||||
</div>
|
||||
<ImageCarousel items={memories} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if recents.length > 0}
|
||||
<div class="mt-2 mb-6">
|
||||
<div class="flex justify-between">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user