Merge branch 'main' into fix/map-unresponsive

This commit is contained in:
Yaros
2026-06-10 16:04:13 +02:00
committed by GitHub
115 changed files with 3269 additions and 1117 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false
+2 -2
View File
@@ -84,7 +84,7 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -211,7 +211,7 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
+3 -3
View File
@@ -20,12 +20,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@50e6a3413e5aa9c3ae4d8393c34745be44288b46 # v0.0.48
uses: oasdiff/oasdiff-action/breaking@a8c7f0e5649d20d623edb5b38446d3ab3d82d43c # v0.0.53
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
@@ -37,7 +37,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -69,7 +69,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
+4 -4
View File
@@ -50,14 +50,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
+1 -1
View File
@@ -132,7 +132,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: true
@@ -45,7 +45,7 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: always()
with:
github-token: ${{ steps.generate-token.outputs.token }}
github-token: ${{ steps.token.outputs.token }}
script: |
github.rest.issues.removeLabel({
issue_number: context.payload.pull_request.number,
+9 -5
View File
@@ -10,9 +10,13 @@ on:
type: choice
options:
- 'false'
- major
- minor
- patch
- premajor
- preminor
- prepatch
- prerelease
- release
mobileBump:
description: 'Bump mobile build number'
required: false
@@ -55,7 +59,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ steps.token.outputs.token }}
persist-credentials: true
@@ -68,13 +72,13 @@ jobs:
# TODO move to mise
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Bump version
env:
SERVER_BUMP: ${{ inputs.serverBump }}
MOBILE_BUMP: ${{ inputs.mobileBump }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
@@ -125,7 +129,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
+3 -1
View File
@@ -55,7 +55,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -90,6 +90,8 @@ jobs:
mobile/**/*.g.dart
mobile/**/*.gr.dart
mobile/**/*.drift.dart
mobile/**/*.g.swift
mobile/**/*.g.kt
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
+48 -16
View File
@@ -28,6 +28,10 @@ jobs:
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
root:
- 'misc/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
i18n:
- 'i18n/**'
- 'mise.toml'
@@ -62,6 +66,34 @@ jobs:
- '.github/workflows/test.yml'
force-events: 'workflow_dispatch'
root-unit-tests:
name: Test the root workspace
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).root == true }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@9db058b2e6eec20e07760b0e17a0505c78ec3191 # create-workflow-token-action-v2.0.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run unit tests
run: pnpm test
server-unit-tests:
name: Test & Lint Server
needs: pre-job
@@ -77,7 +109,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -108,7 +140,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -139,7 +171,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -183,7 +215,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -221,7 +253,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -249,7 +281,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -299,7 +331,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -331,7 +363,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: 'recursive'
@@ -367,7 +399,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: 'recursive'
@@ -444,7 +476,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
submodules: 'recursive'
@@ -551,7 +583,7 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -589,7 +621,7 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -620,7 +652,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -649,7 +681,7 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -671,7 +703,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -729,7 +761,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
+1
View File
@@ -60,6 +60,7 @@
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"*.js": "${capture}.spec.js,${capture}.mock.js",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"search.exclude": {
+1 -1
View File
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.4.3-ubuntu@sha256:ca3f764fdc48cebdf22dd206f33ecb0795a9a7210eacd1b5c02204aebd78b223
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -7,7 +7,7 @@ Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generat
OpenAPI is used to generate the client (Typescript, Dart) SDK. `openapi-generator-cli` can be installed [here](https://openapi-generator.tech/docs/installation/). The generated SDK is based on the `immich-openapi-specs.json` file, which is autogenerated by the server **when running in development mode**. The `immich-openapi-specs.json` file can be modified with `@nestjs/swagger` decorators used or referenced by controller endpoints. See the [NestJS OpenAPI docs](https://docs.nestjs.com/openapi/types-and-parameters) for more info. When you add a new endpoint or modify an existing one, you must run the server in development mode and run the command below to update the client SDK.
```bash
make open-api
mise open-api
```
You can find the generated client SDK in the `packages/sdk/client` for Typescript SDK and `mobile/openapi` for Dart SDK.
+1 -1
View File
@@ -218,7 +218,7 @@ When the Dev Container starts, it automatically:
- Debug ports: 9230 (workers), 9231 (API)
:::info
The Dev Container setup replaces the `make dev` command from the traditional setup. All services start automatically when you open the container.
The Dev Container setup replaces the `mise dev` command from the traditional setup. All services start automatically when you open the container.
:::
### Accessing Services
+1 -1
View File
@@ -2,7 +2,7 @@
A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
:::warning
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`mise dev`, ....). Feel free to contribute!
:::
When contributing code through a pull request, please check the following:
+2 -2
View File
@@ -45,7 +45,7 @@ All the services are packaged to run as with single Docker Compose command.
5. From the root directory, run:
```bash title="Start development server"
make dev # required Makefile installed on the system.
mise dev
```
5. Access the dev instance in your browser at http://localhost:3000, or connect via the mobile app.
@@ -88,7 +88,7 @@ To see local changes to `@immich/ui` in Immich, do the following:
3. Uncomment the corresponding volume in web service of the `docker/docker-compose.dev.yml` file (`../../ui:/usr/src/ui`)
4. Uncomment the corresponding alias in the `web/vite.config.ts` file (`'@immich/ui': path.resolve(\_\_dirname, '../../ui/packages/ui')`)
5. Uncomment the import statement in `web/src/app.css` file `@import '../../../ui/packages/ui/dist/theme/default.css';` and comment out `@import '@immich/ui/theme/default.css';`
6. Start up the stack via `make dev`
6. Start up the stack via `mise dev`
7. After making changes in `@immich/ui`, rebuild it (`pnpm run build`)
### Mobile app
+1 -1
View File
@@ -12,7 +12,7 @@ You need to run `mise //server:install` before _once_.
The e2e tests can be run by first starting up a test production environment via:
```bash
make e2e
mise e2e
```
Before you can run the tests, you need to run the following commands _once_:
+2 -1
View File
@@ -4,7 +4,8 @@ services:
e2e-auth-server:
container_name: immich-e2e-auth-server
build:
context: ../packages/e2e-auth-server
context: ../
dockerfile: packages/e2e-auth-server/Dockerfile
ports:
- 2286:2286
+4 -3
View File
@@ -504,13 +504,14 @@ describe('/albums', () => {
});
});
it('should not be able to share album with owner', async () => {
it('should deduplicate owner from albumUsers on create', async () => {
const { status, body } = await request(app)
.post('/albums')
.send({ albumName: 'New album', albumUsers: [{ role: AlbumUserRole.Editor, userId: user1.userId }] })
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot share album with owner'));
expect(status).toBe(201);
expect(body.albumUsers).toHaveLength(1);
expect(body.albumUsers[0]).toMatchObject({ role: AlbumUserRole.Owner, user: { id: user1.userId } });
});
});
@@ -492,6 +492,20 @@ describe('/asset', () => {
expect(status).toEqual(200);
});
it('should set the negative rating', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ rating: -1 });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
rating: -1,
}),
});
expect(status).toEqual(200);
});
it('should return tagged people', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
+1
View File
@@ -2248,6 +2248,7 @@
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",
"sort_items": "Number of items",
+4 -4
View File
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4e
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-core-2_2.34.4+21428_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.34.4/intel-igc-opencl-2_2.34.4+21428_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/intel-opencl-icd_26.18.38308.1-0_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
wget -nv https://github.com/intel/compute-runtime/releases/download/26.18.38308.1/libigdgmm12_22.10.0_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
+5 -4
View File
@@ -1,9 +1,10 @@
#! /usr/bin/env node
const { readFileSync, writeFileSync } = require('node:fs');
import { readFileSync, writeFileSync } from 'node:fs';
const asVersion = (item) => {
const { label, url } = item;
const [major, minor, patch] = label.substring(1).split('.').map(Number);
const [version] = label.substring(1).split('-');
const [major, minor, patch] = version.split('.').map(Number);
return { major, minor, patch, label, url };
};
@@ -31,7 +32,7 @@ for (const item of versions) {
) {
versions = versions.filter((item) => item.label !== version.label);
console.log(
`Removed ${version.label} (replaced with ${lastVersion.label})`
`Removed ${version.label} (replaced with ${lastVersion.label})`,
);
continue;
}
@@ -41,5 +42,5 @@ for (const item of versions) {
writeFileSync(
filename,
JSON.stringify([newVersion, ...versions], null, 2) + '\n'
JSON.stringify([newVersion, ...versions], null, 2) + '\n',
);
+18 -30
View File
@@ -3,12 +3,14 @@
#
# Pump one or both of the server/mobile versions in appropriate files
#
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
# usage: './scripts/pump-version.sh -s <minor|patch|premajor|preminor|prepatch|prerelease> <-m> <true|false>
#
# examples:
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -s premajor # 1.0.0+50 => 2.0.0-rc.0+50
# ./scripts/pump-version.sh -s prerelease # 2.0.0-rc.0+50 => 2.0.0-rc.1+50
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
#
SERVER_PUMP="false"
@@ -25,31 +27,15 @@ while getopts 's:m:' flag; do
esac
done
CURRENT_SERVER=$(jq -r '.version' server/package.json)
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
if [[ $SERVER_PUMP == "major" ]]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [[ $SERVER_PUMP == "minor" ]]; then
MINOR=$((MINOR + 1))
PATCH=0
elif [[ $SERVER_PUMP == "patch" ]]; then
PATCH=$((PATCH + 1))
elif [[ $SERVER_PUMP == "false" ]]; then
echo 'Skipping Server Pump'
else
echo 'Expected <major|minor|patch|false> for the server argument'
CURRENT_SERVER=$(jq -r '.version' package.json)
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
exit 1
fi
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
NEXT_MOBILE=$CURRENT_MOBILE
if [[ $MOBILE_PUMP == "true" ]]; then
set $((NEXT_MOBILE++))
elif [[ $MOBILE_PUMP == "false" ]]; then
@@ -59,15 +45,17 @@ else
exit 1
fi
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
pnpm version "$NEXT_SERVER" --no-git-tag-version
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix e2e
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/sdk
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
# copy version to open-api spec
mise run //:open-api
+7
View File
@@ -0,0 +1,7 @@
import { pump } from './pump.js';
const [versionRaw, type] = process.argv.slice(2);
const { message, exitCode } = pump(versionRaw, type);
console.log(message);
process.exit(exitCode);
+105
View File
@@ -0,0 +1,105 @@
import semver, { SemVer } from 'semver';
const printUsage = () => {
return {
message:
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
exitCode: 1,
};
};
const isPrerelease = (version) => version.prerelease.length > 0;
/**
* @param {SemVer} version
* @returns {boolean}
*/
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
/** @param {string} version */
const normalize = (version) => {
if (version.startsWith('v')) {
version = version.slice(1);
}
return version;
};
/**
* @param {string} versionRaw
* @param {string} type
*/
export const pump = (versionRaw, type) => {
if (!versionRaw) {
return printUsage();
}
versionRaw = normalize(versionRaw);
const version = semver.parse(versionRaw);
if (!version) {
return printUsage();
}
let newVersionRaw;
let valid = true;
switch (type) {
case 'patch':
case 'prepatch':
case 'minor':
case 'preminor':
case 'premajor': {
newVersionRaw = inc(version, type);
// can only use while not in a prerelease
valid = !isPrerelease(version);
break;
}
case 'prerelease': {
newVersionRaw = inc(version, type);
// can only use while in a prerelease
valid = isPrerelease(version);
break;
}
case 'release': {
// drop prerelease part
newVersionRaw = `${version.major}.${version.minor}.${version.patch}`;
// can only use to promote a prerelease to a release (no version change)
valid = isPrerelease(version);
break;
}
default: {
return printUsage();
}
}
if (!newVersionRaw) {
return printUsage();
}
newVersionRaw = normalize(newVersionRaw);
const newVersion = semver.parse(newVersionRaw);
if (!newVersion) {
return printUsage();
}
const invalidUpgrade =
isPrerelease(version) &&
!isPrerelease(newVersion) &&
(version.major !== newVersion.major ||
version.minor !== newVersion.minor ||
version.patch !== newVersion.patch);
if (!valid || invalidUpgrade) {
return {
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
exitCode: 1,
};
}
return { message: newVersionRaw, exitCode: 0 };
};
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { pump } from './pump';
describe(pump.name, () => {
describe('usage', () => {
it.each([
[],
['2.7.5'],
['2.7.5', 'invalid'],
['invalid', 'patch'],
['2.7.5', 'major'],
])('should not accept $0, $1 as inputs', (version, type) => {
expect(pump(version, type)).toEqual({
message: expect.stringContaining('Usage: '),
exitCode: 1,
});
});
});
describe('transitions', () => {
const valid = [
{
name: 'patch',
items: [['patch', '2.7.5', '2.7.6']],
},
{
name: 'prepatch',
items: [
['prepatch', '2.7.5', '2.7.6-rc.0'],
['prerelease', '2.7.6-rc.0', '2.7.6-rc.1'],
['release', '2.7.6-rc.1', '2.7.6'],
],
},
{
name: 'minor',
items: [['minor', '2.7.5', '2.8.0']],
},
{
name: 'preminor',
items: [
['preminor', '2.7.5', '2.8.0-rc.0'],
['prerelease', '2.8.0-rc.0', '2.8.0-rc.1'],
['release', '2.8.0-rc.1', '2.8.0'],
],
},
{
name: 'premajor',
items: [
['premajor', '2.7.5', '3.0.0-rc.0'],
['prerelease', '3.0.0-rc.0', '3.0.0-rc.1'],
['release', '3.0.0-rc.1', '3.0.0'],
],
},
];
for (const group of valid) {
describe(group.name, () => {
it.each(group.items)(
'should allow a $0 from $1 to $2',
(type, version, next) => {
expect(pump(version, type)).toEqual({
message: next,
exitCode: 0,
});
},
);
});
}
describe('invalid', () => {
it.each([
['patch', 'v3.0.0-rc.0'],
['prepatch', 'v3.0.0-rc.0'],
['minor', 'v3.0.0-rc.0'],
['preminor', 'v3.0.0-rc.0'],
['premajor', 'v3.0.0-rc.0'],
['prerelease', 'v3.0.0'],
['release', 'v3.0.0'],
])('should not allow a $0 on $1', (type, version) => {
expect(pump(version, type)).toEqual({
message: expect.stringContaining('Invalid pump'),
exitCode: 1,
});
});
});
});
});
+45 -51
View File
@@ -82,40 +82,8 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
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"
[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"
[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"
[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"
[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"
[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"
[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"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
@@ -156,6 +124,30 @@ checksum = "sha256:b5e1d2a1ad3c03229ddc89823848f4a1c11f9c6402a51fa26f0aaa5f1d7a2
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-windows.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288925833"
[[tools.java]]
version = "21.0.2"
backend = "core:java"
[tools.java."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"
@@ -225,36 +217,38 @@ checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c70773
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz"
[[tools.pnpm]]
version = "10.33.4"
version = "11.4.0"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
checksum = "sha256:cc38ebd5b2610a5744f84576b963c49e6609a8df5aed714ae3de749998d4478c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-arm64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:d29649c7380b5cd522f574208fbd35335846686498f45004604d3f5b8658b5cb"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-arm64"
checksum = "sha256:a1e2ec9123c709fd04b704227cfcf3b50cd2bbbc1bd39d2df414530b5697eb75"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-arm64-musl.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
checksum = "sha256:f3f8d1217eef013bbc71a24d52efb1f1041e4aff55edd80e0b08e25f409305a4"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-x64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:ff1795595535a10d0dfe327303f3dd02377be141190b1f5756de68edde2cf813"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-linux-x64"
checksum = "sha256:60010ad00a96b71e20d1618acaca7a71395e710cbd5e88946c030a1d07c56916"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-linux-x64-musl.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:7aae186a04e1ffaa0047d43cd07d68a98dec303304f28be52234ba955d26c671"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-arm64"
[tools.pnpm."platforms.macos-x64"]
checksum = "sha256:3b0c97b9f794cdda293949a8ee0e0151ca08f512f4a832408386221c7c73eec6"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-macos-x64"
checksum = "sha256:ba59014c2c1ce8b76af9f559385206a2623de4ff2b694b5c91598a8f44abb4e2"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-darwin-arm64.tar.gz"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:3268b2f29defe0dce8a3a26c0ef01488f0d4aa4872923173186ef618ab7d68ef"
url = "https://github.com/pnpm/pnpm/releases/download/v10.33.4/pnpm-win-x64.exe"
checksum = "sha256:84ce90e38bc0b1164173eb853a0fbffc7edcb050cb0d5c8ce4ca609f5c808e0a"
url = "https://github.com/pnpm/pnpm/releases/download/v11.4.0/pnpm-win32-x64.zip"
provenance = "github-attestations"
[[tools.terragrunt]]
version = "1.0.3"
+2 -1
View File
@@ -16,13 +16,14 @@ config_roots = [
[tools]
node = "24.15.0"
pnpm = "10.33.4"
pnpm = "11.4.0"
terragrunt = "1.0.3"
opentofu = "1.11.6"
"npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.6.0"
java = "21.0.2"
[tools."github:jellyfin/jellyfin-ffmpeg"]
version = "7.1.3-6"
-18
View File
@@ -11,24 +11,6 @@ import Foundation
#error("Unsupported platform.")
#endif
/// Error class for passing custom error details to Dart side.
final class PigeonError: Error {
let code: String
let message: String?
let details: Sendable?
init(code: String, message: String?, details: Sendable?) {
self.code = code
self.message = message
self.details = details
}
var localizedDescription: String {
return
"PigeonError(code: \(code), message: \(message ?? "<nil>"), details: \(details ?? "<nil>")"
}
}
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
@@ -138,7 +137,7 @@ class RemoteAlbumService {
Future<RemoteAlbum> updateAlbum(
String albumId, {
String? name,
Option<String?> description = const Option.none(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -1,11 +1,15 @@
import 'dart:convert';
import 'package:diacritic/diacritic.dart' as diacritic;
extension StringExtension on String {
String capitalize() {
return split(" ").map((str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1)).join(" ");
}
String? get nullIfEmpty => isEmpty ? null : this;
String removeDiacritics() => diacritic.removeDiacritics(this);
}
extension DurationExtension on String {
@@ -197,7 +197,7 @@ class FolderContent extends HookConsumerWidget {
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
"${asset.exifInfo.fileSize != null ? formatBytes(asset.exifInfo.fileSize ?? 0) : ""}${DateFormat.yMMMd().format(asset.createdAt)}",
"${asset.exifInfo.fileSize != null ? "${formatBytes(asset.exifInfo.fileSize ?? 0)}" : ""}${DateFormat.yMMMd().format(asset.createdAt.toLocal())}",
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
),
@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class DriftCreateAlbumPage extends ConsumerStatefulWidget {
@@ -47,7 +48,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
super.dispose();
}
bool get _canCreateAlbum => albumTitleController.text.isNotEmpty;
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty;
String _getEffectiveTitle() {
return albumTitleController.text.isNotEmpty
@@ -169,25 +170,23 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
onBackgroundTapped();
final title = _getEffectiveTitle().trim();
if (title.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('create_album_title_required'.t()), backgroundColor: context.colorScheme.error),
);
try {
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(
title: title,
description: albumDescriptionController.text.trim(),
assets: selectedAssets,
);
if (album != null && context.mounted) {
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
}
} catch (_) {
if (context.mounted) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
}
return;
}
final album = await ref
.read(remoteAlbumProvider.notifier)
.createAlbumWithAssets(
title: title,
description: albumDescriptionController.text.trim(),
assets: selectedAssets,
);
if (album != null && context.mounted) {
unawaited(context.replaceRoute(RemoteAlbumRoute(album: album)));
}
}
@@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -64,7 +65,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
data: (people) {
if (_search != null) {
people = people.where((person) {
return person.name.toLowerCase().contains(_search!.toLowerCase());
return person.name.toLowerCase().removeDiacritics().contains(
_search!.toLowerCase().removeDiacritics(),
);
}).toList();
}
return GridView.builder(
@@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dar
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
@@ -248,13 +247,10 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
try {
final newTitle = titleController.text.trim();
final newDescription = descriptionController.text.trim();
final description = newDescription.isEmpty
? const Option<String?>.some(null)
: Option<String?>.some(newDescription);
await ref
.read(remoteAlbumProvider.notifier)
.updateAlbum(widget.album.id, name: newTitle, description: description);
.updateAlbum(widget.album.id, name: newTitle, description: newDescription);
if (mounted) {
Navigator.of(
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -100,6 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
@@ -157,6 +157,55 @@ class _OcrBoxes extends StatelessWidget {
final cx = viewportWidth / 2 + position.dx;
final cy = viewportHeight / 2 + position.dy;
final quads = <List<Offset>>[];
final boxes = <Widget>[];
for (final entry in ocrData.asMap().entries) {
final index = entry.key;
final ocr = entry.value;
// Map normalized image coords (01) to viewport space
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
// Bounding rectangle for hit testing and Positioned placement
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
quads.add([Offset(x1, y1), Offset(x2, y2), Offset(x3, y3), Offset(x4, y4)]);
boxes.add(
_OcrBoxItem(
key: ValueKey(index),
ocr: ocr,
index: index,
isSelected: selectedBoxIndex == index,
points: [
Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY),
],
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
angle: math.atan2(y2 - y1, x2 - x1),
labelDx: (minX + maxX) / 2 - minX,
labelDy: (minY + maxY) / 2 - minY,
onSelectionChanged: onSelectionChanged,
),
);
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onSelectionChanged(null),
@@ -165,47 +214,13 @@ class _OcrBoxes extends StatelessWidget {
children: [
// Fills the viewport so taps outside boxes deselect
SizedBox(width: viewportWidth, height: viewportHeight),
...ocrData.asMap().entries.map((entry) {
final index = entry.key;
final ocr = entry.value;
// Map normalized image coords (01) to viewport space
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
// Bounding rectangle for hit testing and Positioned placement
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
return _OcrBoxItem(
key: ValueKey(index),
ocr: ocr,
index: index,
isSelected: selectedBoxIndex == index,
points: [
Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY),
],
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
angle: math.atan2(y2 - y1, x2 - x1),
labelDx: (minX + maxX) / 2 - minX,
labelDy: (minY + maxY) / 2 - minY,
onSelectionChanged: onSelectionChanged,
);
}),
// Dark scrim with the text boxes punched out
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _OcrScrimPainter(quads: quads)),
),
),
...boxes,
],
),
),
@@ -307,6 +322,35 @@ class _OcrBoxItem extends StatelessWidget {
}
}
class _OcrScrimPainter extends CustomPainter {
final List<List<Offset>> quads;
const _OcrScrimPainter({required this.quads});
@override
void paint(Canvas canvas, Size size) {
// Fill the whole viewport, then subtract each text quad using the even-odd
// rule so the original image shows through the boxes.
final path = Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size);
for (final quad in quads) {
path
..moveTo(quad[0].dx, quad[0].dy)
..lineTo(quad[1].dx, quad[1].dy)
..lineTo(quad[2].dx, quad[2].dy)
..lineTo(quad[3].dx, quad[3].dy)
..close();
}
canvas.drawPath(path, Paint()..color = Colors.black54);
}
@override
bool shouldRepaint(_OcrScrimPainter oldDelegate) => true;
}
class _OcrBoxPainter extends CustomPainter {
final List<Offset> points;
final bool isSelected;
@@ -322,7 +366,7 @@ class _OcrBoxPainter extends CustomPainter {
..strokeWidth = 2.0;
final fillPaint = Paint()
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
..color = isSelected ? colorScheme.primary.withValues(alpha: 0.45) : Colors.transparent
..style = PaintingStyle.fill;
final path = Path()
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
class OcrToggleButton extends ConsumerWidget {
final BaseAsset asset;
const OcrToggleButton({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
return AnimatedSwitcher(
duration: Durations.short4,
child: !hasOcr
? const SizedBox.shrink()
: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 32, bottom: 8),
child: Material(
color: showingOcr ? context.primaryColor : Colors.black.withValues(alpha: 0.4),
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
child: const Padding(
padding: EdgeInsets.all(10.0),
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
),
),
),
),
),
);
}
}
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -36,7 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
@@ -48,15 +46,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
final actions = <Widget>[
if (hasOcr)
IconButton(
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
onPressed: ref.read(assetViewerProvider.notifier).toggleOcr,
color: showingOcr ? context.primaryColor : null,
),
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(
@@ -1,6 +1,8 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
@@ -14,21 +16,68 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class ArchiveBottomSheet extends ConsumerWidget {
class ArchiveBottomSheet extends ConsumerStatefulWidget {
const ArchiveBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ArchiveBottomSheet> createState() => _ArchiveBottomSheetState();
}
class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
late final DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
maxChildSize: 0.4,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
const ShareActionButton(source: ActionSource.timeline),
@@ -48,6 +97,10 @@ class ArchiveBottomSheet extends ConsumerWidget {
],
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
);
}
}
@@ -30,7 +30,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
style: TextStyle(color: Colors.grey[400], fontSize: 13.0, fontWeight: FontWeight.w500),
),
Text(
df.format(fileCreatedDate),
df.format(fileCreatedDate.toLocal()),
style: const TextStyle(color: Colors.white, fontSize: 15.0, fontWeight: FontWeight.w500),
),
],
@@ -41,7 +41,7 @@ class DriftMemoryBottomInfo extends StatelessWidget {
minWidth: 0,
onPressed: () async {
await context.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate));
EventStream.shared.emit(ScrollToDateEvent(fileCreatedDate.toLocal()));
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -221,6 +221,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
return;
}
if (_scrubberHeight <= 0) {
return;
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -154,7 +153,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<RemoteAlbum?> updateAlbum(
String albumId, {
String? name,
Option<String?> description = const Option.none(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -3,7 +3,6 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart' hide AlbumUserRole;
@@ -72,7 +71,7 @@ class DriftAlbumApiRepository extends ApiRepository {
String albumId,
UserDto owner, {
String? name,
Option<String?> description = const Option.none(),
String? description,
String? thumbnailAssetId,
bool? isActivityEnabled,
AlbumAssetOrder? order,
@@ -87,7 +86,7 @@ class DriftAlbumApiRepository extends ApiRepository {
albumId,
UpdateAlbumDto(
albumName: name == null ? const Optional.absent() : Optional.present(name),
description: description.toOptional(),
description: description == null ? const Optional.absent() : Optional.present(description),
albumThumbnailAssetId: thumbnailAssetId == null
? const Optional.absent()
: Optional.present(thumbnailAssetId),
@@ -141,7 +141,7 @@ class _ProfileIndicator extends ConsumerWidget {
color: serverInfoState.versionStatus == VersionStatus.error
? context.colorScheme.error
: context.primaryColor,
size: widgetSize / 2,
size: widgetSize / 2 - 3,
semanticLabel: 'new_version_available'.tr(),
),
),
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
@@ -44,16 +45,19 @@ class PeoplePicker extends HookConsumerWidget {
Expanded(
child: people.widgetWhen(
onData: (people) {
final filtered = people
.where(
(person) => person.name.toLowerCase().removeDiacritics().contains(
searchQuery.value.toLowerCase().removeDiacritics(),
),
)
.toList();
return ListView.builder(
shrinkWrap: true,
itemCount: people
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.length,
itemCount: filtered.length,
padding: const EdgeInsets.all(8),
itemBuilder: (context, index) {
final person = people
.where((person) => person.name.toLowerCase().contains(searchQuery.value.toLowerCase()))
.toList()[index];
final person = filtered[index];
final isSelected = selectedPeople.value.contains(person);
return Padding(
-4
View File
@@ -102,8 +102,6 @@ run = "flutter run"
[tasks."i18n:loader"]
description = "Generate i18n loader"
hide = true
sources = ["i18n/"]
outputs = "lib/generated/codegen_loader.g.dart"
run = [
"dart run easy_localization:generate -S ../i18n",
"dart format lib/generated/codegen_loader.g.dart",
@@ -112,8 +110,6 @@ run = [
[tasks."i18n:keys"]
description = "Generate i18n keys"
hide = true
sources = ["i18n/en.json"]
outputs = "lib/generated/translations.g.dart"
run = [
"dart run bin/generate_keys.dart",
"dart format lib/generated/translations.g.dart",
+14
View File
@@ -148,6 +148,20 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DeprecatedApi* | [**updateApiKey**](doc//DeprecatedApi.md#updateapikey) | **PUT** /api-keys/{id} | Update an API key
*DeprecatedApi* | [**updateAsset**](doc//DeprecatedApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
*DeprecatedApi* | [**updateAssets**](doc//DeprecatedApi.md#updateassets) | **PUT** /assets | Update assets
*DeprecatedApi* | [**updateLibrary**](doc//DeprecatedApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
*DeprecatedApi* | [**updateMemory**](doc//DeprecatedApi.md#updatememory) | **PUT** /memories/{id} | Update a memory
*DeprecatedApi* | [**updateMyPreferences**](doc//DeprecatedApi.md#updatemypreferences) | **PUT** /users/me/preferences | Update my preferences
*DeprecatedApi* | [**updateMyUser**](doc//DeprecatedApi.md#updatemyuser) | **PUT** /users/me | Update current user
*DeprecatedApi* | [**updatePerson**](doc//DeprecatedApi.md#updateperson) | **PUT** /people/{id} | Update person
*DeprecatedApi* | [**updateSession**](doc//DeprecatedApi.md#updatesession) | **PUT** /sessions/{id} | Update a session
*DeprecatedApi* | [**updateStack**](doc//DeprecatedApi.md#updatestack) | **PUT** /stacks/{id} | Update a stack
*DeprecatedApi* | [**updateTag**](doc//DeprecatedApi.md#updatetag) | **PUT** /tags/{id} | Update a tag
*DeprecatedApi* | [**updateUserAdmin**](doc//DeprecatedApi.md#updateuseradmin) | **PUT** /admin/users/{id} | Update a user
*DeprecatedApi* | [**updateUserPreferencesAdmin**](doc//DeprecatedApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | Update user preferences
*DeprecatedApi* | [**updateWorkflow**](doc//DeprecatedApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Dismiss a duplicate group
+845
View File
@@ -184,4 +184,849 @@ class DeprecatedApi {
}
return null;
}
/// Update an API key
///
/// Updates the name and permissions of an API key by its ID. The current user must own this API key.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [ApiKeyUpdateDto] apiKeyUpdateDto (required):
Future<Response> updateApiKeyWithHttpInfo(String id, ApiKeyUpdateDto apiKeyUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/api-keys/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = apiKeyUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update an API key
///
/// Updates the name and permissions of an API key by its ID. The current user must own this API key.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [ApiKeyUpdateDto] apiKeyUpdateDto (required):
Future<ApiKeyResponseDto?> updateApiKey(String id, ApiKeyUpdateDto apiKeyUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateApiKeyWithHttpInfo(id, apiKeyUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ApiKeyResponseDto',) as ApiKeyResponseDto;
}
return null;
}
/// Update an asset
///
/// Update information of a specific asset.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateAssetDto] updateAssetDto (required):
Future<Response> updateAssetWithHttpInfo(String id, UpdateAssetDto updateAssetDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = updateAssetDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update an asset
///
/// Update information of a specific asset.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateAssetDto] updateAssetDto (required):
Future<AssetResponseDto?> updateAsset(String id, UpdateAssetDto updateAssetDto, { Future<void>? abortTrigger, }) async {
final response = await updateAssetWithHttpInfo(id, updateAssetDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetResponseDto',) as AssetResponseDto;
}
return null;
}
/// Update assets
///
/// Updates multiple assets at the same time.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets';
// ignore: prefer_final_locals
Object? postBody = assetBulkUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update assets
///
/// Updates multiple assets at the same time.
///
/// Parameters:
///
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
Future<void> updateAssets(AssetBulkUpdateDto assetBulkUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateAssetsWithHttpInfo(assetBulkUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Update a library
///
/// Update an existing external library.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateLibraryDto] updateLibraryDto (required):
Future<Response> updateLibraryWithHttpInfo(String id, UpdateLibraryDto updateLibraryDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/libraries/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = updateLibraryDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update a library
///
/// Update an existing external library.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateLibraryDto] updateLibraryDto (required):
Future<LibraryResponseDto?> updateLibrary(String id, UpdateLibraryDto updateLibraryDto, { Future<void>? abortTrigger, }) async {
final response = await updateLibraryWithHttpInfo(id, updateLibraryDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LibraryResponseDto',) as LibraryResponseDto;
}
return null;
}
/// Update a memory
///
/// Update an existing memory by its ID.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MemoryUpdateDto] memoryUpdateDto (required):
Future<Response> updateMemoryWithHttpInfo(String id, MemoryUpdateDto memoryUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/memories/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = memoryUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update a memory
///
/// Update an existing memory by its ID.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MemoryUpdateDto] memoryUpdateDto (required):
Future<MemoryResponseDto?> updateMemory(String id, MemoryUpdateDto memoryUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateMemoryWithHttpInfo(id, memoryUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemoryResponseDto',) as MemoryResponseDto;
}
return null;
}
/// Update my preferences
///
/// Update the preferences of the current user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
Future<Response> updateMyPreferencesWithHttpInfo(UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/users/me/preferences';
// ignore: prefer_final_locals
Object? postBody = userPreferencesUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update my preferences
///
/// Update the preferences of the current user.
///
/// Parameters:
///
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
Future<UserPreferencesResponseDto?> updateMyPreferences(UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateMyPreferencesWithHttpInfo(userPreferencesUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserPreferencesResponseDto',) as UserPreferencesResponseDto;
}
return null;
}
/// Update current user
///
/// Update the current user making the API request.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [UserUpdateMeDto] userUpdateMeDto (required):
Future<Response> updateMyUserWithHttpInfo(UserUpdateMeDto userUpdateMeDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/users/me';
// ignore: prefer_final_locals
Object? postBody = userUpdateMeDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update current user
///
/// Update the current user making the API request.
///
/// Parameters:
///
/// * [UserUpdateMeDto] userUpdateMeDto (required):
Future<UserAdminResponseDto?> updateMyUser(UserUpdateMeDto userUpdateMeDto, { Future<void>? abortTrigger, }) async {
final response = await updateMyUserWithHttpInfo(userUpdateMeDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto;
}
return null;
}
/// Update person
///
/// Update an individual person.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [PersonUpdateDto] personUpdateDto (required):
Future<Response> updatePersonWithHttpInfo(String id, PersonUpdateDto personUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/people/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = personUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update person
///
/// Update an individual person.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [PersonUpdateDto] personUpdateDto (required):
Future<PersonResponseDto?> updatePerson(String id, PersonUpdateDto personUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updatePersonWithHttpInfo(id, personUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
}
return null;
}
/// Update a session
///
/// Update a specific session identified by id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [SessionUpdateDto] sessionUpdateDto (required):
Future<Response> updateSessionWithHttpInfo(String id, SessionUpdateDto sessionUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/sessions/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = sessionUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update a session
///
/// Update a specific session identified by id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [SessionUpdateDto] sessionUpdateDto (required):
Future<SessionResponseDto?> updateSession(String id, SessionUpdateDto sessionUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateSessionWithHttpInfo(id, sessionUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SessionResponseDto',) as SessionResponseDto;
}
return null;
}
/// Update a stack
///
/// Update an existing stack by its ID.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [StackUpdateDto] stackUpdateDto (required):
Future<Response> updateStackWithHttpInfo(String id, StackUpdateDto stackUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/stacks/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = stackUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update a stack
///
/// Update an existing stack by its ID.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [StackUpdateDto] stackUpdateDto (required):
Future<StackResponseDto?> updateStack(String id, StackUpdateDto stackUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateStackWithHttpInfo(id, stackUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'StackResponseDto',) as StackResponseDto;
}
return null;
}
/// Update a tag
///
/// Update an existing tag identified by its ID.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [TagUpdateDto] tagUpdateDto (required):
Future<Response> updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/tags/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = tagUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update a tag
///
/// Update an existing tag identified by its ID.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [TagUpdateDto] tagUpdateDto (required):
Future<TagResponseDto?> updateTag(String id, TagUpdateDto tagUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateTagWithHttpInfo(id, tagUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TagResponseDto',) as TagResponseDto;
}
return null;
}
/// Update a user
///
/// Update an existing user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UserAdminUpdateDto] userAdminUpdateDto (required):
Future<Response> updateUserAdminWithHttpInfo(String id, UserAdminUpdateDto userAdminUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/users/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = userAdminUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update a user
///
/// Update an existing user.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UserAdminUpdateDto] userAdminUpdateDto (required):
Future<UserAdminResponseDto?> updateUserAdmin(String id, UserAdminUpdateDto userAdminUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateUserAdminWithHttpInfo(id, userAdminUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto;
}
return null;
}
/// Update user preferences
///
/// Update the preferences of a specific user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
Future<Response> updateUserPreferencesAdminWithHttpInfo(String id, UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/users/{id}/preferences'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = userPreferencesUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update user preferences
///
/// Update the preferences of a specific user.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UserPreferencesUpdateDto] userPreferencesUpdateDto (required):
Future<UserPreferencesResponseDto?> updateUserPreferencesAdmin(String id, UserPreferencesUpdateDto userPreferencesUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateUserPreferencesAdminWithHttpInfo(id, userPreferencesUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserPreferencesResponseDto',) as UserPreferencesResponseDto;
}
return null;
}
/// Update a workflow
///
/// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [WorkflowUpdateDto] workflowUpdateDto (required):
Future<Response> updateWorkflowWithHttpInfo(String id, WorkflowUpdateDto workflowUpdateDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/workflows/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = workflowUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update a workflow
///
/// Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [WorkflowUpdateDto] workflowUpdateDto (required):
Future<WorkflowResponseDto?> updateWorkflow(String id, WorkflowUpdateDto workflowUpdateDto, { Future<void>? abortTrigger, }) async {
final response = await updateWorkflowWithHttpInfo(id, workflowUpdateDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowResponseDto',) as WorkflowResponseDto;
}
return null;
}
}
+2 -2
View File
@@ -95,9 +95,9 @@ class AssetBulkUpdateDto {
///
Optional<num?> longitude;
/// Rating in range [1-5], or null for unrated
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
///
/// Minimum value: 1
/// Minimum value: -1
/// Maximum value: 5
Optional<int?> rating;
+2 -2
View File
@@ -77,9 +77,9 @@ class UpdateAssetDto {
///
Optional<num?> longitude;
/// Rating in range [1-5], or null for unrated
/// Rating in range [1-5] (starred), -1 (rejected), or null (unrated)
///
/// Minimum value: 1
/// Minimum value: -1
/// Maximum value: 5
Optional<int?> rating;
+8
View File
@@ -354,6 +354,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.3"
diacritic:
dependency: "direct main"
description:
name: diacritic
sha256: "12981945ec38931748836cd76f2b38773118d0baef3c68404bdfde9566147876"
url: "https://pub.dev"
source: hosted
version: "0.1.6"
drift:
dependency: "direct main"
description:
+1
View File
@@ -18,6 +18,7 @@ dependencies:
crop_image: ^1.0.17
crypto: ^3.0.7
device_info_plus: ^12.4.0
diacritic: ^0.1.6
drift: ^2.32.1
drift_sqlite_async: 0.3.1
dynamic_color: ^1.8.1
@@ -18,6 +18,56 @@ void main() {
expect("a:b:c".toDuration(), isNull);
});
});
group('Test removeDiacritics', () {
test('removes acute accents', () {
expect('Amélie'.removeDiacritics(), 'Amelie');
});
test('removes grave accents', () {
expect('À la carte'.removeDiacritics(), 'A la carte');
});
test('removes circumflex', () {
expect('hôpital'.removeDiacritics(), 'hopital');
});
test('removes tilde', () {
expect('São João'.removeDiacritics(), 'Sao Joao');
});
test('removes diaeresis', () => expect('naïve'.removeDiacritics(), 'naive'));
test('removes cedilla', () => expect('ça va'.removeDiacritics(), 'ca va'));
test('handles Hungarian exteded characters (ű/ő)', () {
expect('árvíztűrő tükörfúrógép'.removeDiacritics(), 'arvizturo tukorfurogep');
});
test('handles Polish characters', () {
expect('Jędrzej Łącki'.removeDiacritics(), 'Jedrzej Lacki');
});
test('handles German umlauts', () => expect('Müller'.removeDiacritics(), 'Muller'));
test('handles Nordic characters', () => expect('Göteborg'.removeDiacritics(), 'Goteborg'));
test('handles empty string', () => expect(''.removeDiacritics(), ''));
test('handles string with no diacritics', () {
expect('hello world'.removeDiacritics(), 'hello world');
});
test('handles Ñ/ñ', () => expect('Niño'.removeDiacritics(), 'Nino'));
test('diacritic removal is order-independent', () {
const raw = 'Árvíztűrő';
expect(
raw.toLowerCase().removeDiacritics(),
raw.removeDiacritics().toLowerCase(),
);
});
});
group('Test uniqueConsecutive', () {
test('empty', () {
final a = [];
+133 -44
View File
@@ -1194,6 +1194,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update an existing user.",
"operationId": "updateUserAdmin",
"parameters": [
@@ -1243,7 +1244,8 @@
],
"summary": "Update a user",
"tags": [
"Users (admin)"
"Users (admin)",
"Deprecated"
],
"x-immich-admin-only": true,
"x-immich-history": [
@@ -1258,10 +1260,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateUserAdmin"
}
],
"x-immich-permission": "adminUser.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/admin/users/{id}/calendar-heatmap": {
@@ -1416,6 +1423,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update the preferences of a specific user.",
"operationId": "updateUserPreferencesAdmin",
"parameters": [
@@ -1465,7 +1473,8 @@
],
"summary": "Update user preferences",
"tags": [
"Users (admin)"
"Users (admin)",
"Deprecated"
],
"x-immich-admin-only": true,
"x-immich-history": [
@@ -1480,10 +1489,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateUserPreferencesAdmin"
}
],
"x-immich-permission": "adminUser.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/admin/users/{id}/restore": {
@@ -2863,6 +2877,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Updates the name and permissions of an API key by its ID. The current user must own this API key.",
"operationId": "updateApiKey",
"parameters": [
@@ -2912,7 +2927,8 @@
],
"summary": "Update an API key",
"tags": [
"API keys"
"API keys",
"Deprecated"
],
"x-immich-history": [
{
@@ -2926,10 +2942,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateApiKey"
}
],
"x-immich-permission": "apiKey.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/assets": {
@@ -3080,6 +3101,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Updates multiple assets at the same time.",
"operationId": "updateAssets",
"parameters": [],
@@ -3111,7 +3133,8 @@
],
"summary": "Update assets",
"tags": [
"Assets"
"Assets",
"Deprecated"
],
"x-immich-history": [
{
@@ -3125,10 +3148,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateAssets"
}
],
"x-immich-permission": "asset.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/assets/bulk-upload-check": {
@@ -3557,6 +3585,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update information of a specific asset.",
"operationId": "updateAsset",
"parameters": [
@@ -3606,7 +3635,8 @@
],
"summary": "Update an asset",
"tags": [
"Assets"
"Assets",
"Deprecated"
],
"x-immich-history": [
{
@@ -3620,10 +3650,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateAsset"
}
],
"x-immich-permission": "asset.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/assets/{id}/edits": {
@@ -6329,6 +6364,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update an existing external library.",
"operationId": "updateLibrary",
"parameters": [
@@ -6378,7 +6414,8 @@
],
"summary": "Update a library",
"tags": [
"Libraries"
"Libraries",
"Deprecated"
],
"x-immich-admin-only": true,
"x-immich-history": [
@@ -6393,10 +6430,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateLibrary"
}
],
"x-immich-permission": "library.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/libraries/{id}/scan": {
@@ -7165,6 +7207,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update an existing memory by its ID.",
"operationId": "updateMemory",
"parameters": [
@@ -7214,7 +7257,8 @@
],
"summary": "Update a memory",
"tags": [
"Memories"
"Memories",
"Deprecated"
],
"x-immich-history": [
{
@@ -7228,10 +7272,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateMemory"
}
],
"x-immich-permission": "memory.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/memories/{id}/assets": {
@@ -8711,6 +8760,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update an individual person.",
"operationId": "updatePerson",
"parameters": [
@@ -8760,7 +8810,8 @@
],
"summary": "Update person",
"tags": [
"People"
"People",
"Deprecated"
],
"x-immich-history": [
{
@@ -8774,10 +8825,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updatePerson"
}
],
"x-immich-permission": "person.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/people/{id}/merge": {
@@ -11529,6 +11585,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update a specific session identified by id.",
"operationId": "updateSession",
"parameters": [
@@ -11578,7 +11635,8 @@
],
"summary": "Update a session",
"tags": [
"Sessions"
"Sessions",
"Deprecated"
],
"x-immich-history": [
{
@@ -11592,10 +11650,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateSession"
}
],
"x-immich-permission": "session.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/sessions/{id}/lock": {
@@ -12545,6 +12608,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update an existing stack by its ID.",
"operationId": "updateStack",
"parameters": [
@@ -12594,7 +12658,8 @@
],
"summary": "Update a stack",
"tags": [
"Stacks"
"Stacks",
"Deprecated"
],
"x-immich-history": [
{
@@ -12608,10 +12673,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateStack"
}
],
"x-immich-permission": "stack.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/stacks/{id}/assets/{assetId}": {
@@ -13648,6 +13718,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update an existing tag identified by its ID.",
"operationId": "updateTag",
"parameters": [
@@ -13697,7 +13768,8 @@
],
"summary": "Update a tag",
"tags": [
"Tags"
"Tags",
"Deprecated"
],
"x-immich-history": [
{
@@ -13711,10 +13783,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateTag"
}
],
"x-immich-permission": "tag.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/tags/{id}/assets": {
@@ -14517,6 +14594,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update the current user making the API request.",
"operationId": "updateMyUser",
"parameters": [],
@@ -14555,7 +14633,8 @@
],
"summary": "Update current user",
"tags": [
"Users"
"Users",
"Deprecated"
],
"x-immich-history": [
{
@@ -14569,10 +14648,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateMyUser"
}
],
"x-immich-permission": "user.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/users/me/calendar-heatmap": {
@@ -15003,6 +15087,7 @@
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Update the preferences of the current user.",
"operationId": "updateMyPreferences",
"parameters": [],
@@ -15041,7 +15126,8 @@
],
"summary": "Update my preferences",
"tags": [
"Users"
"Users",
"Deprecated"
],
"x-immich-history": [
{
@@ -15055,10 +15141,15 @@
{
"version": "v2",
"state": "Stable"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateMyPreferences"
}
],
"x-immich-permission": "userPreference.update",
"x-immich-state": "Stable"
"x-immich-state": "Deprecated"
}
},
"/users/profile-image": {
@@ -15680,6 +15771,7 @@
"x-immich-permission": "workflow.read"
},
"put": {
"deprecated": true,
"description": "Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.",
"operationId": "updateWorkflow",
"parameters": [
@@ -15729,15 +15821,22 @@
],
"summary": "Update a workflow",
"tags": [
"Workflows"
"Workflows",
"Deprecated"
],
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Added"
},
{
"version": "v3",
"state": "Deprecated",
"replacementId": "updateWorkflow"
}
],
"x-immich-permission": "workflow.update"
"x-immich-permission": "workflow.update",
"x-immich-state": "Deprecated"
}
},
"/workflows/{id}/share": {
@@ -16503,9 +16602,9 @@
"type": "number"
},
"rating": {
"description": "Rating in range [1-5], or null for unrated",
"description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)",
"maximum": 5,
"minimum": 1,
"minimum": -1,
"nullable": true,
"type": "integer",
"x-immich-history": [
@@ -16517,15 +16616,10 @@
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
},
{
"version": "v3",
"state": "Updated",
"description": "Using -1 as a rating is no longer valid."
"description": "Using 0 as a rating is no longer valid."
}
],
"x-immich-state": "Stable"
@@ -26331,9 +26425,9 @@
"type": "number"
},
"rating": {
"description": "Rating in range [1-5], or null for unrated",
"description": "Rating in range [1-5] (starred), -1 (rejected), or null (unrated)",
"maximum": 5,
"minimum": 1,
"minimum": -1,
"nullable": true,
"type": "integer",
"x-immich-history": [
@@ -26345,15 +26439,10 @@
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
},
{
"version": "v3",
"state": "Updated",
"description": "Using -1 as a rating is no longer valid."
"description": "Using 0 as a rating is no longer valid."
}
],
"x-immich-state": "Stable"
+10 -3
View File
@@ -2,17 +2,24 @@
"name": "immich-monorepo",
"version": "2.7.5",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
"scripts": {
"format": "prettier --cache --check i18n/",
"format:fix": "prettier --cache --write --list-different i18n"
"format:fix": "prettier --cache --write --list-different i18n",
"test": "vitest",
"release": "./misc/release/pump-version.sh",
"pump": "node ./misc/release/pump-wrapper.js"
},
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800",
"packageManager": "pnpm@11.4.0",
"engines": {
"pnpm": ">=10.0.0"
},
"devDependencies": {
"@types/node": "^24.12.4",
"prettier": "^3.8.3",
"prettier-plugin-sort-json": "^4.2.0"
"prettier-plugin-sort-json": "^4.2.0",
"semver": "^7.8.1",
"vitest": "^4.1.8"
}
}
+5 -2
View File
@@ -1,6 +1,9 @@
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25
WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./
COPY ./packages ./packages/
WORKDIR /usr/src/app/packages/e2e-auth-server
RUN corepack enable
ADD package.json *.ts ./
RUN pnpm install
RUN pnpm install --frozen-lockfile
EXPOSE 2286
CMD ["pnpm", "run", "start"]
+1 -1
View File
@@ -13,5 +13,5 @@
"oidc-provider": "^9.0.0",
"tsx": "^4.20.6"
},
"packageManager": "pnpm@10.33.4"
"packageManager": "pnpm@11.4.0"
}
+76
View File
@@ -55,6 +55,26 @@
}
],
"uiHints": ["SmartAlbum"]
},
{
"name": "location-smart-album",
"title": "Location-based album",
"description": "Automatically add assets taken in a specific location to an album",
"trigger": "AssetMetadataExtraction",
"steps": [
{
"method": "immich-plugin-core#assetLocationFilter",
"config": { "region": { "city": "Vancouver", "state": "British Columbia", "country": "Canada" } }
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumName": "Vancouver photos & videos",
"albumIds": []
}
}
],
"uiHints": ["SmartAlbum"]
}
],
"methods": [
@@ -107,6 +127,62 @@
},
"uiHints": ["Filter"]
},
{
"name": "assetLocationFilter",
"title": "Filter assets by geolocation",
"description": "Filter assets by where they were taken",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"region": {
"type": "object",
"title": "Region",
"description": "Filter by region name",
"properties": {
"country": {
"type": "string",
"title": "Country",
"description": "Exact name of the country the asset must be taken in"
},
"state": {
"type": "string",
"title": "State/province",
"description": "Exact name of the state/province the asset must be taken in"
},
"city": {
"type": "string",
"title": "City",
"description": "Exact name of the city the asset must be taken in"
}
}
},
"coordinate": {
"type": "object",
"title": "Coordinate",
"description": "Filter by distance to a coordinate",
"properties": {
"latitude": {
"type": "string",
"title": "Latitude",
"description": "GPS latitude of a coordinate which the asset must be close to"
},
"longitude": {
"type": "string",
"title": "Longitude",
"description": "GPS longitude of a coordinate which the asset must be close to"
},
"radius": {
"type": "number",
"title": "Maximum distance",
"description": "How close in kilometres the asset must be to the given point"
}
}
}
}
},
"uiHints": ["Filter"]
},
{
"name": "filterFileType",
"title": "Filter by file type",
+1
View File
@@ -13,6 +13,7 @@ declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
export function assetLocationFilter(): I32;
// updates
export function assetFavorite(): I32;
+45
View File
@@ -50,6 +50,51 @@ export const assetMissingTimeZoneFilter = () => {
});
};
export const assetLocationFilter = () => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
+1 -1
View File
@@ -24,7 +24,7 @@
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"packageManager": "pnpm@10.33.4",
"packageManager": "pnpm@11.4.0",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
+2 -2
View File
@@ -672,7 +672,7 @@ export type AssetBulkUpdateDto = {
latitude?: number;
/** Longitude coordinate */
longitude?: number;
/** Rating in range [1-5], or null for unrated */
/** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */
rating?: number | null;
/** Time zone (IANA timezone) */
timeZone?: string;
@@ -919,7 +919,7 @@ export type UpdateAssetDto = {
livePhotoVideoId?: string | null;
/** Longitude coordinate */
longitude?: number;
/** Rating in range [1-5], or null for unrated */
/** Rating in range [1-5] (starred), -1 (rejected), or null (unrated) */
rating?: number | null;
visibility?: AssetVisibility;
};
+698 -646
View File
File diff suppressed because it is too large Load Diff
+19 -22
View File
@@ -8,31 +8,28 @@ packages:
- web
- .github
- packages/*
ignoredBuiltDependencies:
- '@nestjs/core'
- '@parcel/watcher'
- '@scarf/scarf'
- '@swc/core'
- canvas
- core-js
- core-js-pure
- cpu-features
- es5-ext
- esbuild
- msgpackr-extract
- postman-code-generators
- protobufjs
- ssh2
- utimes
onlyBuiltDependencies:
- sharp
- '@tailwindcss/oxide'
- bcrypt
allowBuilds:
'@nestjs/core': false
'@parcel/watcher': false
'@scarf/scarf': false
'@swc/core': false
bcrypt: true
canvas: false
core-js: false
cpu-features: false
es5-ext: false
esbuild: false
msgpackr-extract: false
protobufjs: false
sharp: true
ssh2: false
utimes: false
'@tailwindcss/oxide': true
core-js-pure: false
postman-code-generators: false
overrides:
canvas: 3.2.3
sharp: ^0.34.5
# pending docusaurus 3.10.1
webpackbar: ^7.0.0
packageExtensions:
nestjs-kysely:
dependencies:
+4 -4
View File
@@ -20,8 +20,8 @@ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --prod --no-optional deploy /output/server-pruned
FROM builder AS web
@@ -37,7 +37,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web --frozen-lockfile --force install && \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \
pnpm --filter @immich/sdk --filter immich-web build
FROM builder AS cli
@@ -48,7 +48,7 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install && \
pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && \
pnpm --filter @immich/sdk --filter @immich/cli build && \
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
+18 -3
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { ApiKeyCreateDto, ApiKeyCreateResponseDto, ApiKeyResponseDto, ApiKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -62,7 +62,11 @@ export class ApiKeyController {
@Endpoint({
summary: 'Update an API key',
description: 'Updates the name and permissions of an API key by its ID. The current user must own this API key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateApiKey' }),
})
updateApiKey(
@Auth() auth: AuthDto,
@@ -72,6 +76,17 @@ export class ApiKeyController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.ApiKeyUpdate })
updateApiKeyV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: ApiKeyUpdateDto,
): Promise<ApiKeyResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.ApiKeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@@ -240,7 +240,16 @@ describe(AssetController.name, () => {
for (const [test, errors] of [
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=1' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
[
{ rating: 0 },
[
{
path: ['rating'],
message: 'Rating must be -1 (rejected), 15 (starred), or null (unrated); 0 is not valid',
},
],
],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
+31 -4
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
@@ -59,12 +59,24 @@ export class AssetController {
@Endpoint({
summary: 'Update assets',
description: 'Updates multiple assets at the same time.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateAssets' }),
})
updateAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Patch()
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateAssetsV3(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@@ -131,7 +143,11 @@ export class AssetController {
@Endpoint({
summary: 'Update an asset',
description: 'Update information of a specific asset.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateAsset' }),
})
updateAsset(
@Auth() auth: AuthDto,
@@ -141,6 +157,17 @@ export class AssetController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.AssetUpdate })
updateAssetV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateAssetDto,
): Promise<AssetResponseDto> {
return this.service.update(auth, id, dto);
}
@Get(':id/metadata')
@Authenticated({ permission: Permission.AssetRead })
@Endpoint({
+14 -3
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
CreateLibraryDto,
@@ -57,12 +57,23 @@ export class LibraryController {
@Endpoint({
summary: 'Update a library',
description: 'Update an existing external library.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateLibrary' }),
})
updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
return this.service.update(id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.LibraryUpdate, admin: true })
updateLibraryV3(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
return this.service.update(id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.LibraryDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
+18 -3
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -71,7 +71,11 @@ export class MemoryController {
@Endpoint({
summary: 'Update a memory',
description: 'Update an existing memory by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateMemory' }),
})
updateMemory(
@Auth() auth: AuthDto,
@@ -81,6 +85,17 @@ export class MemoryController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.MemoryUpdate })
updateMemoryV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: MemoryUpdateDto,
): Promise<MemoryResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.MemoryDelete })
@HttpCode(HttpStatus.NO_CONTENT)
+18 -2
View File
@@ -7,12 +7,13 @@ import {
HttpStatus,
Next,
Param,
Patch,
Post,
Put,
Query,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
@@ -106,7 +107,11 @@ export class PersonController {
@Endpoint({
summary: 'Update person',
description: 'Update an individual person.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updatePerson' }),
})
updatePerson(
@Auth() auth: AuthDto,
@@ -116,6 +121,17 @@ export class PersonController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.PersonUpdate })
updatePersonV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: PersonUpdateDto,
): Promise<PersonResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
+18 -3
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, SessionUpdateDto } from 'src/dtos/session.dto';
@@ -52,7 +52,11 @@ export class SessionController {
@Endpoint({
summary: 'Update a session',
description: 'Update a specific session identified by id.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateSession' }),
})
updateSession(
@Auth() auth: AuthDto,
@@ -62,6 +66,17 @@ export class SessionController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.SessionUpdate })
updateSessionV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: SessionUpdateDto,
): Promise<SessionResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.SessionDelete })
@HttpCode(HttpStatus.NO_CONTENT)
+18 -3
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -65,7 +65,11 @@ export class StackController {
@Endpoint({
summary: 'Update a stack',
description: 'Update an existing stack by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateStack' }),
})
updateStack(
@Auth() auth: AuthDto,
@@ -75,6 +79,17 @@ export class StackController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.StackUpdate })
updateStackV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: StackUpdateDto,
): Promise<StackResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.StackDelete })
@HttpCode(HttpStatus.NO_CONTENT)
+14 -3
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -81,12 +81,23 @@ export class TagController {
@Endpoint({
summary: 'Update a tag',
description: 'Update an existing tag identified by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder().added('v1').beta('v1').stable('v2').deprecated('v3', { replacementId: 'updateTag' }),
})
updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise<TagResponseDto> {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.TagUpdate })
updateTagV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: TagUpdateDto,
): Promise<TagResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.TagDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -61,7 +61,11 @@ export class UserAdminController {
@Endpoint({
summary: 'Update a user',
description: 'Update an existing user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateUserAdmin' }),
})
updateUserAdmin(
@Auth() auth: AuthDto,
@@ -71,6 +75,17 @@ export class UserAdminController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
updateUserAdminV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UserAdminUpdateDto,
): Promise<UserAdminResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@Endpoint({
@@ -143,7 +158,11 @@ export class UserAdminController {
@Endpoint({
summary: 'Update user preferences',
description: 'Update the preferences of a specific user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateUserPreferencesAdmin' }),
})
updateUserPreferencesAdmin(
@Auth() auth: AuthDto,
@@ -153,6 +172,17 @@ export class UserAdminController {
return this.service.updatePreferences(auth, id, dto);
}
@Patch(':id/preferences')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
updateUserPreferencesAdminV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UserPreferencesUpdateDto,
): Promise<UserPreferencesResponseDto> {
return this.service.updatePreferences(auth, id, dto);
}
@Post(':id/restore')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@HttpCode(HttpStatus.OK)
+29 -3
View File
@@ -7,6 +7,7 @@ import {
HttpStatus,
Next,
Param,
Patch,
Post,
Put,
Query,
@@ -14,7 +15,7 @@ import {
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiConsumes, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -78,12 +79,23 @@ export class UserController {
@Endpoint({
summary: 'Update current user',
description: 'Update the current user making the API request.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateMyUser' }),
})
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
return this.service.updateMe(auth, dto);
}
@Patch('me')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.UserUpdate })
updateMyUserV3(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
return this.service.updateMe(auth, dto);
}
@Get('me/preferences')
@Authenticated({ permission: Permission.UserPreferenceRead })
@Endpoint({
@@ -100,7 +112,11 @@ export class UserController {
@Endpoint({
summary: 'Update my preferences',
description: 'Update the preferences of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3', { replacementId: 'updateMyPreferences' }),
})
updateMyPreferences(
@Auth() auth: AuthDto,
@@ -109,6 +125,16 @@ export class UserController {
return this.service.updateMyPreferences(auth, dto);
}
@Patch('me/preferences')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.UserPreferenceUpdate })
updateMyPreferencesV3(
@Auth() auth: AuthDto,
@Body() dto: UserPreferencesUpdateDto,
): Promise<UserPreferencesResponseDto> {
return this.service.updateMyPreferences(auth, dto);
}
@Get('me/license')
@Authenticated({ permission: Permission.UserLicenseRead })
@Endpoint({
@@ -95,15 +95,15 @@ describe(WorkflowController.name, () => {
});
});
describe('PUT /workflows/:id', () => {
describe('PATCH /workflows/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/workflows/${factory.uuid()}`).send({});
await request(ctx.getHttpServer()).patch(`/workflows/${factory.uuid()}`).send({});
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/workflows/invalid`)
.patch(`/workflows/invalid`)
.set('Authorization', `Bearer token`)
.send({});
expect(status).toBe(400);
+14 -3
View File
@@ -1,5 +1,5 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -81,7 +81,7 @@ export class WorkflowController {
summary: 'Update a workflow',
description:
'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.',
history: HistoryBuilder.v3(),
history: new HistoryBuilder().added('v3.0.0').deprecated('v3', { replacementId: 'updateWorkflow' }),
})
updateWorkflow(
@Auth() auth: AuthDto,
@@ -91,6 +91,17 @@ export class WorkflowController {
return this.service.update(auth, id, dto);
}
@Patch(':id')
@ApiExcludeEndpoint()
@Authenticated({ permission: Permission.WorkflowUpdate })
updateWorkflowV3(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: WorkflowUpdateDto,
): Promise<WorkflowResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.WorkflowDelete })
@HttpCode(HttpStatus.NO_CONTENT)
+21
View File
@@ -392,6 +392,27 @@ export const columns = {
'asset.height',
'asset.isEdited',
],
syncAlbumAsset: [
'asset.id',
'asset.ownerId',
'asset.originalFileName',
'asset.thumbhash',
'asset.checksum',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.createdAt',
'asset.localDateTime',
'asset.type',
'asset.deletedAt',
'asset.visibility',
'asset.duration',
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
'asset.isEdited',
],
syncPartnerAsset: [
'asset.id',
'asset.ownerId',
+6 -4
View File
@@ -15,16 +15,18 @@ const UpdateAssetBaseSchema = z
longitude: longitudeSchema.optional().describe('Longitude coordinate'),
rating: z
.int()
.min(1)
.min(-1)
.max(5)
.nullish()
.describe('Rating in range [1-5], or null for unrated')
.refine((v) => v !== 0, {
error: 'Rating must be -1 (rejected), 15 (starred), or null (unrated); 0 is not valid',
})
.describe('Rating in range [1-5] (starred), -1 (rejected), or null (unrated)')
.meta({
...new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
.updated('v3', 'Using -1 as a rating is no longer valid.')
.updated('v3', 'Using 0 as a rating is no longer valid.')
.getExtensions(),
}),
description: z.string().optional().describe('Asset description'),
+24 -15
View File
@@ -69,7 +69,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -78,15 +77,19 @@ select
"asset"."width",
"asset"."height",
"asset"."isEdited",
case
when "asset"."ownerId" = $1 then "asset"."isFavorite"
else $2
end as "isFavorite",
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."albumId" = $4
"album_asset"."updateId" < $3
and "album_asset"."updateId" <= $4
and "album_asset"."updateId" >= $5
and "album_asset"."albumId" = $6
order by
"album_asset"."updateId" asc
@@ -103,7 +106,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -112,16 +114,20 @@ select
"asset"."width",
"asset"."height",
"asset"."isEdited",
case
when "asset"."ownerId" = $1 then "asset"."isFavorite"
else $2
end as "isFavorite",
"asset"."updateId"
from
"asset" as "asset"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
and "album_asset"."updateId" <= $3
and "album_user"."userId" = $4
"asset"."updateId" < $3
and "asset"."updateId" > $4
and "album_asset"."updateId" <= $5
and "album_user"."userId" = $6
order by
"asset"."updateId" asc
@@ -139,7 +145,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -147,15 +152,19 @@ select
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."isEdited"
"asset"."isEdited",
case
when "asset"."ownerId" = $1 then "asset"."isFavorite"
else $2
end as "isFavorite"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and "album_user"."userId" = $3
"album_asset"."updateId" < $3
and "album_asset"."updateId" > $4
and "album_user"."userId" = $5
order by
"album_asset"."updateId" asc
+39 -2
View File
@@ -5,7 +5,7 @@ import { JobsOptions, Queue, Worker } from 'bullmq';
import { setTimeout } from 'node:timers/promises';
import { JobConfig } from 'src/decorators';
import { QueueJobResponseDto, QueueJobSearchDto } from 'src/dtos/queue.dto';
import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueJobStatus, QueueName } from 'src/enum';
import { ImmichWorker, JobName, JobStatus, MetadataKey, QueueCleanType, QueueJobStatus, QueueName } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -19,10 +19,14 @@ type JobMapItem = {
label: string;
};
const WORKER_WATCH_INTERVAL_MS = 30_000;
@Injectable()
export class JobRepository {
private workers: Partial<Record<QueueName, Worker>> = {};
private handlers: Partial<Record<JobName, JobMapItem>> = {};
private workerWatcher?: ReturnType<typeof setInterval>;
private microservicesPresent = true;
constructor(
private moduleRef: ModuleRef,
@@ -90,11 +94,44 @@ export class JobRepository {
this.workers[queueName] = new Worker(
queueName,
(job) => this.eventRepository.emit('JobRun', queueName, job as JobItem),
{ ...bull.config, concurrency: 1 },
{ ...bull.config, concurrency: 1, name: ImmichWorker.Microservices },
);
}
}
watchWorkers() {
this.workerWatcher ??= setInterval(() => void this.checkWorkers(), WORKER_WATCH_INTERVAL_MS);
}
teardown() {
if (this.workerWatcher) {
clearInterval(this.workerWatcher);
this.workerWatcher = undefined;
}
}
private async checkWorkers() {
let present: boolean;
try {
const suffix = `:w:${ImmichWorker.Microservices}`;
const workers = await this.getQueue(QueueName.BackgroundTask).getWorkers();
present = workers.some((worker) => worker.rawname?.endsWith(suffix));
} catch {
return;
}
if (this.microservicesPresent !== present) {
if (present) {
this.logger.log('Microservices worker connected.');
} else {
this.logger.warn(
'No microservices worker is connected. Background jobs will not be processed until one is running.',
);
}
}
this.microservicesPresent = present;
}
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
if (!item) {
@@ -84,7 +84,7 @@ export class MetadataRepository {
inferTimezoneFromDatestamps: true,
inferTimezoneFromTimeStamp: true,
useMWG: true,
numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'],
numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize', 'Rotation'],
/* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
geolocation: true,
+32 -5
View File
@@ -195,11 +195,20 @@ class AlbumSync extends BaseSync {
}
class AlbumAssetSync extends BaseSync {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, albumId: string) {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, albumId: string, userId: string) {
return this.backfillQuery('album_asset', options)
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.select(columns.syncAlbumAsset)
.select((eb) =>
eb
.case()
.when('asset.ownerId', '=', userId)
.then(eb.ref('asset.isFavorite'))
.else(eb.val(false))
.end()
.as('isFavorite'),
)
.select('album_asset.updateId')
.where('album_asset.albumId', '=', albumId)
.stream();
@@ -210,7 +219,16 @@ class AlbumAssetSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('asset', options)
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.select(columns.syncAsset)
.select(columns.syncAlbumAsset)
.select((eb) =>
eb
.case()
.when('asset.ownerId', '=', userId)
.then(eb.ref('asset.isFavorite'))
.else(eb.val(false))
.end()
.as('isFavorite'),
)
.select('asset.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
@@ -224,7 +242,16 @@ class AlbumAssetSync extends BaseSync {
return this.upsertQuery('album_asset', options)
.select('album_asset.updateId')
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.select(columns.syncAlbumAsset)
.select((eb) =>
eb
.case()
.when('asset.ownerId', '=', userId)
.then(eb.ref('asset.isFavorite'))
.else(eb.val(false))
.end()
.as('isFavorite'),
)
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
@@ -1,7 +1,5 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db);
export async function up(): Promise<void> {
// await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = -1;`.execute(db);
}
export async function down(): Promise<void> {
+19 -11
View File
@@ -348,17 +348,25 @@ describe(AlbumService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId, 'asset-2']), false);
});
it('should throw an error if the userId is the ownerId', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.user.get.mockResolvedValue(owner);
await expect(
sut.create(AuthFactory.create(owner), {
albumName: 'Empty album',
albumUsers: [{ userId: owner.id, role: AlbumUserRole.Editor }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.create).not.toHaveBeenCalled();
it('should deduplicate owner from albumUsers on create', async () => {
const auth = AuthFactory.create();
const album = AlbumFactory.from().build();
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.getMetadata.mockResolvedValue([]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
await sut.create(auth, {
albumName: 'Empty album',
albumUsers: [{ userId: auth.user.id, role: AlbumUserRole.Editor }],
});
expect(mocks.user.get).not.toHaveBeenCalled();
expect(mocks.album.create).toHaveBeenCalledWith(
expect.objectContaining({ albumName: 'Empty album' }),
[],
[{ userId: auth.user.id, role: AlbumUserRole.Owner }],
auth.user.id,
);
});
});
+1 -5
View File
@@ -98,7 +98,7 @@ export class AlbumService extends BaseService {
}
async create(auth: AuthDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
const albumUsers = dto.albumUsers || [];
const albumUsers = (dto.albumUsers || []).filter(({ userId }) => userId !== auth.user.id);
for (const { userId } of albumUsers) {
const exists = await this.userRepository.get(userId, {});
@@ -106,10 +106,6 @@ export class AlbumService extends BaseService {
this.logger.debug('Album creation failed: user not found');
throw new BadRequestException('Invalid user');
}
if (userId == auth.user.id) {
throw new BadRequestException('Cannot share album with owner');
}
}
const allowedAssetIdsSet = await this.checkAccess({
+31
View File
@@ -616,6 +616,17 @@ export class MetadataService extends BaseService {
// never use duration from sidecar
delete sidecarTags?.Duration;
// don't use Exif Orientation for HEIF based images, it's usually missing or invalid.
// prefer irot (ExifTool QuickTime:Rotation) mapped to ExifOrientation.
if (mimeTypes.isHeifImage(asset.originalPath)) {
const orientation = this.getHeifOrientation(mediaTags);
if (orientation === null) {
delete mediaTags.Orientation;
} else {
mediaTags.Orientation = orientation;
}
}
return {
tags: { ...mediaTags, ...videoResult?.tags, ...sidecarTags },
audio: videoResult?.audio,
@@ -1110,4 +1121,24 @@ export class MetadataService extends BaseService {
return { tags, audio, video, packets, format };
}
private getHeifOrientation(exifTags: ImmichTags): ExifOrientation | null {
// https://exiftool.org/TagNames/QuickTime.html#ItemPropCont
const rotation = typeof exifTags.Rotation === 'number' ? exifTags.Rotation : undefined;
switch (rotation) {
case 0: {
return ExifOrientation.Horizontal;
}
case 1: {
return ExifOrientation.Rotate270CW;
}
case 2: {
return ExifOrientation.Rotate180;
}
case 3: {
return ExifOrientation.Rotate90CW;
}
}
return null;
}
}
+7
View File
@@ -80,9 +80,16 @@ export class QueueService extends BaseService {
this.jobRepository.setup(this.services);
if (this.worker === ImmichWorker.Microservices) {
this.jobRepository.startWorkers();
} else if (this.worker === ImmichWorker.Api) {
this.jobRepository.watchWorkers();
}
}
@OnEvent({ name: 'AppShutdown' })
onShutdown() {
this.jobRepository.teardown();
}
private updateConcurrency(config: SystemConfig) {
this.logger.debug(`Updating queue concurrency settings`);
for (const queueName of Object.values(QueueName)) {
+1
View File
@@ -545,6 +545,7 @@ export class SyncService extends BaseService {
const backfill = this.syncRepository.albumAsset.getBackfill(
{ ...options, afterUpdateId: startId, beforeUpdateId: endId },
album.id,
options.userId,
);
for await (const { updateId, ...data } of backfill) {
+6
View File
@@ -74,6 +74,11 @@ const possiblyAnimatedImage: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => possiblyAnimatedImageExtensions.has(key)),
);
const heifImageExtensions = new Set(['.avif', '.heic', '.heif', '.hif']);
const heifImage: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => heifImageExtensions.has(key)),
);
const extensionOverrides: Record<string, string> = {
'image/jpeg': '.jpg',
};
@@ -147,6 +152,7 @@ export const mimeTypes = {
isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
isImage: (filename: string) => isType(filename, image),
isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage),
isHeifImage: (filename: string) => isType(filename, heifImage),
isPossiblyAnimatedImage: (filename: string) => isType(filename, possiblyAnimatedImage),
isProfile: (filename: string) => isType(filename, profile),
isSidecar: (filename: string) => isType(filename, sidecar),
@@ -270,7 +270,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
it('should sync asset updates for an album shared with you', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: false });
const { asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'before' });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await wait(2);
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
@@ -281,9 +281,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset.id,
}),
data: expect.objectContaining({ id: asset.id, originalFileName: 'before' }),
type: SyncEntityType.AlbumAssetCreateV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@@ -291,24 +289,56 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
await ctx.syncAckAll(auth, response);
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.update({
id: asset.id,
isFavorite: true,
});
await assetRepository.update({ id: asset.id, originalFileName: 'after' });
const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
expect(updateResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset.id,
isFavorite: true,
}),
data: expect.objectContaining({ id: asset.id, originalFileName: 'after' }),
type: SyncEntityType.AlbumAssetUpdateV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
it('should hide isFavorite for album assets owned by another user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
expect(response).toEqual([
updateSyncAck,
{
ack: expect.any(String),
data: expect.objectContaining({ id: asset.id, isFavorite: false }),
type: SyncEntityType.AlbumAssetCreateV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
it('should sync isFavorite for album assets owned by the requesting user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Viewer });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]);
expect(response).toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({ id: asset.id, isFavorite: true }),
type: SyncEntityType.AlbumAssetCreateV2,
}),
]),
);
});
});
@@ -278,4 +278,21 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
await ctx.syncAckAll(auth, newResponse);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should hide isFavorite for partner assets', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: true });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: asset.id, isFavorite: false }),
type: SyncEntityType.PartnerAssetV2,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
});
});
@@ -332,4 +332,75 @@ describe('core plugin', () => {
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
});
describe('assetLocationFilter', () => {
it('should favorite an asset within a given radius', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, latitude: 49.273_353_221_145_36, longitude: -123.103_871_440_787_64 });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetLocationFilter',
config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 2 } },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
it('should not favorite asset outside a given radius', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, latitude: 49.261_266_052_570_35, longitude: -123.248_959_390_781_96 });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetLocationFilter',
config: { coordinate: { latitude: 49.288_821_679_949_29, longitude: -123.111_153_098_813_7, radius: 10 } },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
it('should favorite asset by location name', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, city: 'Vancouver' });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetMetadataExtraction,
steps: [
{
method: 'immich-plugin-core#assetLocationFilter',
config: { region: { city: 'Vancouver' } },
},
{
method: 'immich-plugin-core#assetFavorite',
},
],
});
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
});
});

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