Compare commits

..

1 Commits

Author SHA1 Message Date
timonrieger 7e0dd0f61b chore: update documentation to use mise commands 2026-05-20 13:03:41 +02:00
67 changed files with 339 additions and 29550 deletions
-7
View File
@@ -30,32 +30,25 @@ jobs:
filters: |
i18n:
- 'i18n/**'
- 'mise.toml'
web:
- 'web/**'
- 'i18n/**'
- 'packages/sdk/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
server:
- 'server/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
e2e:
- 'e2e/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
mobile:
- 'mobile/**'
- 'mise.toml'
machine-learning:
- 'machine-learning/**'
- 'mise.toml'
.github:
- '.github/**'
force-filters: |
-65
View File
@@ -1,65 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.opentofu]]
version = "1.11.6"
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"
[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"
[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"
[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"
[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"
[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"
[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"
[[tools.terragrunt]]
version = "1.0.3"
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"
[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"
[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"
[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"
[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"
[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"
[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"
+2 -2
View File
@@ -5,7 +5,7 @@ After making any changes in the `server/src/schema`, a database migration need t
1. Run the command
```bash
pnpm run migrations:generate <migration-name>
mise //server:migrations generate <migration-name>
```
2. Check if the migration file makes sense.
@@ -18,7 +18,7 @@ The server will automatically detect `*.ts` file changes and restart. Part of th
If you need to undo the most recently applied migration—for example, when developing or testing on schema changes—run:
```bash
pnpm run migrations:revert
mise //server:migrations revert
```
This command rolls back the latest migration and brings the database schema back to its previous state.
+19 -30
View File
@@ -252,44 +252,33 @@ To connect the mobile app to your Dev Container:
The Dev Container supports multiple ways to run tests:
#### Using Mise Commands (Recommended)
```bash
# Run tests for specific components
mise run checklist # in `server/`, `web/`, `packages/cli`
# Server
mise //server:test # unit tests
mise //server:test-medium # medium / integration tests
# Web
mise //web:test # unit tests
# E2E
mise //e2e:test # API tests
mise //e2e:test-web # web UI tests (Playwright)
# Run all checks for a component
mise //server:checklist
mise //web:checklist
```
#### Using PNPM Directly
```bash
# Server tests
cd /workspaces/immich/server
pnpm test # Run all tests
pnpm run test:medium # Medium tests (integration tests)
pnpm run test:watch # Watch mode
pnpm run test:cov # Coverage report
# Web tests
cd /workspaces/immich/web
pnpm test # Run all tests
pnpm run test:watch # Watch mode
# E2E tests
cd /workspaces/immich/e2e
pnpm run test # Run API tests
pnpm run test:web # Run web UI tests
```
### Additional Make Commands
### Additional Commands
```bash
# API generation
make open-api # Generate OpenAPI specs
make open-api-typescript # Generate TypeScript SDK
make open-api-dart # Generate Dart SDK
mise //:open-api # Generate OpenAPI specs
mise //:open-api-typescript # Generate TypeScript SDK
mise //:open-api-dart # Generate Dart SDK
# Database
mise sql # Sync database schema
mise //server:sql # Sync database schema
```
### Debugging
+32 -13
View File
@@ -8,34 +8,42 @@ When contributing code through a pull request, please check the following:
## Web Checks
- [ ] `pnpm run lint` (linting via ESLint)
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] `pnpm run check:svelte` (Type checking via SvelteKit)
- [ ] `pnpm run check:typescript` (check typescript)
- [ ] `pnpm test` (unit tests)
- [ ] `mise //web:lint` (linting via ESLint)
- [ ] `mise //web:format` (formatting via Prettier)
- [ ] `mise //web:check-svelte` (type checking via SvelteKit)
- [ ] `mise //web:check-typescript` (type checking via `tsc`)
- [ ] `mise //web:test` (unit tests)
:::tip AIO
Run all web checks with `pnpm run check:all`
Run all web checks with `mise //web:checklist`
:::
:::tip Auto Fix
Use `mise //web:lint-fix` and `mise //web:format-fix` to automatically correct some issues.
:::
## Documentation
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] `mise //docs:format` (formatting via Prettier)
- [ ] Update the `_redirects` file if you have renamed a page or removed it from the documentation.
:::tip Auto Fix
Use `mise //docs:format-fix` to automatically fix formatting.
:::
## Server Checks
- [ ] `pnpm run lint` (linting via ESLint)
- [ ] `pnpm run format` (formatting via Prettier)
- [ ] `pnpm run check` (Type checking via `tsc`)
- [ ] `pnpm test` (unit tests)
- [ ] `mise //server:lint` (linting via ESLint)
- [ ] `mise //server:format` (formatting via Prettier)
- [ ] `mise //server:check` (type checking via `tsc`)
- [ ] `mise //server:test` (unit tests)
:::tip AIO
Run all server checks with `pnpm run check:all`
Run all server checks with `mise //server:checklist`
:::
:::tip Auto Fix
You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`.
Use `mise //server:lint-fix` and `mise //server:format-fix` to automatically correct some issues.
:::
## Mobile Checklist
@@ -53,6 +61,17 @@ Run all these commands at once with `mise //mobile:checklist`
You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`.
:::
## Machine Learning Checklist
- [ ] `mise //machine-learning:lint` (linting via ruff)
- [ ] `mise //machine-learning:format` (formatting via ruff)
- [ ] `mise //machine-learning:check` (type checking via mypy)
- [ ] `mise //machine-learning:test` (unit tests via pytest)
:::tip AIO
Run all machine learning checks with `mise //machine-learning:checklist`
:::
## OpenAPI
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details.
+18 -17
View File
@@ -32,6 +32,10 @@ This environment includes the services below. Additional details are available i
All the services are packaged to run as with single Docker Compose command.
:::tip mise
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
:::
### Server and web apps
1. Clone the project repo.
@@ -56,22 +60,23 @@ You can access the web from `http://your-machine-ip:3000` or `http://localhost:3
#### Connect web to a remote backend
If you only want to do web development connected to an existing, remote backend, follow these steps:
1. Build the Immich SDK - `pnpm --filter @immich/sdk install && pnpm --filter @immich/sdk build`
2. Enter the web directory - `cd web/`
3. Install web dependencies - `pnpm i`
4. Start the web development server
If you only want to do web development connected to an existing, remote backend, run from the repo root:
```bash
IMMICH_SERVER_URL=https://demo.immich.app/ pnpm run dev
IMMICH_SERVER_URL=https://demo.immich.app/ mise //web:start
```
This will install all dependencies (including the SDK) and start the dev server in one step. To connect to the hosted demo server specifically, use the shorthand:
```bash
mise //web:start-demo
```
If you're using PowerShell on Windows you may need to set the env var separately like so:
```powershell
$env:IMMICH_SERVER_URL = "https://demo.immich.app/"
pnpm run dev
mise //web:start
```
#### `@immich/ui`
@@ -90,20 +95,16 @@ To see local changes to `@immich/ui` in Immich, do the following:
#### Setup
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
2. Change to the immich (root) directory and trust the mise config with `mise trust`.
3. Install tools with mise: `mise install`.
4. Change to the `mobile/` directory.
5. Run `flutter pub get` to install the dependencies.
6. Run `make translation` to generate the translation file.
7. Run `flutter run` to start the app.
1. Run `mise //mobile:install` to install Flutter dependencies.
2. Run `mise //mobile:translation` to generate the translation file.
3. Change to the `mobile/` directory and run `flutter run` to start the app.
#### Translation
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then, from the `mobile/` directory, run
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then run:
```bash
make translation
mise //mobile:translation
```
The mobile app asks you what backend to connect to. You can utilize the demo backend (https://demo.immich.app/) if you don't need to change server code or upload photos. Alternatively, you can run the server yourself per the instructions above.
+3 -4
View File
@@ -4,8 +4,8 @@
### Unit tests
Unit are run by calling `pnpm run test` from the `server/` directory.
You need to run `pnpm install` (in `server/`) before _once_.
Unit tests are run with `mise //server:test`.
You need to run `mise //server:install` before _once_.
### End to end tests
@@ -17,8 +17,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_:
- `pnpm install`
- `pnpm --filter @immich/sdk --filter @immich/cli build`
- `mise //e2e:ci-setup` (installs e2e, SDK, and CLI dependencies)
- `mise //:open-api`
Once the test environment is running, the e2e tests can be run via:
+1 -3
View File
@@ -10,6 +10,7 @@ const config = {
url: 'https://docs.immich.app',
baseUrl: '/',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
favicon: 'img/favicon.png',
// GitHub pages deployment config.
@@ -28,9 +29,6 @@ const config = {
// Mermaid diagrams
markdown: {
mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},
themes: ['@docusaurus/theme-mermaid'],
-5
View File
@@ -1,5 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.66.0"
backend = "npm:wrangler"
+1 -1
View File
@@ -3,7 +3,7 @@ run = "pnpm install --filter documentation --frozen-lockfile"
[tasks.start]
env._.path = "./node_modules/.bin"
run = "docusaurus --port 3005"
run = "docusaurus start --port 3005"
[tasks.build]
env._.path = "./node_modules/.bin"
+1 -1
View File
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.12.4",
"@types/node": "^24.12.2",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
-72
View File
@@ -1,72 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.python]]
version = "3.11.15"
backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:243f794278eff6adba96ed3677ec6877175df84c25f140e17f09f9be82d0f12a"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:52b4c52094ff8b383a45c694acf4c5c0e883152be6d5229a35a8186ce907c6eb"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:171dffd8c0f66e8a0725364a7428015b22fc18dd298b24f541392e17dd0e561f"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:2ac90fef8917ebd14826a6d667593a06cf0ae5f745ba9b1147dc086dd35f5284"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-unknown-linux-musl-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:fdfc363b538662eb7441a14e06f72c4a992c56af7f401f5730ea5081f8f8ad6e"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:5f1eb247cbca2c0ad5ccbf6d299a4f54b31b5c63b492d74c3531dc4344a42f88"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:756d7f148498b8822f6aedf44a020613576f09983161f346ad36dcef6238cdc3"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260510/cpython-3.11.15+20260510-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.uv]]
version = "0.8.15"
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"
[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"
[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"
[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"
[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"
[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"
[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"
-391
View File
@@ -1,391 +0,0 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.flutter]]
version = "3.41.9-stable"
backend = "asdf:flutter"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-arm64-musl"]
checksum = "sha256:253da2512b149913dfe345bf9a62a79acb2d730f66e71162ba4a92dfc4224b82"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543838"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.linux-x64-musl"]
checksum = "sha256:477e086d4099c12f21e5ccd83b005d5fb945dd4cac4fd127fd9a08d7649af1cf"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-linux-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543797"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-arm64"]
checksum = "sha256:30bede64367d09067093cc57af6ec9496d7717898138ded5cb98a16ac8dd9d93"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-arm-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543757"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.macos-x64"]
checksum = "sha256:e56cb99872be7445a4de1d37e5438ca70e3bcd83be7a2b9b385e3538881f8068"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-macos-x64-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543727"
github_attestations = "unavailable"
[tools."github:CQLabs/homebrew-dcm"."platforms.windows-x64"]
checksum = "sha256:f133470daa3fb0427f039b424392af7e917d7e7db6b556aa2a968ab0e31587da"
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.37.0/dcm-windows-release.zip"
url_api = "https://api.github.com/repos/CQLabs/homebrew-dcm/releases/assets/404543660"
github_attestations = "unavailable"
[[tools."github:extism/cli"]]
version = "1.6.3"
backend = "github:extism/cli"
[tools."github:extism/cli"."platforms.linux-arm64"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.linux-x64"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.linux-x64-musl"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.macos-arm64"]
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694029"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.macos-x64"]
checksum = "sha256:9a2f71b6e6009685a622cc3084e52d2a1a8e23c98d29ffa72e666e9dc699855f"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694026"
github_attestations = "unavailable"
[tools."github:extism/cli"."platforms.windows-x64"]
checksum = "sha256:47e4ed2782445b2b08a4d1ac127211588f8b4d1fc25fd6481d4cb65151b5213c"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-windows-amd64.zip"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
github_attestations = "unavailable"
[[tools."github:extism/js-pdk"]]
version = "1.6.0"
backend = "github:extism/js-pdk"
[tools."github:extism/js-pdk"."platforms.linux-arm64"]
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
checksum = "sha256:15a186250e68d6bff4ec839fff275d45a90e383a69209dcc1239eb9e3aee6e1b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223214"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.linux-x64"]
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
checksum = "sha256:4ded271ccf465031ccd0dc35e7a140e134d7f30721671cc4a8e1ff805d4aad68"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-linux-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223119"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.macos-arm64"]
checksum = "sha256:548e25bda3971a07c32d78a249135cf8cb7b3eede101e878e06e53e01ac2e0ce"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-aarch64-macos-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223215"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.macos-x64"]
checksum = "sha256:d85a875c2a071f0c29fe572764c52c3a499f157ab7f9efac8939a4364390e29b"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-macos-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353223239"
github_attestations = "unavailable"
[tools."github:extism/js-pdk"."platforms.windows-x64"]
checksum = "sha256:97b7b746141e4777e1ca2b76febdeb16dc9d314ff6a4257df05a476b67228acc"
url = "https://github.com/extism/js-pdk/releases/download/v1.6.0/extism-js-x86_64-windows-v1.6.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
github_attestations = "unavailable"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
github_attestations = "unavailable"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
github_attestations = "unavailable"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
[tools."github:webassembly/binaryen"."platforms.linux-arm64"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.linux-x64"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.macos-arm64"]
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926134"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.macos-x64"]
checksum = "sha256:b389bb0731758d86c3cb266d01d28a12725c23bd3cabc3df34faa162af0887e9"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-macos.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926135"
github_attestations = "unavailable"
[tools."github:webassembly/binaryen"."platforms.windows-x64"]
checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2203"
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"
github_attestations = "unavailable"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-aarch64_bin.tar.gz"
[tools.java."platforms.linux-x64"]
checksum = "sha256:a2def047a73941e01a73739f92755f86b895811afb1f91243db214cff5bdac3f"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_linux-x64_bin.tar.gz"
[tools.java."platforms.macos-arm64"]
checksum = "sha256:b3d588e16ec1e0ef9805d8a696591bd518a5cea62567da8f53b5ce32d11d22e4"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-aarch64_bin.tar.gz"
[tools.java."platforms.macos-x64"]
checksum = "sha256:8fd09e15dc406387a0aba70bf5d99692874e999bf9cd9208b452b5d76ac922d3"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_macos-x64_bin.tar.gz"
[tools.java."platforms.windows-x64"]
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
[[tools.node]]
version = "24.15.0"
backend = "core:node"
[tools.node."platforms.linux-arm64"]
checksum = "sha256:73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-arm64.tar.gz"
[tools.node."platforms.linux-arm64-musl"]
checksum = "sha256:31e98aa960a067da91edffd5d93bc46657b5d2a8029612c359f5f2ac0060152a"
url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-arm64-musl.tar.gz"
[tools.node."platforms.linux-x64"]
checksum = "sha256:44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-linux-x64.tar.gz"
[tools.node."platforms.linux-x64-musl"]
checksum = "sha256:f55af5bd489c5347b113ca6594cae00a54b30ba57ac5875324311bfc6f4762e3"
url = "https://unofficial-builds.nodejs.org/download/release/v24.15.0/node-v24.15.0-linux-x64-musl.tar.gz"
[tools.node."platforms.macos-arm64"]
checksum = "sha256:372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-arm64.tar.gz"
[tools.node."platforms.macos-x64"]
checksum = "sha256:ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-darwin-x64.tar.gz"
[tools.node."platforms.windows-x64"]
checksum = "sha256:cc5149eabd53779ce1e7bdc5401643622d0c7e6800ade18928a767e940bb0e62"
url = "https://nodejs.org/dist/v24.15.0/node-v24.15.0-win-x64.zip"
[[tools."npm:oazapfts"]]
version = "7.5.0"
backend = "npm:oazapfts"
[[tools.opentofu]]
version = "1.11.6"
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"
[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"
[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"
[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"
[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"
[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"
[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"
[[tools.pnpm]]
version = "10.33.1"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:ed8aa7901cf325f4cf5019405bdd6bf988426e4b23d08fe9b12ea4df7046f23e"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-arm64"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:fba950842532edd365e949b74643b64e6311089a45532dbe1e8f909a247fe3e9"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-linux-x64"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:909ced0038b00881d4d620ba2018c5d9691de373deea8e3c84b722b44324e47c"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-arm64"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:afdad60b83f4f482f4c95cc79325f29aef776d0922a324f023a312f40e0cc7d3"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-macos-x64"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:67b23fd8c6800566b1cc04c446b170ff6e7977250084e4d8df9bfdbd8e6f4d02"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.1/pnpm-win-x64.exe"
[[tools.terragrunt]]
version = "1.0.3"
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"
[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"
[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"
[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"
[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"
[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"
[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"
+4 -5
View File
@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
"aqua:flutter/flutter" = "3.41.9"
flutter = "3.41.9"
pnpm = "10.33.1"
terragrunt = "1.0.3"
opentofu = "1.11.6"
@@ -50,12 +50,11 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[settings]
experimental = true
pin = true
lockfile = true
[tasks.plugins]
run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build"
]
[tasks.open-api-typescript]
@@ -77,8 +76,8 @@ run = [
{ task = "//server:install" },
{ task = "//server:build" },
{ task = "//server:sync-open-api" },
{ task = ":open-api-typescript" },
{ task = ":open-api-dart" },
{ task = ":open-api-typescript"},
{ task = ":open-api-dart"},
]
[tasks.sql]
@@ -207,18 +207,6 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
}
}
enum class EditState(val raw: Int) {
NOT_EDITED(0),
EDITED(1),
UNKNOWN(2);
companion object {
fun ofRaw(raw: Int): EditState? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class PlatformAsset (
val id: String,
@@ -484,52 +472,6 @@ data class CloudIdResult (
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class BaseResource (
val path: String,
val sha1: String,
val sizeBytes: Long,
val mimeType: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
val path = pigeonVar_list[0] as String
val sha1 = pigeonVar_list[1] as String
val sizeBytes = pigeonVar_list[2] as Long
val mimeType = pigeonVar_list[3] as String
return BaseResource(path, sha1, sizeBytes, mimeType)
}
}
fun toList(): List<Any?> {
return listOf(
path,
sha1,
sizeBytes,
mimeType,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as BaseResource
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1) && MessagesPigeonUtils.deepEquals(this.sizeBytes, other.sizeBytes) && MessagesPigeonUtils.deepEquals(this.mimeType, other.mimeType)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
result = 31 * result + MessagesPigeonUtils.deepHash(this.sizeBytes)
result = 31 * result + MessagesPigeonUtils.deepHash(this.mimeType)
return result
}
}
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -539,40 +481,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
}
130.toByte() -> {
return (readValue(buffer) as Long?)?.let {
EditState.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it)
}
}
132.toByte() -> {
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it)
}
}
133.toByte() -> {
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
}
}
134.toByte() -> {
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
135.toByte() -> {
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
}
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BaseResource.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
@@ -582,32 +514,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
is EditState -> {
stream.write(130)
writeValue(stream, value.raw.toLong())
}
is PlatformAsset -> {
stream.write(131)
stream.write(130)
writeValue(stream, value.toList())
}
is PlatformAlbum -> {
stream.write(132)
stream.write(131)
writeValue(stream, value.toList())
}
is SyncDelta -> {
stream.write(133)
stream.write(132)
writeValue(stream, value.toList())
}
is HashResult -> {
stream.write(134)
stream.write(133)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(135)
writeValue(stream, value.toList())
}
is BaseResource -> {
stream.write(136)
stream.write(134)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
@@ -630,8 +554,6 @@ interface NativeSyncApi {
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
companion object {
/** The codec used by NativeSyncApi. */
@@ -842,48 +764,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val assetIdArg = args[0] as String
val allowNetworkAccessArg = args[1] as Boolean
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -453,14 +453,4 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
return emptyList()
}
// Android has no Photos-style edit original to stack; iOS-only.
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
callback(Result.success(null))
}
// iOS-only; Android assets never carry a Photos-style edit.
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
callback(Result.success(EditState.NOT_EDITED))
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -117
View File
@@ -183,12 +183,6 @@ enum PlatformAssetPlaybackStyle: Int {
case videoLooping = 5
}
enum EditState: Int {
case notEdited = 0
case edited = 1
case unknown = 2
}
/// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable {
var id: String
@@ -464,52 +458,6 @@ struct CloudIdResult: Hashable {
}
}
/// Generated class from Pigeon that represents data sent in messages.
struct BaseResource: Hashable {
var path: String
var sha1: String
var sizeBytes: Int64
var mimeType: String
// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
let path = pigeonVar_list[0] as! String
let sha1 = pigeonVar_list[1] as! String
let sizeBytes = pigeonVar_list[2] as! Int64
let mimeType = pigeonVar_list[3] as! String
return BaseResource(
path: path,
sha1: sha1,
sizeBytes: sizeBytes,
mimeType: mimeType
)
}
func toList() -> [Any?] {
return [
path,
sha1,
sizeBytes,
mimeType,
]
}
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
if Swift.type(of: lhs) != Swift.type(of: rhs) {
return false
}
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1) && deepEqualsMessages(lhs.sizeBytes, rhs.sizeBytes) && deepEqualsMessages(lhs.mimeType, rhs.mimeType)
}
func hash(into hasher: inout Hasher) {
hasher.combine("BaseResource")
deepHashMessages(value: path, hasher: &hasher)
deepHashMessages(value: sha1, hasher: &hasher)
deepHashMessages(value: sizeBytes, hasher: &hasher)
deepHashMessages(value: mimeType, hasher: &hasher)
}
}
private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
@@ -520,23 +468,15 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
}
return nil
case 130:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return EditState(rawValue: enumResultAsInt)
}
return nil
case 131:
return PlatformAsset.fromList(self.readValue() as! [Any?])
case 132:
case 131:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 133:
case 132:
return SyncDelta.fromList(self.readValue() as! [Any?])
case 134:
case 133:
return HashResult.fromList(self.readValue() as! [Any?])
case 135:
case 134:
return CloudIdResult.fromList(self.readValue() as! [Any?])
case 136:
return BaseResource.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
}
@@ -548,26 +488,20 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
if let value = value as? PlatformAssetPlaybackStyle {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? EditState {
super.writeByte(130)
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(131)
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeByte(132)
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
super.writeByte(133)
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
super.writeByte(134)
super.writeByte(133)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(135)
super.writeValue(value.toList())
} else if let value = value as? BaseResource {
super.writeByte(136)
super.writeByte(134)
super.writeValue(value.toList())
} else {
super.writeValue(value)
@@ -604,8 +538,6 @@ protocol NativeSyncApi {
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -806,45 +738,5 @@ class NativeSyncApiSetup {
} else {
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
}
let getBaseResourceChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getBaseResourceChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getBaseResourceChannel.setMessageHandler(nil)
}
let getEditStateChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
if let api = api {
getEditStateChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let assetIdArg = args[0] as! String
let allowNetworkAccessArg = args[1] as! Bool
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getEditStateChannel.setMessageHandler(nil)
}
}
}
-132
View File
@@ -1,6 +1,5 @@
import Photos
import CryptoKit
import UniformTypeIdentifiers
struct AssetWrapper: Hashable, Equatable {
let asset: PlatformAsset
@@ -416,135 +415,4 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
}
return mappings;
}
func getBaseResource(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<BaseResource?, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
let resources = PHAssetResource.assetResources(for: asset)
let state = await Self.classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
guard state == .edited, let original = resources.first(where: { $0.type == .photo }) else {
return self.completeWhenActive(for: completion, with: .success(nil))
}
do {
let result = try await self.streamBaseResource(
resource: original,
localId: asset.localIdentifier,
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(result))
} catch {
self.completeWhenActive(for: completion, with: .failure(error))
}
}
}
// Returns whether the asset carries a live Photos edit without reading the photo
// itself, only the small adjustment metadata. The revert probe relies on this to
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
// mistakes an unreadable edit for a revert.
func getEditState(
assetId: String,
allowNetworkAccess: Bool,
completion: @escaping (Result<EditState, Error>) -> Void
) {
Task { [weak self] in
guard let self = self else { return }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
// Not in the library don't let the caller act on a "not edited" answer.
return self.completeWhenActive(for: completion, with: .success(.unknown))
}
let state = await Self.classifyEdit(
resources: PHAssetResource.assetResources(for: asset),
allowNetworkAccess: allowNetworkAccess
)
self.completeWhenActive(for: completion, with: .success(state))
}
}
// adjustmentRenderTypes value of a photo with no live user edit a plain
// capture (incl. Photographic Style) or a reverted edit. Any real edit moves it.
private static let kNoEditRenderTypes = 27648
// Classifies an asset's edit state from its Adjustments.plist, reading only the
// adjustment metadata (not the photo). A Photos edit is authored by
// com.apple.mobileslideshow (or a third-party editor) and moves the render
// pipeline off the no-edit baseline; a capture (including a Photographic Style)
// is authored by com.apple.camera, and a revert keeps the Photos editor id but
// restores the baseline render types so requiring both excludes reverts.
// Cleanup/object-removal stays camera-attributed but writes
// AdjustmentsSecondary.data, the fallback. `.unknown` = the adjustment data
// couldn't be read (e.g. offloaded with network disallowed).
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
return .edited
}
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
return .notEdited
}
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
else {
return .unknown
}
let editor = plist["adjustmentEditorBundleID"] as? String
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
let isUserEdit = editor != nil && editor != "com.apple.camera" && renderTypes != kNoEditRenderTypes
return isUserEdit ? .edited : .notEdited
}
private func streamBaseResource(
resource: PHAssetResource,
localId: String,
allowNetworkAccess: Bool
) async throws -> BaseResource {
guard let data = await Self.collectResourceData(resource, allowNetworkAccess: allowNetworkAccess) else {
throw NSError(
domain: "NativeSyncApi",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Failed to read base resource for \(localId)"]
)
}
let safeId = localId.replacingOccurrences(of: "/", with: "_")
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("immich_base", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let unique = UUID().uuidString.prefix(8)
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
try data.write(to: tempUrl, options: .atomic)
let sha1 = Data(Insecure.SHA1.hash(data: data)).base64EncodedString()
let mime = UTType(resource.uniformTypeIdentifier)?.preferredMIMEType ?? "application/octet-stream"
return BaseResource(path: tempUrl.path, sha1: sha1, sizeBytes: Int64(data.count), mimeType: mime)
}
private static func collectResourceData(
_ resource: PHAssetResource,
allowNetworkAccess: Bool
) async -> Data? {
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = allowNetworkAccess
var buffer = Data()
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { data in buffer.append(data) },
completionHandler: { error in continuation.resume(returning: error == nil ? buffer : nil) }
)
}
}
}
-1
View File
@@ -20,7 +20,6 @@ const String kSecuredPinCode = "secured_pin_code";
const String kManualUploadGroup = 'manual_upload_group';
const String kBackupGroup = 'backup_group';
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
const String kBackupEditPairGroup = 'backup_edit_pair_group';
const String kDownloadGroupImage = 'group_image';
const String kDownloadGroupVideo = 'group_video';
const String kDownloadGroupLivePhoto = 'group_livephoto';
@@ -12,13 +12,6 @@ class LocalAsset extends BaseAsset {
final double? latitude;
final double? longitude;
// Remote id of this asset's previous upload; used to stack a new edit under it.
final String? priorRemoteId;
// Local checksum at the last sync action; lets backup skip an already-handled
// local whose current render hashes fresh (the iOS revert case).
final String? syncedChecksum;
const LocalAsset({
required this.id,
String? remoteId,
@@ -39,8 +32,6 @@ class LocalAsset extends BaseAsset {
this.latitude,
this.longitude,
required super.isEdited,
this.priorRemoteId,
this.syncedChecksum,
}) : remoteAssetId = remoteId;
@override
@@ -129,8 +120,6 @@ class LocalAsset extends BaseAsset {
double? latitude,
double? longitude,
bool? isEdited,
String? priorRemoteId,
String? syncedChecksum,
}) {
return LocalAsset(
id: id ?? this.id,
@@ -151,8 +140,6 @@ class LocalAsset extends BaseAsset {
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
isEdited: isEdited ?? this.isEdited,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
);
}
}
@@ -1,130 +0,0 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
/// Handles the iOS revert case: the user reverted an edit in Photos, so the local
/// is no longer an edit but was already uploaded as one. Flips the stack primary
/// back to the original base (found via prior_remote_id), deletes the edit members,
/// and marks the local handled so it isn't re-uploaded.
class EditRevertService {
final NativeSyncApi _nativeSyncApi;
final DriftStackRepository _stackRepository;
final DriftLocalAssetRepository _localAssetRepository;
final AssetApiRepository _assetApiRepository;
final _log = Logger('EditRevertService');
EditRevertService({
required NativeSyncApi nativeSyncApi,
required DriftStackRepository stackRepository,
required DriftLocalAssetRepository localAssetRepository,
required AssetApiRepository assetApiRepository,
}) : _nativeSyncApi = nativeSyncApi,
_stackRepository = stackRepository,
_localAssetRepository = localAssetRepository,
_assetApiRepository = assetApiRepository;
/// Returns true if the asset was a revert and was handled (caller skips the
/// upload); false to fall through to the normal upload path.
Future<bool> tryDedupRevert(LocalAsset asset) async {
if (asset.priorRemoteId == null) {
return false;
}
// Only a confirmed "no live edit" counts as a revert. `edited` means a fresh
// edit, so bail and let the pair flow handle it. `unknown` means the native
// side couldn't read the adjustment (e.g. the asset is offloaded to iCloud and
// we didn't allow network); bail there too rather than risk treating an edit we
// simply couldn't read as a revert and deleting it. allowNetworkAccess=false
// keeps this a cheap, offline metadata read.
try {
final editState = await _nativeSyncApi.getEditState(asset.id, allowNetworkAccess: false);
if (editState != EditState.notEdited) {
return false;
}
} catch (error, stack) {
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
return false;
}
// No live edit, but this local was uploaded as an edit before → revert. iOS
// re-encodes a reverted render to fresh bytes that match nothing remote, so we
// never try to checksum-match. Flip the stack by structure: prior_remote_id is
// the latest edit (the stack primary); flip back to the stack's original base.
final String stackId;
final String baseId;
try {
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
if (foundStack == null) {
return false;
}
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
if (base == null) {
return false;
}
stackId = foundStack;
baseId = base;
} catch (error, stack) {
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
return false;
}
try {
await _assetApiRepository.setStackPrimary(stackId, baseId);
await _stackRepository.setPrimary(stackId, baseId);
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum ?? '');
} catch (error, stack) {
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
return false;
}
await _tearDownStackAfterFlip(stackId, baseId);
return true;
}
/// Finishes what [HashService] started: when a local checksum directly matched
/// a non-primary stack member and the primary was already flipped, drop the
/// other (now-stale) members.
Future<void> finishReconcile({required String stackId, required String newPrimaryId}) async {
await _tearDownStackAfterFlip(stackId, newPrimaryId);
}
// Trash the other stack members on the server, unstack, and mirror the result
// locally. Each step is best-effort — the flipped primary is the load-bearing
// change; the cleanup just tidies up.
Future<void> _tearDownStackAfterFlip(String stackId, String keepId) async {
final List<String> toDelete;
try {
toDelete = await _stackRepository.getOtherMemberIds(stackId, keepId);
} catch (error, stack) {
_log.warning("getOtherMemberIds failed for $stackId", error, stack);
return;
}
if (toDelete.isNotEmpty) {
try {
// Move the stale edit members to the trash rather than deleting outright,
// so a revert stays recoverable on the server (force: false = trash).
await _assetApiRepository.delete(toDelete, false);
} catch (error, stack) {
_log.warning("trashing reverted edit members failed for $toDelete", error, stack);
}
}
try {
await _assetApiRepository.unStack([stackId]);
} catch (error, stack) {
_log.warning("unstack failed for $stackId", error, stack);
}
try {
await _stackRepository.applyRevertCleanup(stackId, toDelete);
} catch (error, stack) {
_log.warning("local cleanup failed for $stackId", error, stack);
}
}
}
+6 -64
View File
@@ -2,14 +2,11 @@ import 'package:flutter/services.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
const String _kHashCancelledCode = "HASH_CANCELLED";
@@ -20,9 +17,6 @@ class HashService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftStackRepository _stackRepository;
final AssetApiRepository _assetApiRepository;
final EditRevertService _editRevertService;
final bool Function()? _cancelChecker;
final _log = Logger('HashService');
@@ -31,9 +25,6 @@ class HashService {
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required NativeSyncApi nativeSyncApi,
required DriftStackRepository stackRepository,
required AssetApiRepository assetApiRepository,
required EditRevertService editRevertService,
bool Function()? cancelChecker,
int? batchSize,
}) : _localAlbumRepository = localAlbumRepository,
@@ -41,9 +32,6 @@ class HashService {
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_cancelChecker = cancelChecker,
_nativeSyncApi = nativeSyncApi,
_stackRepository = stackRepository,
_assetApiRepository = assetApiRepository,
_editRevertService = editRevertService,
_batchSize = batchSize ?? kBatchHashFileLimit;
bool get isCancelled => _cancelChecker?.call() ?? false;
@@ -57,7 +45,6 @@ class HashService {
// Sorted by backupSelection followed by isCloud
final localAlbums = await _localAlbumRepository.getBackupAlbums();
final hashedIds = <String>{};
for (final album in localAlbums) {
if (isCancelled) {
@@ -67,7 +54,7 @@ class HashService {
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
if (assetsToHash.isNotEmpty) {
await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
await _hashAssets(album, assetsToHash);
}
}
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
@@ -75,15 +62,9 @@ class HashService {
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
if (trashedToHash.isNotEmpty) {
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
}
}
// iOS revert reconcile: a reverted edit re-hashes back to the original's
// bytes, which already exist as the stack base — flip the primary to it.
if (CurrentPlatform.isIOS && hashedIds.isNotEmpty && !isCancelled) {
await _reconcileReverts(hashedIds);
}
} on PlatformException catch (e) {
if (e.code == _kHashCancelledCode) {
_log.warning("Hashing cancelled by platform");
@@ -100,12 +81,7 @@ class HashService {
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
/// with hash for those that were successfully hashed. Hashes are looked up in a table
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
Future<void> _hashAssets(
LocalAlbum album,
List<LocalAsset> assetsToHash, {
bool isTrashed = false,
required Set<String> hashedIds,
}) async {
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
final toHash = <String, LocalAsset>{};
for (final asset in assetsToHash) {
@@ -116,21 +92,16 @@ class HashService {
toHash[asset.id] = asset;
if (toHash.length == _batchSize) {
await _processBatch(album, toHash, isTrashed, hashedIds);
await _processBatch(album, toHash, isTrashed);
toHash.clear();
}
}
await _processBatch(album, toHash, isTrashed, hashedIds);
await _processBatch(album, toHash, isTrashed);
}
/// Processes a batch of assets.
Future<void> _processBatch(
LocalAlbum album,
Map<String, LocalAsset> toHash,
bool isTrashed,
Set<String> hashedIds,
) async {
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
if (toHash.isEmpty) {
return;
}
@@ -170,34 +141,5 @@ class HashService {
} else {
await _localAssetRepository.updateHashes(hashed);
}
hashedIds.addAll(hashed.keys);
}
Future<void> _reconcileReverts(Set<String> localIds) async {
final List<StackReconcileTarget> targets;
try {
targets = await _stackRepository.findRevertReconcileTargets(localIds);
} catch (error, stack) {
_log.warning("findRevertReconcileTargets failed", error, stack);
return;
}
for (final target in targets) {
try {
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
// Roll priorRemoteId forward to the new primary (the matched member) so a
// later edit stacks onto THAT instead of the soon-deleted old primary.
await _localAssetRepository.markSynced(
target.localAssetId,
priorRemoteId: target.newPrimaryId,
syncedChecksum: target.localAssetChecksum,
);
} catch (error, stack) {
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
continue;
}
await _editRevertService.finishReconcile(stackId: target.stackId, newPrimaryId: target.newPrimaryId);
}
}
}
@@ -186,22 +186,6 @@ class BackgroundSyncManager {
});
}
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
/// joins an in-flight sync whose snapshot can pre-date a just-received change
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
/// first, then run a fresh one.
Future<void> runFreshRemoteSync() async {
final inflight = _syncTask;
if (inflight != null) {
try {
await inflight.future;
} catch (_) {
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
}
}
await syncRemote();
}
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
if (_syncWebsocketTask != null) {
return _syncWebsocketTask!.future;
@@ -27,14 +27,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
// remote id of the previous upload (iOS edit-pair stacking)
TextColumn get priorRemoteId => text().nullable()();
// local checksum at the last sync action. Lets the backup query skip a local
// whose current hash matches nothing remote but is still "handled" — the iOS
// revert case, where the reverted render hashes fresh but is already reconciled.
TextColumn get syncedChecksum => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -59,7 +51,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
longitude: longitude,
cloudId: iCloudId,
isEdited: false,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
);
}
@@ -26,8 +26,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({
@@ -47,8 +45,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<double?> latitude,
i0.Value<double?> longitude,
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
i0.Value<String?> priorRemoteId,
i0.Value<String?> syncedChecksum,
});
class $$LocalAssetEntityTableFilterComposer
@@ -145,16 +141,6 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.playbackStyle,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$LocalAssetEntityTableOrderingComposer
@@ -245,16 +231,6 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.playbackStyle,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$LocalAssetEntityTableAnnotationComposer
@@ -324,16 +300,6 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.playbackStyle,
builder: (column) => column,
);
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
column: $table.priorRemoteId,
builder: (column) => column,
);
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
column: $table.syncedChecksum,
builder: (column) => column,
);
}
class $$LocalAssetEntityTableTableManager
@@ -393,8 +359,6 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion(
name: name,
type: type,
@@ -412,8 +376,6 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
createCompanionCallback:
({
@@ -434,8 +396,6 @@ class $$LocalAssetEntityTableTableManager
i0.Value<double?> longitude = const i0.Value.absent(),
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
const i0.Value.absent(),
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert(
name: name,
type: type,
@@ -453,8 +413,6 @@ class $$LocalAssetEntityTableTableManager
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle,
priorRemoteId: priorRemoteId,
syncedChecksum: syncedChecksum,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -679,28 +637,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
).withConverter<i2.AssetPlaybackStyle>(
i1.$LocalAssetEntityTable.$converterplaybackStyle,
);
static const i0.VerificationMeta _priorRemoteIdMeta =
const i0.VerificationMeta('priorRemoteId');
@override
late final i0.GeneratedColumn<String> priorRemoteId =
i0.GeneratedColumn<String>(
'prior_remote_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _syncedChecksumMeta =
const i0.VerificationMeta('syncedChecksum');
@override
late final i0.GeneratedColumn<String> syncedChecksum =
i0.GeneratedColumn<String>(
'synced_checksum',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<i0.GeneratedColumn> get $columns => [
name,
@@ -719,8 +655,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -825,24 +759,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
);
}
if (data.containsKey('prior_remote_id')) {
context.handle(
_priorRemoteIdMeta,
priorRemoteId.isAcceptableOrUnknown(
data['prior_remote_id']!,
_priorRemoteIdMeta,
),
);
}
if (data.containsKey('synced_checksum')) {
context.handle(
_syncedChecksumMeta,
syncedChecksum.isAcceptableOrUnknown(
data['synced_checksum']!,
_syncedChecksumMeta,
),
);
}
return context;
}
@@ -923,14 +839,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
data['${effectivePrefix}playback_style'],
)!,
),
priorRemoteId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}prior_remote_id'],
),
syncedChecksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}synced_checksum'],
),
);
}
@@ -969,8 +877,6 @@ class LocalAssetEntityData extends i0.DataClass
final double? latitude;
final double? longitude;
final i2.AssetPlaybackStyle playbackStyle;
final String? priorRemoteId;
final String? syncedChecksum;
const LocalAssetEntityData({
required this.name,
required this.type,
@@ -988,8 +894,6 @@ class LocalAssetEntityData extends i0.DataClass
this.latitude,
this.longitude,
required this.playbackStyle,
this.priorRemoteId,
this.syncedChecksum,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -1034,12 +938,6 @@ class LocalAssetEntityData extends i0.DataClass
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
);
}
if (!nullToAbsent || priorRemoteId != null) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
}
if (!nullToAbsent || syncedChecksum != null) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
}
return map;
}
@@ -1069,8 +967,6 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
serializer.fromJson<int>(json['playbackStyle']),
),
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
);
}
@override
@@ -1097,8 +993,6 @@ class LocalAssetEntityData extends i0.DataClass
'playbackStyle': serializer.toJson<int>(
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
),
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
};
}
@@ -1119,8 +1013,6 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<double?> latitude = const i0.Value.absent(),
i0.Value<double?> longitude = const i0.Value.absent(),
i2.AssetPlaybackStyle? playbackStyle,
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
}) => i1.LocalAssetEntityData(
name: name ?? this.name,
type: type ?? this.type,
@@ -1140,12 +1032,6 @@ class LocalAssetEntityData extends i0.DataClass
latitude: latitude.present ? latitude.value : this.latitude,
longitude: longitude.present ? longitude.value : this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId.present
? priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: syncedChecksum.present
? syncedChecksum.value
: this.syncedChecksum,
);
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData(
@@ -1175,12 +1061,6 @@ class LocalAssetEntityData extends i0.DataClass
playbackStyle: data.playbackStyle.present
? data.playbackStyle.value
: this.playbackStyle,
priorRemoteId: data.priorRemoteId.present
? data.priorRemoteId.value
: this.priorRemoteId,
syncedChecksum: data.syncedChecksum.present
? data.syncedChecksum.value
: this.syncedChecksum,
);
}
@@ -1202,9 +1082,7 @@ class LocalAssetEntityData extends i0.DataClass
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}
@@ -1227,8 +1105,6 @@ class LocalAssetEntityData extends i0.DataClass
latitude,
longitude,
playbackStyle,
priorRemoteId,
syncedChecksum,
);
@override
bool operator ==(Object other) =>
@@ -1249,9 +1125,7 @@ class LocalAssetEntityData extends i0.DataClass
other.adjustmentTime == this.adjustmentTime &&
other.latitude == this.latitude &&
other.longitude == this.longitude &&
other.playbackStyle == this.playbackStyle &&
other.priorRemoteId == this.priorRemoteId &&
other.syncedChecksum == this.syncedChecksum);
other.playbackStyle == this.playbackStyle);
}
class LocalAssetEntityCompanion
@@ -1272,8 +1146,6 @@ class LocalAssetEntityCompanion
final i0.Value<double?> latitude;
final i0.Value<double?> longitude;
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
final i0.Value<String?> priorRemoteId;
final i0.Value<String?> syncedChecksum;
const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(),
@@ -1291,8 +1163,6 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
});
LocalAssetEntityCompanion.insert({
required String name,
@@ -1311,8 +1181,6 @@ class LocalAssetEntityCompanion
this.latitude = const i0.Value.absent(),
this.longitude = const i0.Value.absent(),
this.playbackStyle = const i0.Value.absent(),
this.priorRemoteId = const i0.Value.absent(),
this.syncedChecksum = const i0.Value.absent(),
}) : name = i0.Value(name),
type = i0.Value(type),
id = i0.Value(id);
@@ -1333,8 +1201,6 @@ class LocalAssetEntityCompanion
i0.Expression<double>? latitude,
i0.Expression<double>? longitude,
i0.Expression<int>? playbackStyle,
i0.Expression<String>? priorRemoteId,
i0.Expression<String>? syncedChecksum,
}) {
return i0.RawValuesInsertable({
if (name != null) 'name': name,
@@ -1353,8 +1219,6 @@ class LocalAssetEntityCompanion
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (playbackStyle != null) 'playback_style': playbackStyle,
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
});
}
@@ -1375,8 +1239,6 @@ class LocalAssetEntityCompanion
i0.Value<double?>? latitude,
i0.Value<double?>? longitude,
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
i0.Value<String?>? priorRemoteId,
i0.Value<String?>? syncedChecksum,
}) {
return i1.LocalAssetEntityCompanion(
name: name ?? this.name,
@@ -1395,8 +1257,6 @@ class LocalAssetEntityCompanion
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
playbackStyle: playbackStyle ?? this.playbackStyle,
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
);
}
@@ -1457,12 +1317,6 @@ class LocalAssetEntityCompanion
),
);
}
if (priorRemoteId.present) {
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
}
if (syncedChecksum.present) {
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
}
return map;
}
@@ -1484,9 +1338,7 @@ class LocalAssetEntityCompanion
..write('adjustmentTime: $adjustmentTime, ')
..write('latitude: $latitude, ')
..write('longitude: $longitude, ')
..write('playbackStyle: $playbackStyle, ')
..write('priorRemoteId: $priorRemoteId, ')
..write('syncedChecksum: $syncedChecksum')
..write('playbackStyle: $playbackStyle')
..write(')'))
.toString();
}
@@ -83,13 +83,6 @@ AND NOT EXISTS (
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
-- iOS edit-in-progress / revert: if this local was already uploaded (its
-- prior_remote_id resolves to a live remote), hide the local tile so the remote
-- (the edit, or the flipped-back original) is the single source of truth. Kills
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
)
ORDER BY created_at DESC
LIMIT $limit;
@@ -143,10 +136,6 @@ FROM
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
)
-- iOS edit-in-progress / revert: hide a local already represented by a live remote.
AND NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids
)
)
GROUP BY bucket_date
ORDER BY bucket_date DESC;
+2 -2
View File
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds)) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds))) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),
@@ -58,8 +58,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
INNER JOIN main.local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id
AND la.backup_selection = ?3
)
AND (lae.synced_checksum IS NULL OR lae.synced_checksum != lae.checksum);
);
''';
final row = await _db
@@ -105,10 +104,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
),
) &
// iOS revert: a reverted local hashes fresh (matches nothing remote),
// but if it was already reconciled (syncedChecksum == current checksum)
// it's handled — don't re-queue it as a fresh upload.
(lae.syncedChecksum.isNull() | lae.syncedChecksum.equalsExp(lae.checksum).not()) &
lae.id.isNotInQuery(_getExcludedSubquery()),
)
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
@@ -98,7 +98,7 @@ class Drift extends $Drift {
}
@override
int get schemaVersion => 28;
int get schemaVersion => 26;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -276,12 +276,6 @@ class Drift extends $Drift {
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
from26To27: (m, v27) async {
await m.addColumn(v27.localAssetEntity, v27.localAssetEntity.priorRemoteId);
},
from27To28: (m, v28) async {
await m.addColumn(v28.localAssetEntity, v28.localAssetEntity.syncedChecksum);
},
),
);
File diff suppressed because it is too large Load Diff
@@ -64,12 +64,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> markSynced(String localId, {required String priorRemoteId, required String syncedChecksum}) {
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
);
}
Future<void> delete(List<String> ids) {
if (ids.isEmpty) {
return Future.value();
@@ -1,25 +1,8 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class StackReconcileTarget {
final String stackId;
final String newPrimaryId;
final String localAssetId;
final String localAssetChecksum;
const StackReconcileTarget({
required this.stackId,
required this.newPrimaryId,
required this.localAssetId,
required this.localAssetChecksum,
});
}
class DriftStackRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftStackRepository(this._db) : super(_db);
@@ -31,123 +14,6 @@ class DriftStackRepository extends DriftDatabaseRepository {
return stack.toDto();
}).get();
}
// Per local id, find a stack member whose checksum matches the local's current
// checksum but isn't the stack primary — the iOS revert case where the local
// hashed back to the base while the primary still points at the edit.
Future<List<StackReconcileTarget>> findRevertReconcileTargets(Iterable<String> localAssetIds) async {
final ids = localAssetIds.toSet();
if (ids.isEmpty) {
return const [];
}
final targets = <StackReconcileTarget>[];
for (final slice in ids.slices(kDriftMaxChunk)) {
final placeholders = List.filled(slice.length, '?').join(',');
final rows = await _db
.customSelect(
'''
SELECT
s.id AS stack_id,
member.id AS new_primary,
local.id AS local_id,
local.checksum AS local_checksum
FROM local_asset_entity local
INNER JOIN remote_asset_entity prior ON prior.id = local.prior_remote_id
INNER JOIN stack_entity s ON s.id = prior.stack_id
INNER JOIN remote_asset_entity member
ON member.stack_id = s.id
AND member.checksum = local.checksum
AND member.deleted_at IS NULL
WHERE local.id IN ($placeholders)
AND s.primary_asset_id != member.id
''',
variables: slice.map((id) => Variable<String>(id)).toList(),
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
)
.get();
for (final row in rows) {
targets.add(
StackReconcileTarget(
stackId: row.read<String>('stack_id'),
newPrimaryId: row.read<String>('new_primary'),
localAssetId: row.read<String>('local_id'),
localAssetChecksum: row.read<String>('local_checksum'),
),
);
}
}
return targets;
}
// The stack a remote asset belongs to, if any. Used by the revert path to find
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
Future<String?> findStackIdByRemoteId(String remoteId) async {
final row = await _db
.customSelect(
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
variables: [Variable<String>(remoteId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
return row?.read<String?>('stack_id');
}
// The stack's original base member to flip back to on revert: the earliest-
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
// before its edits, so oldest uploaded_at = the original.
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
final row = await _db
.customSelect(
'''
SELECT id FROM remote_asset_entity
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
LIMIT 1
''',
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
readsFrom: {_db.remoteAssetEntity},
)
.getSingleOrNull();
return row?.read<String?>('id');
}
// Optimistic local primary flip so the timeline updates immediately; the
// server's stack-update websocket rewrites it shortly after.
Future<void> setPrimary(String stackId, String primaryAssetId) {
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
);
}
// Optimistic local cleanup after a revert: detach every member, drop the stack,
// and delete the edit assets — atomically, so the timeline never shows members
// pointing at a deleted stack.
Future<void> applyRevertCleanup(String stackId, List<String> deletedAssetIds) {
return _db.batch((batch) {
batch.update(
_db.remoteAssetEntity,
const RemoteAssetEntityCompanion(stackId: Value(null)),
where: (e) => e.stackId.equals(stackId),
);
batch.deleteWhere(_db.stackEntity, (e) => e.id.equals(stackId));
for (final id in deletedAssetIds) {
batch.deleteWhere(_db.remoteAssetEntity, (e) => e.id.equals(id));
}
});
}
Future<List<String>> getOtherMemberIds(String stackId, String keepId) async {
final rows = await _db
.customSelect(
'SELECT id FROM remote_asset_entity WHERE stack_id = ? AND id != ? AND deleted_at IS NULL',
variables: [Variable<String>(stackId), Variable<String>(keepId)],
readsFrom: {_db.remoteAssetEntity},
)
.get();
return rows.map((r) => r.read<String>('id')).toList(growable: false);
}
}
extension on StackEntityData {
+9 -109
View File
@@ -88,8 +88,6 @@ int _deepHash(Object? value) {
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum EditState { notEdited, edited, unknown }
class PlatformAsset {
PlatformAsset({
required this.id,
@@ -397,55 +395,6 @@ class CloudIdResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class BaseResource {
BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
String path;
String sha1;
int sizeBytes;
String mimeType;
List<Object?> _toList() {
return <Object?>[path, sha1, sizeBytes, mimeType];
}
Object encode() {
return _toList();
}
static BaseResource decode(Object result) {
result as List<Object?>;
return BaseResource(
path: result[0]! as String,
sha1: result[1]! as String,
sizeBytes: result[2]! as int,
mimeType: result[3]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! BaseResource || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(path, other.path) &&
_deepEquals(sha1, other.sha1) &&
_deepEquals(sizeBytes, other.sizeBytes) &&
_deepEquals(mimeType, other.mimeType);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -456,26 +405,20 @@ class _PigeonCodec extends StandardMessageCodec {
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is EditState) {
buffer.putUint8(130);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
buffer.putUint8(131);
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) {
buffer.putUint8(132);
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
buffer.putUint8(133);
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
buffer.putUint8(134);
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
buffer.putUint8(135);
writeValue(buffer, value.encode());
} else if (value is BaseResource) {
buffer.putUint8(136);
buffer.putUint8(134);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
@@ -489,20 +432,15 @@ class _PigeonCodec extends StandardMessageCodec {
final value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130:
final value = readValue(buffer) as int?;
return value == null ? null : EditState.values[value];
case 131:
return PlatformAsset.decode(readValue(buffer)!);
case 132:
case 131:
return PlatformAlbum.decode(readValue(buffer)!);
case 133:
case 132:
return SyncDelta.decode(readValue(buffer)!);
case 134:
case 133:
return HashResult.decode(readValue(buffer)!);
case 135:
case 134:
return CloudIdResult.decode(readValue(buffer)!);
case 136:
return BaseResource.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
@@ -734,42 +672,4 @@ class NativeSyncApi {
);
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
}
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as BaseResource?;
}
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as EditState;
}
}
@@ -1,5 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/hash.service.dart';
import 'package:immich_mobile/domain/services/local_sync.service.dart';
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
@@ -12,9 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/stack.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -48,23 +45,11 @@ final localSyncServiceProvider = Provider(
),
);
final editRevertServiceProvider = Provider(
(ref) => EditRevertService(
nativeSyncApi: ref.watch(nativeSyncApiProvider),
stackRepository: ref.watch(driftStackProvider),
localAssetRepository: ref.watch(localAssetRepository),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
),
);
final hashServiceProvider = Provider(
(ref) => HashService(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
stackRepository: ref.watch(driftStackProvider),
assetApiRepository: ref.watch(assetApiRepositoryProvider),
editRevertService: ref.watch(editRevertServiceProvider),
),
);
@@ -105,7 +105,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
} catch (e) {
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}
@@ -189,13 +188,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
}
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
// the stacked primary instead of briefly hiding the asset.
void _handleAssetStackUpdate(dynamic _) {
unawaited(_ref.read(backgroundSyncProvider).runFreshRemoteSync());
}
void _processBatchedAssetUploadReadyV1() {
if (_batchedAssetUploadReady.isEmpty) {
return;
@@ -67,10 +67,6 @@ class AssetApiRepository extends ApiRepository {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
}
Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
@@ -30,11 +30,6 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupEditPairGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
@@ -9,18 +9,14 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
@@ -35,8 +31,6 @@ final backgroundUploadServiceProvider = Provider((ref) {
ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(editRevertServiceProvider),
);
ref.onDispose(service.dispose);
@@ -49,35 +43,13 @@ class UploadTaskMetadata {
final bool isLivePhotos;
final String livePhotoVideoId;
// Marks the base upload of an edit pair. On completion the chained edit
// upload is enqueued with stackParentId = this base's remote id.
final bool isEditPair;
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
// Path of the native temp file backing this task (the edit base), so it can
// be cleaned up on terminal status.
final String basePath;
const UploadTaskMetadata({
required this.localAssetId,
required this.isLivePhotos,
required this.livePhotoVideoId,
this.isEditPair = false,
this.basePath = '',
});
UploadTaskMetadata copyWith({
String? localAssetId,
bool? isLivePhotos,
String? livePhotoVideoId,
bool? isEditPair,
String? basePath,
}) {
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
return UploadTaskMetadata(
localAssetId: localAssetId ?? this.localAssetId,
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
isEditPair: isEditPair ?? this.isEditPair,
basePath: basePath ?? this.basePath,
);
}
@@ -86,8 +58,6 @@ class UploadTaskMetadata {
'localAssetId': localAssetId,
'isLivePhotos': isLivePhotos,
'livePhotoVideoId': livePhotoVideoId,
'isEditPair': isEditPair,
'basePath': basePath,
};
}
@@ -96,8 +66,6 @@ class UploadTaskMetadata {
localAssetId: map['localAssetId'] as String,
isLivePhotos: map['isLivePhotos'] as bool,
livePhotoVideoId: map['livePhotoVideoId'] as String,
isEditPair: (map['isEditPair'] as bool?) ?? false,
basePath: (map['basePath'] as String?) ?? '',
);
}
@@ -108,7 +76,7 @@ class UploadTaskMetadata {
@override
String toString() =>
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
@override
bool operator ==(covariant UploadTaskMetadata other) {
@@ -118,18 +86,11 @@ class UploadTaskMetadata {
return other.localAssetId == localAssetId &&
other.isLivePhotos == isLivePhotos &&
other.livePhotoVideoId == livePhotoVideoId &&
other.isEditPair == isEditPair &&
other.basePath == basePath;
other.livePhotoVideoId == livePhotoVideoId;
}
@override
int get hashCode =>
localAssetId.hashCode ^
isLivePhotos.hashCode ^
livePhotoVideoId.hashCode ^
isEditPair.hashCode ^
basePath.hashCode;
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
}
/// Service for handling background uploads using iOS URLSession (background_downloader)
@@ -143,8 +104,6 @@ class BackgroundUploadService {
this._localAssetRepository,
this._backupRepository,
this._assetMediaRepository,
this._nativeSyncApi,
this._editRevertService,
) {
_uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
@@ -155,8 +114,6 @@ class BackgroundUploadService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository;
final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final EditRevertService _editRevertService;
final Logger _logger = Logger('BackgroundUploadService');
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
@@ -248,20 +205,9 @@ class BackgroundUploadService {
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
UploadTaskMetadata? metadata;
if (update.task.metaData.isNotEmpty) {
try {
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
} catch (_) {
metadata = null;
}
}
switch (update.status) {
case TaskStatus.complete:
unawaited(_handleLivePhoto(update, metadata));
unawaited(_handleEditPair(update, metadata));
unawaited(_recordPriorRemoteIdOnSuccess(update, metadata));
unawaited(_handleLivePhoto(update));
if (CurrentPlatform.isIOS) {
try {
@@ -274,20 +220,19 @@ class BackgroundUploadService {
break;
case TaskStatus.failed:
case TaskStatus.canceled:
case TaskStatus.notFound:
unawaited(_cleanupTempResourceOnFailure(metadata));
break;
default:
break;
}
}
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
try {
if (metadata == null || !metadata.isLivePhotos) {
if (update.task.metaData.isEmpty || update.task.metaData == '') {
return;
}
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
if (!metadata.isLivePhotos) {
return;
}
@@ -313,161 +258,6 @@ class BackgroundUploadService {
}
}
/// When an edit-pair base upload completes, enqueue the edit upload stacked
/// onto it (stackParentId = the base's freshly-returned remote id).
Future<void> _handleEditPair(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || !metadata.isEditPair) {
return;
}
if (metadata.basePath.isNotEmpty) {
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
if (update.responseBody == null || update.responseBody!.isEmpty) {
return;
}
final baseRemoteId = jsonDecode(update.responseBody!)['id'] as String?;
if (baseRemoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
if (localAsset == null) {
return;
}
final editTask = await getEditUploadTask(localAsset, baseRemoteId);
if (editTask != null) {
await enqueueTasks([editTask]);
}
} catch (error, stackTrace) {
dPrint(() => "Error handling edit pair task: $error $stackTrace");
}
}
/// Records the uploaded remote id as the asset's priorRemoteId so a later
/// edit stacks onto it. Skipped for edit-pair base uploads — those become
/// stack members; the chained edit's success records the prior.
Future<void> _recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
try {
if (metadata == null || metadata.isEditPair || metadata.localAssetId.isEmpty) {
return;
}
if (update.responseBody == null || update.responseBody!.isEmpty) {
return;
}
final remoteId = jsonDecode(update.responseBody!)['id'] as String?;
if (remoteId == null) {
return;
}
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
await _localAssetRepository.markSynced(
metadata.localAssetId,
priorRemoteId: remoteId,
syncedChecksum: localAsset?.checksum ?? '',
);
} catch (error, stackTrace) {
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
}
}
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
if (metadata == null || metadata.basePath.isEmpty) {
return;
}
try {
await File(metadata.basePath).delete();
} catch (_) {}
}
/// Resolves how an iOS edit should stack: a [String] prior remote id to use
/// as stackParentId (absorption), a [BaseResource] whose bytes must upload as
/// a fresh base first, or null when there's no edit to handle.
Future<Object?> _tryGetBaseOrPrior(LocalAsset asset) async {
if (asset.priorRemoteId != null) {
return asset.priorRemoteId;
}
BaseResource? base;
try {
base = await _nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
} catch (error, stack) {
_logger.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
return null;
}
if (base == null) {
return null;
}
// Identical bytes (e.g. auto-HDR) — no real edit to stack.
if (base.sha1 == asset.checksum) {
try {
await File(base.path).delete();
} catch (_) {}
return null;
}
return base;
}
Future<UploadTask> _buildBaseUploadTask(LocalAsset asset, BaseResource base) async {
final metadata = UploadTaskMetadata(
localAssetId: asset.id,
isLivePhotos: false,
livePhotoVideoId: '',
isEditPair: true,
basePath: base.path,
).toJson();
// The base is the unedited original (no adjustmentTime); the `_base`
// deviceAssetId keeps it distinct from the chained edit task.
return buildUploadTask(
File(base.path),
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
deviceAssetId: '${asset.id}_base',
metadata: metadata,
group: kBackupGroup,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getEditUploadTask(LocalAsset asset, String stackParentId) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
final fields = {'stackParentId': stackParentId};
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '').toJson();
return buildUploadTask(
file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
fields: fields,
group: kBackupEditPairGroup,
priority: 0,
isFavorite: asset.isFavorite,
requiresWiFi: _shouldRequireWiFi(asset),
cloudId: asset.cloudId,
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
latitude: asset.latitude?.toString(),
longitude: asset.longitude?.toString(),
);
}
@visibleForTesting
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
@@ -476,24 +266,6 @@ class BackgroundUploadService {
return null;
}
// iOS edit pair: stack a user edit onto the original. Reuse a prior upload
// as the stack parent (absorption), or upload the unedited bytes as a fresh
// base first. Live-photo edits aren't handled.
if (!entity.isLivePhoto && CurrentPlatform.isIOS) {
// A reverted edit dedups against the existing stack base — flip the primary
// back and skip the upload entirely.
if (asset.priorRemoteId != null && await _editRevertService.tryDedupRevert(asset)) {
return null;
}
final pairResolution = await _tryGetBaseOrPrior(asset);
if (pairResolution is BaseResource) {
return _buildBaseUploadTask(asset, pairResolution);
}
if (pairResolution is String) {
return getEditUploadTask(asset, pairResolution);
}
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
@@ -6,21 +6,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
@@ -44,9 +39,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(nativeSyncApiProvider),
ref.watch(localAssetRepository),
ref.watch(editRevertServiceProvider),
);
});
@@ -62,9 +54,6 @@ class ForegroundUploadService {
this._backupRepository,
this._connectivityApi,
this._assetMediaRepository,
this._nativeSyncApi,
this._localAssetRepository,
this._editRevertService,
);
final UploadRepository _uploadRepository;
@@ -72,9 +61,6 @@ class ForegroundUploadService {
final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi;
final AssetMediaRepository _assetMediaRepository;
final NativeSyncApi _nativeSyncApi;
final DriftLocalAssetRepository _localAssetRepository;
final EditRevertService _editRevertService;
final Logger _logger = Logger('ForegroundUploadService');
bool shouldAbortUpload = false;
@@ -264,13 +250,6 @@ class ForegroundUploadService {
return;
}
// A reverted iOS edit dedups against the existing stack base instead of
// re-uploading; tryDedupRevert flips the primary back and skips the upload.
if (Platform.isIOS && asset.priorRemoteId != null && await _editRevertService.tryDedupRevert(asset)) {
callbacks.onSuccess?.call(asset.localId!, asset.priorRemoteId!);
return;
}
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
@@ -392,11 +371,6 @@ class ForegroundUploadService {
]);
}
final stackParentId = await _maybeUploadBaseResource(asset, Map.of(fields), cancelToken);
if (stackParentId != null) {
fields['stackParentId'] = stackParentId;
}
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile(
file: file,
@@ -410,13 +384,6 @@ class ForegroundUploadService {
);
if (result.isSuccess && result.remoteAssetId != null) {
unawaited(
_localAssetRepository.markSynced(
asset.localId!,
priorRemoteId: result.remoteAssetId!,
syncedChecksum: asset.checksum ?? '',
),
);
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
@@ -448,60 +415,6 @@ class ForegroundUploadService {
}
}
/// For an edited iOS photo, uploads the original camera bytes and returns its
/// remote id to use as the edit's stackParentId. Returns null for non-edits.
Future<String?> _maybeUploadBaseResource(
LocalAsset asset,
Map<String, String> baseFields,
Completer<void>? cancelToken,
) async {
if (!Platform.isIOS) {
return null;
}
// A prior upload exists, so stack the new edit onto it instead of creating a
// duplicate top-level asset.
if (asset.priorRemoteId != null) {
return asset.priorRemoteId;
}
BaseResource? base;
try {
base = await _nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
} catch (error, stack) {
_logger.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
return null;
}
if (base == null) {
return null;
}
final baseFile = File(base.path);
// Identical bytes (e.g. auto-HDR) — no real edit to stack.
if (base.sha1 == asset.checksum) {
try {
await baseFile.delete();
} catch (_) {}
return null;
}
try {
final baseName = p.setExtension(asset.name, p.extension(base.path));
final result = await _uploadRepository.uploadFile(
file: baseFile,
originalFileName: baseName,
fields: baseFields,
cancelToken: cancelToken,
logContext: 'baseResource[${asset.localId}]',
);
return result.isSuccess ? result.remoteAssetId : null;
} finally {
try {
await baseFile.delete();
} catch (_) {}
}
}
Future<UploadResult> _uploadSingleFile(
File file, {
required String deviceAssetId,
+8 -8
View File
@@ -1,26 +1,26 @@
.PHONY: build watch create_app_icon create_splash build_release_android pigeon test analyze format migration translation
build:
@printf "This command has been removed. Please use:\n\n mise codegen # or mise //mobile:codegen:dart from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise codegen # or mise //:mobile:codegen:dart from another directory\n\n" >&2 && exit 1
pigeon:
@printf "This command has been removed. Please use:\n\n mise pigeon # or mise //mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise pigeon # or mise //:mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1
build_release_android:
@printf "This command has been removed. Please use:\n\n mise run build:android # or mise //mobile:build:android from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise run build:android # or mise //:mobile:build:android from another directory\n\n" >&2 && exit 1
migration:
@printf "This command has been removed. Please use:\n\n mise migration # or mise //mobile:drift:migration from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise migration # or mise //:mobile:drift:migration from another directory\n\n" >&2 && exit 1
translation:
@printf "This command has been removed. Please use:\n\n mise translation # or mise //mobile:codegen:translation from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise translation # or mise //:mobile:codegen:translation from another directory\n\n" >&2 && exit 1
analyze:
@printf "This command has been removed. Please use:\n\n mise analyze # or mise //mobile:lint from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise analyze # or mise //:mobile:lint from another directory\n\n" >&2 && exit 1
format:
@printf "This command has been removed. Please use:\n\n mise format # or mise //mobile:format from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise format # or mise //:mobile:format from another directory\n\n" >&2 && exit 1
test:
@printf "This command has been removed. Please use:\n\n mise test # or mise //mobile:test from another directory\n\n" >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise test # or mise //:mobile:test from another directory\n\n" >&2 && exit 1
+9
View File
@@ -78,6 +78,15 @@ alias = "migration"
description = "Generate database migrations"
run = "dart run drift_dev make-migrations"
[tasks.install]
alias = "install"
description = "Install flutter dependencies"
run = "flutter pub get"
[tasks.start]
alias = "start"
description = "Start flutter app"
run = "flutter run"
# Internal tasks
[tasks."i18n:loader"]
+3 -13
View File
@@ -1252,11 +1252,8 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility:
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
@@ -1320,10 +1317,6 @@ class AssetsApi {
mp.fields[r'sidecarData'] = sidecarData.field;
mp.files.add(sidecarData);
}
if (stackParentId != null) {
hasFields = true;
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
}
if (visibility != null) {
hasFields = true;
mp.fields[r'visibility'] = parameterToString(visibility);
@@ -1383,12 +1376,9 @@ class AssetsApi {
/// * [MultipartFile] sidecarData:
/// Sidecar file data
///
/// * [String] stackParentId:
/// Stack this asset onto the parent asset, with the new asset as the stack primary
///
/// * [AssetVisibility] visibility:
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, );
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
-23
View File
@@ -110,21 +110,6 @@ class CloudIdResult {
const CloudIdResult({required this.assetId, this.error, this.cloudId});
}
class BaseResource {
final String path;
final String sha1;
final int sizeBytes;
final String mimeType;
const BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
}
// Whether an iOS asset currently carries a user edit, as opposed to a
// capture-time Photographic Style or a reverted edit. `unknown` means the
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
// network wasn't allowed), so callers must not treat it as "not edited".
enum EditState { notEdited, edited, unknown }
@HostApi()
abstract class NativeSyncApi {
bool shouldFullSync();
@@ -159,12 +144,4 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
@async
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
}
-3
View File
@@ -1,4 +1,3 @@
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
@@ -12,5 +11,3 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
class MockAppSettingsService extends Mock implements AppSettingsService {}
class MockEditRevertService extends Mock implements EditRevertService {}
-8
View File
@@ -30,8 +30,6 @@ import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26;
import 'schema_v27.dart' as v27;
import 'schema_v28.dart' as v28;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -89,10 +87,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v25.DatabaseAtV25(db);
case 26:
return v26.DatabaseAtV26(db);
case 27:
return v27.DatabaseAtV27(db);
case 28:
return v28.DatabaseAtV28(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -125,7 +119,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
24,
25,
26,
27,
28,
];
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
@@ -37,8 +36,6 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
@@ -6,7 +6,6 @@ import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -14,11 +13,9 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:mocktail/mocktail.dart';
import '../domain/service.mock.dart';
import '../fixtures/asset.stub.dart';
import '../infrastructure/repository.mock.dart';
import '../mocks/asset_entity.mock.dart';
@@ -31,13 +28,10 @@ void main() {
late MockDriftLocalAssetRepository mockLocalAssetRepository;
late MockDriftBackupRepository mockBackupRepository;
late MockAssetMediaRepository mockAssetMediaRepository;
late MockNativeSyncApi mockNativeSyncApi;
late MockEditRevertService mockEditRevertService;
late Drift db;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
registerFallbackValue(LocalAssetStub.image1);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(MethodCall methodCall) async => 'test',
@@ -56,8 +50,6 @@ void main() {
mockLocalAssetRepository = MockDriftLocalAssetRepository();
mockBackupRepository = MockDriftBackupRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockNativeSyncApi = MockNativeSyncApi();
mockEditRevertService = MockEditRevertService();
sut = BackgroundUploadService(
mockUploadRepository,
@@ -65,18 +57,8 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
// Default: no edit base, so getUploadTask falls through to the normal path.
when(
() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => null);
// Default: not a revert, so getUploadTask proceeds with the normal flow.
when(() => mockEditRevertService.tryDedupRevert(any())).thenAnswer((_) async => false);
mockUploadRepository.onUploadStatus = (_) {};
mockUploadRepository.onTaskProgress = (_) {};
});
@@ -140,71 +122,6 @@ void main() {
});
});
group('getUploadTask edit pair', () {
test('absorption: stacks the edit under the prior upload via stackParentId', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(priorRemoteId: 'prior-remote-1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupEditPairGroup);
expect(task.fields['stackParentId'], 'prior-remote-1');
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('builds a base upload task for an unsynced edit', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(checksum: 'edited-sha1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'original-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.metaData, contains('"isEditPair":true'));
});
test('falls through to a normal upload when base bytes match the checksum', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final asset = LocalAssetStub.image1.copyWith(checksum: 'same-sha1');
final mockEntity = MockAssetEntity();
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
when(
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer(
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'same-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
);
final task = await sut.getUploadTask(asset);
expect(task, isNotNull);
expect(task!.group, kBackupGroup);
expect(task.fields.containsKey('stackParentId'), isFalse);
});
});
group('getLivePhotoUploadTask', () {
test('should call getOriginalFilename for live photo upload task', () async {
final asset = LocalAssetStub.image1;
@@ -255,8 +172,6 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutWithV24.dispose());
@@ -307,8 +222,6 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutAndroid.dispose());
@@ -349,8 +262,6 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutWithV24.dispose());
@@ -391,8 +302,6 @@ void main() {
mockLocalAssetRepository,
mockBackupRepository,
mockAssetMediaRepository,
mockNativeSyncApi,
mockEditRevertService,
);
addTearDown(() => sutWithV24.dispose());
-7
View File
@@ -4,15 +4,11 @@ import 'package:mocktail/mocktail.dart' as mocktail;
import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import '../repository.mocks.dart';
class UnitMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final trashedAsset = MockTrashedLocalAssetRepository();
final stack = MockDriftStackRepository();
final assetApi = MockAssetApiRepository();
final editRevert = MockEditRevertService();
final nativeApi = MockNativeSyncApi();
@@ -35,9 +31,6 @@ class UnitMocks {
mocktail.reset(localAlbum);
mocktail.reset(localAsset);
mocktail.reset(trashedAsset);
mocktail.reset(stack);
mocktail.reset(assetApi);
mocktail.reset(editRevert);
mocktail.reset(nativeApi);
}
}
@@ -1,121 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart';
import '../mocks.dart';
void main() {
late EditRevertService sut;
final mocks = UnitMocks();
LocalAsset asset({String? priorRemoteId, String? checksum = 'reverted-sha1'}) => LocalAsset(
id: 'local-1',
name: 'photo.jpg',
type: AssetType.image,
createdAt: DateTime(2025),
updatedAt: DateTime(2025, 2),
playbackStyle: AssetPlaybackStyle.image,
isEdited: false,
priorRemoteId: priorRemoteId,
checksum: checksum,
);
setUp(() {
sut = EditRevertService(
nativeSyncApi: mocks.nativeApi,
stackRepository: mocks.stack,
localAssetRepository: mocks.localAsset,
assetApiRepository: mocks.assetApi,
);
});
tearDown(() {
mocks.reset();
});
group('tryDedupRevert', () {
test('returns false when the asset was never uploaded as an edit', () async {
expect(await sut.tryDedupRevert(asset(priorRemoteId: null)), isFalse);
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
});
test('returns false (lets the pair flow run) when there is still a live edit', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.edited);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the edit state cannot be read (offloaded to iCloud)', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.unknown);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
});
test('returns false when the prior remote is not in a stack', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('returns false when the stack has no base member to flip back to', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit')).thenAnswer((_) async => null);
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
});
test('flips the primary back to the base via prior_remote_id and trashes the edit', () async {
when(
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
).thenAnswer((_) async => EditState.notEdited);
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
when(
() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit'),
).thenAnswer((_) async => 'remote-base');
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
when(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).thenAnswer((_) async {});
when(() => mocks.stack.getOtherMemberIds('stack-1', 'remote-base')).thenAnswer((_) async => ['remote-edit']);
when(() => mocks.assetApi.delete(['remote-edit'], false)).thenAnswer((_) async {});
when(() => mocks.assetApi.unStack(['stack-1'])).thenAnswer((_) async {});
when(() => mocks.stack.applyRevertCleanup('stack-1', ['remote-edit'])).thenAnswer((_) async {});
expect(await sut.tryDedupRevert(asset(priorRemoteId: 'remote-edit')), isTrue);
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
verify(
() => mocks.localAsset.markSynced(
'local-1',
priorRemoteId: 'remote-base',
syncedChecksum: any(named: 'syncedChecksum'),
),
).called(1);
verify(() => mocks.assetApi.delete(['remote-edit'], false)).called(1);
verify(() => mocks.assetApi.unStack(['stack-1'])).called(1);
verify(() => mocks.stack.applyRevertCleanup('stack-1', ['remote-edit'])).called(1);
});
});
}
@@ -18,9 +18,6 @@ void main() {
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
editRevertService: mocks.editRevert,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
@@ -113,9 +110,6 @@ void main() {
nativeSyncApi: mocks.nativeApi,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
stackRepository: mocks.stack,
assetApiRepository: mocks.assetApi,
editRevertService: mocks.editRevert,
);
final album = LocalAlbumFactory.create();
-6
View File
@@ -16490,12 +16490,6 @@
"format": "binary",
"type": "string"
},
"stackParentId": {
"description": "Stack this asset onto the parent asset, with the new asset as the stack primary",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"visibility": {
"$ref": "#/components/schemas/AssetVisibility"
}
+1 -1
View File
@@ -25,7 +25,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.12.4",
"@types/node": "^24.12.2",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
+1 -1
View File
@@ -27,7 +27,7 @@
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@types/node": "^24.12.4",
"@types/node": "^24.11.0",
"esbuild": "^0.27.3",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
+1 -1
View File
@@ -24,7 +24,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.12.4",
"@types/node": "^24.12.2",
"typescript": "^6.0.0"
}
}
-2
View File
@@ -630,8 +630,6 @@ export type AssetMediaCreateDto = {
metadata?: AssetMetadataUpsertItemDto[];
/** Sidecar file data */
sidecarData?: Blob;
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
stackParentId?: string;
visibility?: AssetVisibility;
};
export type AssetMediaResponseDto = {
+175 -175
View File
@@ -128,8 +128,8 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^24.12.4
version: 24.12.4
specifier: ^24.12.2
version: 24.12.2
'@types/pg':
specifier: ^8.15.1
version: 8.20.0
@@ -195,10 +195,10 @@ importers:
version: 5.2.1(encoding@0.1.13)
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
packages/cli:
dependencies:
@@ -240,8 +240,8 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^24.12.4
version: 24.12.4
specifier: ^24.12.2
version: 24.12.2
'@vitest/coverage-v8':
specifier: ^4.0.0
version: 4.1.5(vitest@4.1.5)
@@ -286,10 +286,10 @@ importers:
version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)
vite:
specifier: ^8.0.0
version: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest:
specifier: ^4.0.0
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@4.1.5)
@@ -333,8 +333,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1
'@types/node':
specifier: ^24.12.4
version: 24.12.4
specifier: ^24.11.0
version: 24.12.2
esbuild:
specifier: ^0.27.3
version: 0.27.4
@@ -352,8 +352,8 @@ importers:
version: 1.2.0
devDependencies:
'@types/node':
specifier: ^24.12.4
version: 24.12.4
specifier: ^24.12.2
version: 24.12.2
typescript:
specifier: ^6.0.0
version: 6.0.3
@@ -524,7 +524,7 @@ importers:
version: 2.1.1
nest-commander:
specifier: ^3.16.0
version: 3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.4)(typescript@6.0.3)
version: 3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.2)(typescript@6.0.3)
nestjs-cls:
specifier: ^6.0.0
version: 6.2.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -609,7 +609,7 @@ importers:
version: 10.0.1(eslint@10.2.1(jiti@2.6.1))
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(prettier@3.8.3)
version: 11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
@@ -662,8 +662,8 @@ importers:
specifier: ^2.0.0
version: 2.1.0
'@types/node':
specifier: ^24.12.4
version: 24.12.4
specifier: ^24.12.2
version: 24.12.2
'@types/nodemailer':
specifier: ^8.0.0
version: 8.0.0
@@ -690,7 +690,7 @@ importers:
version: 13.15.10
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
eslint:
specifier: ^10.0.0
version: 10.2.1(jiti@2.6.1)
@@ -741,10 +741,10 @@ importers:
version: 1.5.9(@swc/core@1.15.30(@swc/helpers@0.5.21))(rollup@4.55.1)
vite-tsconfig-paths:
specifier: ^6.0.0
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
web:
dependencies:
@@ -5321,8 +5321,8 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@24.12.4':
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
'@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
@@ -12962,11 +12962,11 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/schematics-cli@19.2.24(@types/node@24.12.4)(chokidar@4.0.3)':
'@angular-devkit/schematics-cli@19.2.24(@types/node@24.12.2)(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
'@inquirer/prompts': 7.3.2(@types/node@24.12.4)
'@inquirer/prompts': 7.3.2(@types/node@24.12.2)
ansi-colors: 4.1.3
symbol-observable: 4.0.0
yargs-parser: 21.1.1
@@ -15476,143 +15476,143 @@ snapshots:
'@inquirer/ansi@1.0.2': {}
'@inquirer/checkbox@4.3.2(@types/node@24.12.4)':
'@inquirer/checkbox@4.3.2(@types/node@24.12.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/confirm@5.1.21(@types/node@24.12.4)':
'@inquirer/confirm@5.1.21(@types/node@24.12.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/core@10.3.2(@types/node@24.12.4)':
'@inquirer/core@10.3.2(@types/node@24.12.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/editor@4.2.23(@types/node@24.12.4)':
'@inquirer/editor@4.2.23(@types/node@24.12.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/expand@4.0.23(@types/node@24.12.4)':
'@inquirer/expand@4.0.23(@types/node@24.12.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/external-editor@1.0.3(@types/node@24.12.4)':
'@inquirer/external-editor@1.0.3(@types/node@24.12.2)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/figures@1.0.15': {}
'@inquirer/input@4.3.1(@types/node@24.12.4)':
'@inquirer/input@4.3.1(@types/node@24.12.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/number@3.0.23(@types/node@24.12.4)':
'@inquirer/number@3.0.23(@types/node@24.12.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/password@4.0.23(@types/node@24.12.4)':
'@inquirer/password@4.0.23(@types/node@24.12.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/prompts@7.10.1(@types/node@24.12.4)':
'@inquirer/prompts@7.10.1(@types/node@24.12.2)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.12.4)
'@inquirer/confirm': 5.1.21(@types/node@24.12.4)
'@inquirer/editor': 4.2.23(@types/node@24.12.4)
'@inquirer/expand': 4.0.23(@types/node@24.12.4)
'@inquirer/input': 4.3.1(@types/node@24.12.4)
'@inquirer/number': 3.0.23(@types/node@24.12.4)
'@inquirer/password': 4.0.23(@types/node@24.12.4)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.4)
'@inquirer/search': 3.2.2(@types/node@24.12.4)
'@inquirer/select': 4.4.2(@types/node@24.12.4)
'@inquirer/checkbox': 4.3.2(@types/node@24.12.2)
'@inquirer/confirm': 5.1.21(@types/node@24.12.2)
'@inquirer/editor': 4.2.23(@types/node@24.12.2)
'@inquirer/expand': 4.0.23(@types/node@24.12.2)
'@inquirer/input': 4.3.1(@types/node@24.12.2)
'@inquirer/number': 3.0.23(@types/node@24.12.2)
'@inquirer/password': 4.0.23(@types/node@24.12.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.2)
'@inquirer/search': 3.2.2(@types/node@24.12.2)
'@inquirer/select': 4.4.2(@types/node@24.12.2)
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/prompts@7.3.2(@types/node@24.12.4)':
'@inquirer/prompts@7.3.2(@types/node@24.12.2)':
dependencies:
'@inquirer/checkbox': 4.3.2(@types/node@24.12.4)
'@inquirer/confirm': 5.1.21(@types/node@24.12.4)
'@inquirer/editor': 4.2.23(@types/node@24.12.4)
'@inquirer/expand': 4.0.23(@types/node@24.12.4)
'@inquirer/input': 4.3.1(@types/node@24.12.4)
'@inquirer/number': 3.0.23(@types/node@24.12.4)
'@inquirer/password': 4.0.23(@types/node@24.12.4)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.4)
'@inquirer/search': 3.2.2(@types/node@24.12.4)
'@inquirer/select': 4.4.2(@types/node@24.12.4)
'@inquirer/checkbox': 4.3.2(@types/node@24.12.2)
'@inquirer/confirm': 5.1.21(@types/node@24.12.2)
'@inquirer/editor': 4.2.23(@types/node@24.12.2)
'@inquirer/expand': 4.0.23(@types/node@24.12.2)
'@inquirer/input': 4.3.1(@types/node@24.12.2)
'@inquirer/number': 3.0.23(@types/node@24.12.2)
'@inquirer/password': 4.0.23(@types/node@24.12.2)
'@inquirer/rawlist': 4.1.11(@types/node@24.12.2)
'@inquirer/search': 3.2.2(@types/node@24.12.2)
'@inquirer/select': 4.4.2(@types/node@24.12.2)
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/rawlist@4.1.11(@types/node@24.12.4)':
'@inquirer/rawlist@4.1.11(@types/node@24.12.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/search@3.2.2(@types/node@24.12.4)':
'@inquirer/search@3.2.2(@types/node@24.12.2)':
dependencies:
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/select@4.4.2(@types/node@24.12.4)':
'@inquirer/select@4.4.2(@types/node@24.12.2)':
dependencies:
'@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2(@types/node@24.12.4)
'@inquirer/core': 10.3.2(@types/node@24.12.2)
'@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10(@types/node@24.12.4)
'@inquirer/type': 3.0.10(@types/node@24.12.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@inquirer/type@3.0.10(@types/node@24.12.4)':
'@inquirer/type@3.0.10(@types/node@24.12.2)':
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@internationalized/date@3.12.1':
dependencies:
@@ -15644,7 +15644,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -16020,12 +16020,12 @@ snapshots:
bullmq: 5.76.1
tslib: 2.8.1
'@nestjs/cli@11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(prettier@3.8.3)':
'@nestjs/cli@11.0.21(@swc/core@1.15.30(@swc/helpers@0.5.21))(@types/node@24.12.2)(esbuild@0.28.0)(prettier@3.8.3)':
dependencies:
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
'@angular-devkit/schematics-cli': 19.2.24(@types/node@24.12.4)(chokidar@4.0.3)
'@inquirer/prompts': 7.10.1(@types/node@24.12.4)
'@angular-devkit/schematics-cli': 19.2.24(@types/node@24.12.2)(chokidar@4.0.3)
'@inquirer/prompts': 7.10.1(@types/node@24.12.2)
'@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)
ansis: 4.2.0
chokidar: 4.0.3
@@ -17359,7 +17359,7 @@ snapshots:
'@types/accepts@1.3.7':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/archiver@7.0.0':
dependencies:
@@ -17371,16 +17371,16 @@ snapshots:
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/bonjour@3.5.13':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/braces@3.0.5': {}
@@ -17402,21 +17402,21 @@ snapshots:
'@types/cli-progress@3.11.6':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/compression@1.8.1':
dependencies:
'@types/express': 5.0.6
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.0
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/content-disposition@0.5.9': {}
@@ -17433,11 +17433,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/express': 5.0.6
'@types/keygrip': 1.0.6
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/d3-array@3.2.2': {}
@@ -17564,13 +17564,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/ssh2': 1.15.5
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/ssh2': 1.15.5
'@types/dom-to-image@2.6.7': {}
@@ -17595,14 +17595,14 @@ snapshots:
'@types/express-serve-static-core@4.19.7':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -17628,7 +17628,7 @@ snapshots:
'@types/fluent-ffmpeg@2.1.28':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/geojson@7946.0.16': {}
@@ -17656,7 +17656,7 @@ snapshots:
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/inquirer@8.2.12':
dependencies:
@@ -17680,7 +17680,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/justified-layout@4.1.4': {}
@@ -17699,7 +17699,7 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.9
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/leaflet@1.9.21':
dependencies:
@@ -17729,7 +17729,7 @@ snapshots:
'@types/mock-fs@4.13.4':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/ms@2.1.0': {}
@@ -17739,7 +17739,7 @@ snapshots:
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/node@17.0.45': {}
@@ -17747,7 +17747,7 @@ snapshots:
dependencies:
undici-types: 5.26.5
'@types/node@24.12.4':
'@types/node@24.12.2':
dependencies:
undici-types: 7.16.0
@@ -17758,13 +17758,13 @@ snapshots:
'@types/nodemailer@8.0.0':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/oidc-provider@9.5.0':
dependencies:
'@types/keygrip': 1.0.6
'@types/koa': 3.0.1
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/parse5@5.0.3': {}
@@ -17774,13 +17774,13 @@ snapshots:
'@types/pg@8.15.6':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/pg@8.20.0':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
pg-protocol: 1.13.0
pg-types: 2.2.0
@@ -17788,13 +17788,13 @@ snapshots:
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/prismjs@1.26.5': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/qs@6.14.0': {}
@@ -17823,24 +17823,24 @@ snapshots:
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/retry@0.12.2': {}
'@types/sax@1.2.7':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/semver@7.7.1': {}
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/send@1.2.1':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/serve-index@1.9.4':
dependencies:
@@ -17849,25 +17849,25 @@ snapshots:
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/send': 0.17.6
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/ssh2-streams@0.1.13':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -17878,7 +17878,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 24.12.4
'@types/node': 24.12.2
form-data: 4.0.5
'@types/supercluster@7.1.3':
@@ -17892,7 +17892,7 @@ snapshots:
'@types/through@0.0.33':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/trusted-types@2.0.7': {}
@@ -17908,7 +17908,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/yargs-parser@21.0.3': {}
@@ -18015,7 +18015,7 @@ snapshots:
'@vercel/oidc@3.0.5': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -18030,7 +18030,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.2
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@@ -18046,7 +18046,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@3.2.4':
dependencies:
@@ -18065,21 +18065,21 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/mocker@3.2.4(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@vitest/mocker@4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
@@ -19896,7 +19896,7 @@ snapshots:
engine.io@6.6.5:
dependencies:
'@types/cors': 2.8.19
'@types/node': 24.12.4
'@types/node': 24.12.2
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@@ -20315,7 +20315,7 @@ snapshots:
eval@0.1.8:
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
require-like: 0.1.2
event-emitter@0.3.5:
@@ -20876,7 +20876,7 @@ snapshots:
happy-dom@20.9.0:
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
@@ -21301,9 +21301,9 @@ snapshots:
inline-style-parser@0.2.7: {}
inquirer@8.2.7(@types/node@24.12.4):
inquirer@8.2.7(@types/node@24.12.2):
dependencies:
'@inquirer/external-editor': 1.0.3(@types/node@24.12.4)
'@inquirer/external-editor': 1.0.3(@types/node@24.12.2)
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
@@ -21522,7 +21522,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 24.12.4
'@types/node': 24.12.2
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -21530,13 +21530,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -22777,7 +22777,7 @@ snapshots:
neo-async@2.6.2: {}
nest-commander@3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.4)(typescript@6.0.3):
nest-commander@3.20.1(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@types/inquirer@8.2.12)(@types/node@24.12.2)(typescript@6.0.3):
dependencies:
'@fig/complete-commander': 3.2.0(commander@11.1.0)
'@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)
@@ -22786,7 +22786,7 @@ snapshots:
'@types/inquirer': 8.2.12
commander: 11.1.0
cosmiconfig: 8.3.6(typescript@6.0.3)
inquirer: 8.2.7(@types/node@24.12.4)
inquirer: 8.2.7(@types/node@24.12.2)
transitivePeerDependencies:
- '@types/node'
- typescript
@@ -23939,7 +23939,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.12.4
'@types/node': 24.12.2
long: 5.3.2
protobufjs@8.0.1:
@@ -23954,7 +23954,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 24.12.4
'@types/node': 24.12.2
long: 5.3.2
protocol-buffers-schema@3.6.1: {}
@@ -25945,13 +25945,13 @@ snapshots:
- rollup
- supports-color
vite-node@3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite-node@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -25966,17 +25966,17 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@6.0.3)
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
fdir: 6.5.0(picomatch@4.0.4)
@@ -25985,7 +25985,7 @@ snapshots:
rollup: 4.55.1
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.32.0
@@ -25994,7 +25994,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -26002,7 +26002,7 @@ snapshots:
rolldown: 1.0.0-rc.17
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 24.12.4
'@types/node': 24.12.2
esbuild: 0.28.0
fsevents: 2.3.3
jiti: 2.6.1
@@ -26034,13 +26034,13 @@ snapshots:
vitest-fetch-mock@0.4.5(vitest@4.1.5):
dependencies:
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.4)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -26058,12 +26058,12 @@ snapshots:
tinyglobby: 0.2.16
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.2(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@24.12.4)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.12.4
'@types/node': 24.12.2
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
transitivePeerDependencies:
@@ -26080,10 +26080,10 @@ snapshots:
- tsx
- yaml
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -26100,11 +26100,11 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.10(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.12.4
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 26.1.0(canvas@3.2.3)
+1 -1
View File
@@ -55,7 +55,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.5.11@sha256:2ba959e4827f845fe0c4cfb4814089e790dc513040ef74f9e14925f446412a51 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
+1 -1
View File
@@ -138,7 +138,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.12.4",
"@types/node": "^24.12.2",
"@types/nodemailer": "^8.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
-4
View File
@@ -48,10 +48,6 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
isFavorite: stringToBool.optional().describe('Mark as favorite'),
visibility: AssetVisibilitySchema.optional(),
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
stackParentId: z
.uuidv4()
.optional()
.describe('Stack this asset onto the parent asset, with the new asset as the stack primary'),
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
[UploadFieldName.SIDECAR_DATA]: z
.any()
+1 -58
View File
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import { StackTable } from 'src/schema/tables/stack.table';
import { asUuid, isStackPrimaryConstraint, withDefaultVisibility } from 'src/utils/database';
import { asUuid, withDefaultVisibility } from 'src/utils/database';
export interface StackSearch {
ownerId: string;
@@ -124,63 +124,6 @@ export class StackRepository {
});
}
async linkAsset(
ownerId: string,
newAssetId: string,
parentId: string,
): Promise<{ stackId: string; created: boolean } | null> {
try {
return await this.db.transaction().execute(async (tx) => {
// Lock the parent so two concurrent uploads can't each create a stack for it.
const parent = await tx
.selectFrom('asset')
.select(['id', 'ownerId', 'stackId', 'deletedAt'])
.where('id', '=', asUuid(parentId))
.forUpdate()
.executeTakeFirst();
if (!parent || parent.ownerId !== ownerId || parent.deletedAt) {
return null;
}
if (parent.stackId) {
await tx
.updateTable('asset')
.set({ stackId: parent.stackId, updatedAt: new Date() })
.where('id', '=', asUuid(newAssetId))
.execute();
await tx
.updateTable('stack')
.set({ primaryAssetId: newAssetId, updatedAt: new Date() })
.where('id', '=', parent.stackId)
.execute();
return { stackId: parent.stackId, created: false };
}
const stack = await tx
.insertInto('stack')
.values({ ownerId, primaryAssetId: newAssetId })
.returning('id')
.executeTakeFirstOrThrow();
await tx
.updateTable('asset')
.set({ stackId: stack.id, updatedAt: new Date() })
.where('id', 'in', [asUuid(newAssetId), parent.id])
.execute();
return { stackId: stack.id, created: true };
});
} catch (error) {
// newAssetId may already be another stack's primary (e.g. a retried upload) —
// treat the unique-constraint hit as "couldn't stack" rather than a 500.
if (isStackPrimaryConstraint(error)) {
return null;
}
throw error;
}
}
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> {
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
@@ -418,79 +418,6 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.update).not.toHaveBeenCalled();
});
it('should stack a new asset onto the parent and emit the populated stackId', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const parent = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
mocks.asset.create.mockResolvedValue(assetEntity);
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: true });
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'id_1',
status: AssetMediaStatus.CREATED,
});
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, assetEntity.id, parent.id);
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
asset: expect.objectContaining({ stackId: 'stack-1' }),
});
});
it('should reject stacking onto a trashed asset', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const parent = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce({ ...getForAsset(parent), deletedAt: new Date() });
await expect(
sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.stack.linkAsset).not.toHaveBeenCalled();
});
it('should adopt a duplicate into the stack when stacking', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 0,
};
const parent = AssetFactory.create();
const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
mocks.asset.create.mockRejectedValue(error);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('dup-id');
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: false });
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
id: 'dup-id',
status: AssetMediaStatus.DUPLICATE,
});
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, 'dup-id', parent.id);
});
it('should hide the linked motion asset', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const asset = AssetFactory.create();
+4 -56
View File
@@ -140,63 +140,26 @@ export class AssetMediaService extends BaseService {
this.requireQuota(auth, file.size);
if (dto.stackParentId) {
if (auth.sharedLink) {
throw new BadRequestException('Cannot stack an asset uploaded via shared link');
}
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.stackParentId] });
const parent = await this.assetRepository.getById(dto.stackParentId);
if (!parent || parent.deletedAt) {
throw new BadRequestException('Cannot stack onto a trashed or missing asset');
}
}
if (dto.livePhotoVideoId) {
await onBeforeLink(
{ asset: this.assetRepository, event: this.eventRepository },
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
);
}
// When stacking, defer the AssetCreate event and emit it below with the
// populated stackId, so clients don't briefly see the asset as standalone.
const asset = await this.create(auth.user.id, dto, file, sidecarFile, { skipEventEmit: !!dto.stackParentId });
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
if (auth.sharedLink) {
await this.addToSharedLink(auth.sharedLink, asset.id);
}
if (dto.stackParentId) {
const linkResult = await this.linkToStackParent(auth.user.id, asset.id, dto.stackParentId);
await this.eventRepository.emit('AssetCreate', {
asset: linkResult ? { ...asset, stackId: linkResult.stackId } : asset,
});
}
await this.userRepository.updateUsage(auth.user.id, file.size);
return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
return this.handleUploadError(error, auth, file, sidecarFile);
}
}
private async linkToStackParent(
ownerId: string,
newAssetId: string,
parentId: string,
): Promise<{ stackId: string; created: boolean } | null> {
const result = await this.stackRepository.linkAsset(ownerId, newAssetId, parentId);
if (!result) {
this.logger.warn(`Could not link asset ${newAssetId} to stack parent ${parentId}: parent missing or not owned`);
return null;
}
await this.eventRepository.emit(result.created ? 'StackCreate' : 'StackUpdate', {
stackId: result.stackId,
userId: ownerId,
});
return result;
}
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -327,7 +290,6 @@ export class AssetMediaService extends BaseService {
auth: AuthDto,
file: UploadFile,
sidecarFile?: UploadFile,
stackParentId?: string,
): Promise<AssetMediaResponseDto> {
// clean up files
await this.jobRepository.queue({
@@ -347,12 +309,6 @@ export class AssetMediaService extends BaseService {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
if (stackParentId) {
// Adopt the existing duplicate into the stack so a re-uploaded edit still
// stacks instead of silently staying separate.
await this.linkToStackParent(auth.user.id, duplicateId, stackParentId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
@@ -361,13 +317,7 @@ export class AssetMediaService extends BaseService {
throw error;
}
private async create(
ownerId: string,
dto: AssetMediaCreateDto,
file: UploadFile,
sidecarFile?: UploadFile,
options?: { skipEventEmit?: boolean },
) {
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,
libraryId: null,
@@ -406,9 +356,7 @@ export class AssetMediaService extends BaseService {
lockedPropertiesBehavior: 'override',
});
if (!options?.skipEventEmit) {
await this.eventRepository.emit('AssetCreate', { asset });
}
await this.eventRepository.emit('AssetCreate', { asset });
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
-6
View File
@@ -76,12 +76,6 @@ export const isAssetChecksumConstraint = (error: unknown) => {
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
};
export const STACK_PRIMARY_CONSTRAINT = 'stack_primaryAssetId_uq';
export const isStackPrimaryConstraint = (error: unknown) => {
return (error as PostgresError)?.constraint_name === STACK_PRIMARY_CONSTRAINT;
};
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
}