mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
Merge upstream
This commit is contained in:
commit
4f2c08525f
113 changed files with 1824 additions and 395 deletions
24
.github/workflows/build_push_docker_latest.yml
vendored
24
.github/workflows/build_push_docker_latest.yml
vendored
|
@ -17,17 +17,17 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and push Immich Mono Repo
|
- name: Build and push Immich Mono Repo
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
|
@ -45,17 +45,17 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Machine Learning
|
- name: Build and Push Machine Learning
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./machine-learning
|
context: ./machine-learning
|
||||||
file: ./machine-learning/Dockerfile
|
file: ./machine-learning/Dockerfile
|
||||||
|
@ -72,17 +72,17 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Web
|
- name: Build and Push Web
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./web
|
context: ./web
|
||||||
file: ./web/Dockerfile
|
file: ./web/Dockerfile
|
||||||
|
@ -100,17 +100,17 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Proxy
|
- name: Build and Push Proxy
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
file: ./nginx/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
|
|
30
.github/workflows/build_push_docker_staging.yml
vendored
30
.github/workflows/build_push_docker_staging.yml
vendored
|
@ -2,8 +2,6 @@ name: Build and Push Docker Image - Staging
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
@ -19,10 +17,10 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
@ -30,7 +28,7 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and push Immich Mono Repo
|
- name: Build and push Immich Mono Repo
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
|
@ -38,6 +36,7 @@ jobs:
|
||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-server:staging
|
altran1502/immich-server:staging
|
||||||
|
altran1502/immich-server:${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
build_and_push_machine_learning_staging:
|
build_and_push_machine_learning_staging:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -48,10 +47,10 @@ jobs:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
@ -59,7 +58,7 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Machine Learning
|
- name: Build and Push Machine Learning
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./machine-learning
|
context: ./machine-learning
|
||||||
file: ./machine-learning/Dockerfile
|
file: ./machine-learning/Dockerfile
|
||||||
|
@ -67,6 +66,7 @@ jobs:
|
||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-machine-learning:staging
|
altran1502/immich-machine-learning:staging
|
||||||
|
altran1502/immich-machine-learning:${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
build_and_push_web_staging:
|
build_and_push_web_staging:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -76,10 +76,10 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
@ -87,7 +87,7 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Web
|
- name: Build and Push Web
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./web
|
context: ./web
|
||||||
file: ./web/Dockerfile
|
file: ./web/Dockerfile
|
||||||
|
@ -96,6 +96,7 @@ jobs:
|
||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-web:staging
|
altran1502/immich-web:staging
|
||||||
|
altran1502/immich-web:${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
build_and_push_nginx_staging:
|
build_and_push_nginx_staging:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
@ -105,10 +106,10 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: ${{ github.repository == 'immich-app/immich' }}
|
if: ${{ github.repository == 'immich-app/immich' }}
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
@ -116,7 +117,7 @@ jobs:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Proxy
|
- name: Build and Push Proxy
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
file: ./nginx/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
|
@ -124,3 +125,4 @@ jobs:
|
||||||
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
push: ${{ github.event_name == 'pull_request' && github.repository == 'immich-app/immich' }}
|
||||||
tags: |
|
tags: |
|
||||||
altran1502/immich-proxy:staging
|
altran1502/immich-proxy:staging
|
||||||
|
altran1502/immich-proxy:${{ github.event.pull_request.number }}
|
||||||
|
|
24
.github/workflows/build_push_server_release.yml
vendored
24
.github/workflows/build_push_server_release.yml
vendored
|
@ -22,11 +22,11 @@ jobs:
|
||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
@ -35,7 +35,7 @@ jobs:
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-server release
|
- name: Build and push immich-server release
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./server
|
context: ./server
|
||||||
file: ./server/Dockerfile
|
file: ./server/Dockerfile
|
||||||
|
@ -58,17 +58,17 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fallback: latest
|
fallback: latest
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and Push Machine Learning
|
- name: Build and Push Machine Learning
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./machine-learning
|
context: ./machine-learning
|
||||||
file: ./machine-learning/Dockerfile
|
file: ./machine-learning/Dockerfile
|
||||||
|
@ -94,11 +94,11 @@ jobs:
|
||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
@ -107,7 +107,7 @@ jobs:
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-web release
|
- name: Build and push immich-web release
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./web
|
context: ./web
|
||||||
file: ./web/Dockerfile
|
file: ./web/Dockerfile
|
||||||
|
@ -134,11 +134,11 @@ jobs:
|
||||||
fallback: latest
|
fallback: latest
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.0.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2.0.0
|
uses: docker/setup-buildx-action@v2.1.0
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
@ -147,7 +147,7 @@ jobs:
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push immich-proxy release
|
- name: Build and push immich-proxy release
|
||||||
uses: docker/build-push-action@v3.1.1
|
uses: docker/build-push-action@v3.2.0
|
||||||
with:
|
with:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
file: ./nginx/Dockerfile
|
file: ./nginx/Dockerfile
|
||||||
|
|
83
.github/workflows/openapi-generator.yml
vendored
Normal file
83
.github/workflows/openapi-generator.yml
vendored
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
name: Generate OpenAPI SDK
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generate-typescript-axios:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: OpenAPI Generator
|
||||||
|
steps:
|
||||||
|
# Checkout your code
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GH_TOKEN }}
|
||||||
|
|
||||||
|
# Use the action to generate a client package
|
||||||
|
# This uses the default path for the openapi document and thus assumes there is an openapi.json in the current workspace.
|
||||||
|
- name: Generate Typescript Axios Client
|
||||||
|
uses: openapi-generators/openapitools-generator-action@v1
|
||||||
|
with:
|
||||||
|
generator: typescript-axios
|
||||||
|
generator-tag: v6.2.0
|
||||||
|
openapi-file: server/immich-openapi-specs.json
|
||||||
|
|
||||||
|
# Do something with the generated client (likely publishing it somewhere)
|
||||||
|
- name: Push to typescript repo
|
||||||
|
run: |
|
||||||
|
git config --global init.defaultBranch main
|
||||||
|
git config --global pull.rebase false
|
||||||
|
git config --global user.email "alex.tran1502@gmail.com"
|
||||||
|
git config --global user.name "Alex Tran"
|
||||||
|
cd typescript-axios-client
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Update SDK"
|
||||||
|
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-typescript-axios.git
|
||||||
|
git pull origin main --allow-unrelated-histories
|
||||||
|
git push origin main 2>&1 | grep -v 'To https'
|
||||||
|
|
||||||
|
- name: Generate Dart SDK
|
||||||
|
uses: openapi-generators/openapitools-generator-action@v1
|
||||||
|
with:
|
||||||
|
generator: dart
|
||||||
|
generator-tag: v6.2.0
|
||||||
|
openapi-file: server/immich-openapi-specs.json
|
||||||
|
|
||||||
|
- name: Push to Dart repo
|
||||||
|
run: |
|
||||||
|
git config --global init.defaultBranch main
|
||||||
|
git config --global pull.rebase false
|
||||||
|
git config --global user.email "alex.tran1502@gmail.com"
|
||||||
|
git config --global user.name "Alex Tran"
|
||||||
|
cd dart-client
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Update SDK"
|
||||||
|
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-dart.git
|
||||||
|
git pull origin main --allow-unrelated-histories
|
||||||
|
git push origin main 2>&1 | grep -v 'To https'
|
||||||
|
|
||||||
|
- name: Generate Rust SDK
|
||||||
|
uses: openapi-generators/openapitools-generator-action@v1
|
||||||
|
with:
|
||||||
|
generator: rust
|
||||||
|
generator-tag: v6.2.0
|
||||||
|
openapi-file: server/immich-openapi-specs.json
|
||||||
|
|
||||||
|
- name: Push to Rust repo
|
||||||
|
run: |
|
||||||
|
git config --global init.defaultBranch main
|
||||||
|
git config --global pull.rebase false
|
||||||
|
git config --global user.email "alex.tran1502@gmail.com"
|
||||||
|
git config --global user.name "Alex Tran"
|
||||||
|
cd rust-client
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Update SDK"
|
||||||
|
git remote add origin https://immich-app:"${{ secrets.GH_TOKEN }}"@github.com/immich-app/immich-sdk-rust.git
|
||||||
|
git pull origin main --allow-unrelated-histories
|
||||||
|
git push origin main 2>&1 | grep -v 'To https'
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Run Immich Server 2E2 Test
|
- name: Run Immich Server E2E Test
|
||||||
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
|
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
|
||||||
|
|
||||||
server-unit-tests:
|
server-unit-tests:
|
||||||
|
|
15
README.md
15
README.md
|
@ -46,13 +46,14 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Update](#update)
|
- [Update](#update)
|
||||||
- [Mobile App](#mobile-app)
|
- [Mobile App](#mobile-app)
|
||||||
|
- [App Beta Invitation links](#App-Beta-release-channel)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Support](#support)
|
- [Support](#support)
|
||||||
- [Known Issues](#known-issues)
|
- [Known Issues](#known-issues)
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development, there will be continuous functions, features and api changes.
|
> ⚠️ WARNING: **NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS**. This project is under heavy development. There will be continuous functions, features and api changes.
|
||||||
|
|
||||||
| Features | Mobile | Web |
|
| Features | Mobile | Web |
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
|
@ -117,11 +118,11 @@ There are several services that compose Immich:
|
||||||
|
|
||||||
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
|
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
|
||||||
|
|
||||||
## Testing One-step installation (not recommended for production)
|
## Testing one-step installation (not recommended for production)
|
||||||
|
|
||||||
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
> ⚠️ *This installation method is for evaluating Immich before further customization to meet the users' needs.*
|
||||||
|
|
||||||
*Applicable system: Ubuntu, Debian, MacOS*
|
*Applicable operating systems: Ubuntu, Debian, MacOS*
|
||||||
|
|
||||||
- In the shell, from the directory of your choice, run the following command:
|
- In the shell, from the directory of your choice, run the following command:
|
||||||
|
|
||||||
|
@ -203,9 +204,13 @@ docker-compose pull && docker-compose up -d
|
||||||
| - | - | - |
|
| - | - | - |
|
||||||
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
|
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
|
||||||
|
|
||||||
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
|
> *The Play/App Store version might be lagging behind the latest release due to their review process.*
|
||||||
|
|
||||||
|
# App Beta release channel
|
||||||
|
|
||||||
|
You can opt-in to join app beta release channel by following the links below:
|
||||||
|
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||||
|
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
|
@ -38,7 +38,10 @@ LOG_LEVEL=simple
|
||||||
# JWT SECRET
|
# JWT SECRET
|
||||||
###################################################################################
|
###################################################################################
|
||||||
|
|
||||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
# This JWT_SECRET is used to sign the authentication keys for user login
|
||||||
|
# You should set it to a long randomly generated value
|
||||||
|
# You can use this command to generate one: openssl rand -base64 128
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
###################################################################################
|
###################################################################################
|
||||||
# Reverse Geocoding
|
# Reverse Geocoding
|
||||||
|
|
33
install.sh
33
install.sh
|
@ -18,33 +18,37 @@ get_release_version() {
|
||||||
create_immich_directory() {
|
create_immich_directory() {
|
||||||
echo "Creating Immich directory..."
|
echo "Creating Immich directory..."
|
||||||
mkdir -p ./immich-app/immich-data
|
mkdir -p ./immich-app/immich-data
|
||||||
|
cd ./immich-app
|
||||||
}
|
}
|
||||||
|
|
||||||
download_docker_compose_file() {
|
download_docker_compose_file() {
|
||||||
echo "Downloading docker-compose.yml..."
|
echo "Downloading docker-compose.yml..."
|
||||||
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
|
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./docker-compose.yml >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
download_dot_env_file() {
|
download_dot_env_file() {
|
||||||
echo "Downloading .env file..."
|
echo "Downloading .env file..."
|
||||||
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
|
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./.env >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
replace_env_value() {
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
sed -i '' "s|$1=.*|$1=$2|" ./.env
|
||||||
|
else
|
||||||
|
sed -i "s|$1=.*|$1=$2|" ./.env
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
populate_upload_location() {
|
populate_upload_location() {
|
||||||
echo "Populating default UPLOAD_LOCATION value..."
|
echo "Populating default UPLOAD_LOCATION value..."
|
||||||
|
upload_location=$(pwd)/immich-data
|
||||||
|
replace_env_value "UPLOAD_LOCATION" $upload_location
|
||||||
|
}
|
||||||
|
|
||||||
cd ./immich-app/immich-data
|
generate_jwt_secret() {
|
||||||
|
echo "Generating JWT_SECRET value..."
|
||||||
upload_location=$(pwd)
|
jwt_secret=$(openssl rand -base64 128)
|
||||||
|
replace_env_value "JWT_SECRET" $jwt_secret
|
||||||
# Replace value of UPLOAD_LOCATION in .env with upload_location path
|
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
||||||
sed -i '' "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
|
|
||||||
else
|
|
||||||
sed -i "s|UPLOAD_LOCATION=.*|UPLOAD_LOCATION=$upload_location|" ../.env
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start_docker_compose() {
|
start_docker_compose() {
|
||||||
|
@ -88,4 +92,5 @@ create_immich_directory
|
||||||
download_docker_compose_file
|
download_docker_compose_file
|
||||||
download_dot_env_file
|
download_dot_env_file
|
||||||
populate_upload_location
|
populate_upload_location
|
||||||
|
generate_jwt_secret
|
||||||
start_docker_compose
|
start_docker_compose
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="app.alextran.immich" xmlns:tools="http://schemas.android.com/tools">
|
||||||
<application android:label="Immich" android:name="${applicationName}" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
|
<application android:label="Immich" android:name=".ImmichApp" android:usesCleartextTraffic="true" android:icon="@mipmap/ic_launcher" android:requestLegacyExternalStorage="true">
|
||||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
|
@ -12,12 +12,15 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
<service android:name=".AppClearedService" android:stopWithTask="false" />
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data android:name="flutterEmbedding" android:value="2" />
|
<meta-data android:name="flutterEmbedding" android:value="2" />
|
||||||
|
<!-- Disables default WorkManager initialization to use our custom initialization -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
tools:node="remove">
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
package app.alextran.immich
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.IBinder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Catches the event when either the system or the user kills the app
|
|
||||||
* (does not apply on force close!)
|
|
||||||
*/
|
|
||||||
class AppClearedService() : Service() {
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
|
||||||
return START_NOT_STICKY;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent) {
|
|
||||||
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
|
|
||||||
stopSelf();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -10,7 +10,7 @@ import io.flutter.plugin.common.MethodChannel
|
||||||
* Android plugin for Dart `BackgroundService`
|
* Android plugin for Dart `BackgroundService`
|
||||||
*
|
*
|
||||||
* Receives messages/method calls from the foreground Dart side to manage
|
* Receives messages/method calls from the foreground Dart side to manage
|
||||||
* the background service, e.g. start (enqueue), stop (cancel)
|
* the background service, e.g. start (enqueue), stop (cancel)
|
||||||
*/
|
*/
|
||||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
|
@ -38,14 +38,15 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val ctx = context!!
|
val ctx = context!!
|
||||||
when(call.method) {
|
when (call.method) {
|
||||||
"enable" -> {
|
"enable" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
|
.putBoolean(ContentObserverWorker.SHARED_PREF_SERVICE_ENABLED, true)
|
||||||
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
|
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
|
||||||
.apply()
|
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
|
||||||
|
.apply()
|
||||||
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
|
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
@ -54,7 +55,7 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||||
val requireUnmeteredNetwork = args.get(0) as Boolean
|
val requireUnmeteredNetwork = args.get(0) as Boolean
|
||||||
val requireCharging = args.get(1) as Boolean
|
val requireCharging = args.get(1) as Boolean
|
||||||
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"disable" -> {
|
"disable" -> {
|
||||||
ContentObserverWorker.disable(ctx)
|
ContentObserverWorker.disable(ctx)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
@ -47,6 +48,8 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||||
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||||
private var timeBackupStarted: Long = 0L
|
private var timeBackupStarted: Long = 0L
|
||||||
|
private var notificationBuilder: NotificationCompat.Builder? = null
|
||||||
|
private var notificationDetailBuilder: NotificationCompat.Builder? = null
|
||||||
|
|
||||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||||
|
|
||||||
|
@ -61,16 +64,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||||
// Create a Notification channel if necessary
|
// Create a Notification channel if necessary
|
||||||
createChannel()
|
createChannel()
|
||||||
}
|
}
|
||||||
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
|
||||||
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
|
||||||
if (isIgnoringBatteryOptimizations) {
|
if (isIgnoringBatteryOptimizations) {
|
||||||
// normal background services can only up to 10 minutes
|
// normal background services can only up to 10 minutes
|
||||||
// foreground services are allowed to run indefinitely
|
// foreground services are allowed to run indefinitely
|
||||||
// requires battery optimizations to be disabled (either manually by the user
|
// requires battery optimizations to be disabled (either manually by the user
|
||||||
// or by the system learning that immich is important to the user)
|
// or by the system learning that immich is important to the user)
|
||||||
setForegroundAsync(createForegroundInfo(title))
|
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
} else {
|
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
||||||
showBackgroundInfo(title)
|
showInfo(getInfoBuilder(title, indeterminate=true).build())
|
||||||
}
|
}
|
||||||
engine = FlutterEngine(ctx)
|
engine = FlutterEngine(ctx)
|
||||||
|
|
||||||
|
@ -154,18 +155,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||||
}
|
}
|
||||||
"updateNotification" -> {
|
"updateNotification" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String?
|
||||||
val content = args.get(1) as String
|
val content = args.get(1) as String?
|
||||||
if (isIgnoringBatteryOptimizations) {
|
val progress = args.get(2) as Int
|
||||||
setForegroundAsync(createForegroundInfo(title, content))
|
val max = args.get(3) as Int
|
||||||
} else {
|
val indeterminate = args.get(4) as Boolean
|
||||||
showBackgroundInfo(title, content)
|
val isDetail = args.get(5) as Boolean
|
||||||
|
val onlyIfFG = args.get(6) as Boolean
|
||||||
|
if (!onlyIfFG || isIgnoringBatteryOptimizations) {
|
||||||
|
showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"showError" -> {
|
"showError" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String
|
||||||
val content = args.get(1) as String
|
val content = args.get(1) as String?
|
||||||
val individualTag = args.get(2) as String?
|
val individualTag = args.get(2) as String?
|
||||||
showError(title, content, individualTag)
|
showError(title, content, individualTag)
|
||||||
}
|
}
|
||||||
|
@ -182,13 +186,12 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showError(title: String, content: String, individualTag: String?) {
|
private fun showError(title: String, content: String?, individualTag: String?) {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setTicker(title)
|
.setTicker(title)
|
||||||
.setContentText(content)
|
.setContentText(content)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.build()
|
.build()
|
||||||
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
|
||||||
}
|
}
|
||||||
|
@ -197,38 +200,54 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||||
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setTicker(title)
|
|
||||||
.setContentText(content)
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.setOngoing(true)
|
|
||||||
.build()
|
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearBackgroundNotification() {
|
private fun clearBackgroundNotification() {
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
notificationManager.cancel(NOTIFICATION_DETAIL_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
private fun showInfo(notification: Notification, isDetail: Boolean = false) {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
|
||||||
.setContentTitle(title)
|
if (isIgnoringBatteryOptimizations) {
|
||||||
.setTicker(title)
|
setForegroundAsync(ForegroundInfo(id, notification))
|
||||||
.setContentText(content)
|
} else {
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
notificationManager.notify(id, notification)
|
||||||
.setOngoing(true)
|
}
|
||||||
.build()
|
}
|
||||||
return ForegroundInfo(NOTIFICATION_ID, notification)
|
|
||||||
}
|
private fun getInfoBuilder(
|
||||||
|
title: String? = null,
|
||||||
|
content: String? = null,
|
||||||
|
isDetail: Boolean = false,
|
||||||
|
progress: Int = 0,
|
||||||
|
max: Int = 0,
|
||||||
|
indeterminate: Boolean = false,
|
||||||
|
): NotificationCompat.Builder {
|
||||||
|
var builder = if(isDetail) notificationDetailBuilder else notificationBuilder
|
||||||
|
if (builder == null) {
|
||||||
|
builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
if (isDetail) {
|
||||||
|
notificationDetailBuilder = builder
|
||||||
|
} else {
|
||||||
|
notificationBuilder = builder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (title != null) {
|
||||||
|
builder.setTicker(title).setContentTitle(title)
|
||||||
|
}
|
||||||
|
if (content != null) {
|
||||||
|
builder.setContentText(content)
|
||||||
|
}
|
||||||
|
return builder.setProgress(max, progress, indeterminate)
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun createChannel() {
|
private fun createChannel() {
|
||||||
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
|
||||||
notificationManager.createNotificationChannel(foreground)
|
notificationManager.createNotificationChannel(foreground)
|
||||||
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
|
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
|
||||||
notificationManager.createNotificationChannel(error)
|
notificationManager.createNotificationChannel(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -244,6 +263,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
||||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
private const val NOTIFICATION_ERROR_ID = 2
|
private const val NOTIFICATION_ERROR_ID = 2
|
||||||
|
private const val NOTIFICATION_DETAIL_ID = 3
|
||||||
private const val ONE_MINUTE = 60000L
|
private const val ONE_MINUTE = 60000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -46,9 +46,6 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
|
||||||
* @param context Android Context
|
* @param context Android Context
|
||||||
*/
|
*/
|
||||||
fun enable(context: Context, immediate: Boolean = false) {
|
fun enable(context: Context, immediate: Boolean = false) {
|
||||||
// migration to remove any old active background task
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
|
|
||||||
|
|
||||||
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
|
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
|
||||||
Log.d(TAG, "enabled ContentObserverWorker")
|
Log.d(TAG, "enabled ContentObserverWorker")
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
|
@ -123,8 +120,10 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx
|
||||||
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
|
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
fun startBackupWorker(context: Context, delayMilliseconds: Long) {
|
||||||
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
if (!sp.getBoolean(SHARED_PREF_SERVICE_ENABLED, false))
|
||||||
|
return
|
||||||
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
|
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
|
||||||
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
|
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
|
||||||
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
|
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.work.Configuration
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
|
||||||
|
class ImmichApp : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
val config = Configuration.Builder().build()
|
||||||
|
WorkManager.initialize(this, config)
|
||||||
|
// always start BackupWorker after WorkManager init; this fixes the following bug:
|
||||||
|
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
|
||||||
|
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
|
||||||
|
// (because of low memory etc.), the backup is never performed.
|
||||||
|
// As a workaround, we also run a backup check when initializing the application
|
||||||
|
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,21 +5,11 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
try {
|
|
||||||
startService(Intent(getBaseContext(), AppClearedService::class.java));
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// startService must not be called when app is in background (crashes app)
|
|
||||||
// there is nothing we can do
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,12 +16,17 @@
|
||||||
default_platform(:android)
|
default_platform(:android)
|
||||||
|
|
||||||
platform :android do
|
platform :android do
|
||||||
desc "Build Android"
|
desc "Build Android and Release Testing"
|
||||||
lane :build do
|
lane :beta do
|
||||||
gradle(
|
gradle(
|
||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
|
properties: {
|
||||||
|
"android.injected.version.code" => 47,
|
||||||
|
"android.injected.version.name" => "1.30.2",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Build and Release Android"
|
desc "Build and Release Android"
|
||||||
|
@ -30,8 +35,8 @@ platform :android do
|
||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 46,
|
"android.injected.version.code" => 49,
|
||||||
"android.injected.version.name" => "1.30.0",
|
"android.injected.version.name" => "1.31.0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|
|
@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
||||||
|
|
||||||
## Android
|
## Android
|
||||||
|
|
||||||
### android build
|
### android beta
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
[bundle exec] fastlane android build
|
[bundle exec] fastlane android beta
|
||||||
```
|
```
|
||||||
|
|
||||||
Build Android
|
Build Android and Release Testing
|
||||||
|
|
||||||
### android release
|
### android release
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
* Improve scroll thumb date info
|
|
@ -0,0 +1 @@
|
||||||
|
* Fixed parsing date error prevent timeline to be loaded.
|
|
@ -0,0 +1,2 @@
|
||||||
|
* Fixed run background service after being killed
|
||||||
|
* Added background backup progress notifications
|
|
@ -5,17 +5,17 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="99.857291">
|
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="40.236485">
|
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
|
@ -134,6 +134,10 @@
|
||||||
"setting_notifications_notify_never": "never",
|
"setting_notifications_notify_never": "never",
|
||||||
"setting_notifications_subtitle": "Adjust your notification preferences",
|
"setting_notifications_subtitle": "Adjust your notification preferences",
|
||||||
"setting_notifications_title": "Notifications",
|
"setting_notifications_title": "Notifications",
|
||||||
|
"setting_notifications_total_progress_title": "Show background backup total progress",
|
||||||
|
"setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
|
||||||
|
"setting_notifications_single_progress_title": "Show background backup detail progress",
|
||||||
|
"setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset",
|
||||||
"setting_pages_app_bar_settings": "Settings",
|
"setting_pages_app_bar_settings": "Settings",
|
||||||
"share_add": "Add",
|
"share_add": "Add",
|
||||||
"share_add_photos": "Add photos",
|
"share_add_photos": "Add photos",
|
||||||
|
|
|
@ -360,7 +360,7 @@
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 60;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
@ -495,7 +495,7 @@
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 60;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
@ -522,7 +522,7 @@
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 60;
|
CURRENT_PROJECT_VERSION = 62;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|
|
@ -17,11 +17,11 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.29.6</string>
|
<string>1.30.1</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>60</string>
|
<string>62</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true />
|
<true />
|
||||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||||
|
|
|
@ -19,7 +19,7 @@ platform :ios do
|
||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.30.0"
|
version_number: "1.31.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|
|
@ -5,32 +5,32 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.604146">
|
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.321654">
|
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.655368">
|
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="4: build_app" time="62.328417">
|
<testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="62.633232">
|
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
|
@ -27,11 +27,11 @@ final backgroundServiceProvider = Provider(
|
||||||
/// Background backup service
|
/// Background backup service
|
||||||
class BackgroundService {
|
class BackgroundService {
|
||||||
static const String _portNameLock = "immichLock";
|
static const String _portNameLock = "immichLock";
|
||||||
BackgroundService();
|
|
||||||
static const MethodChannel _foregroundChannel =
|
static const MethodChannel _foregroundChannel =
|
||||||
MethodChannel('immich/foregroundChannel');
|
MethodChannel('immich/foregroundChannel');
|
||||||
static const MethodChannel _backgroundChannel =
|
static const MethodChannel _backgroundChannel =
|
||||||
MethodChannel('immich/backgroundChannel');
|
MethodChannel('immich/backgroundChannel');
|
||||||
|
static final NumberFormat numberFormat = NumberFormat("###0.##");
|
||||||
bool _isBackgroundInitialized = false;
|
bool _isBackgroundInitialized = false;
|
||||||
CancellationToken? _cancellationToken;
|
CancellationToken? _cancellationToken;
|
||||||
bool _canceledBySystem = false;
|
bool _canceledBySystem = false;
|
||||||
|
@ -40,6 +40,10 @@ class BackgroundService {
|
||||||
SendPort? _waitingIsolate;
|
SendPort? _waitingIsolate;
|
||||||
ReceivePort? _rp;
|
ReceivePort? _rp;
|
||||||
bool _errorGracePeriodExceeded = true;
|
bool _errorGracePeriodExceeded = true;
|
||||||
|
int _uploadedAssetsCount = 0;
|
||||||
|
int _assetsToUploadCount = 0;
|
||||||
|
int _lastDetailProgressUpdate = 0;
|
||||||
|
String _lastPrintedProgress = "";
|
||||||
|
|
||||||
bool get isBackgroundInitialized {
|
bool get isBackgroundInitialized {
|
||||||
return _isBackgroundInitialized;
|
return _isBackgroundInitialized;
|
||||||
|
@ -125,22 +129,29 @@ class BackgroundService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the notification shown by the background service
|
/// Updates the notification shown by the background service
|
||||||
Future<bool> _updateNotification({
|
Future<bool?> _updateNotification({
|
||||||
required String title,
|
String? title,
|
||||||
String? content,
|
String? content,
|
||||||
|
int progress = 0,
|
||||||
|
int max = 0,
|
||||||
|
bool indeterminate = false,
|
||||||
|
bool isDetail = false,
|
||||||
|
bool onlyIfFG = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (_isBackgroundInitialized) {
|
if (_isBackgroundInitialized) {
|
||||||
return await _backgroundChannel
|
return _backgroundChannel.invokeMethod<bool>(
|
||||||
.invokeMethod('updateNotification', [title, content]);
|
'updateNotification',
|
||||||
|
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint("[_updateNotification] failed to communicate with plugin");
|
debugPrint("[_updateNotification] failed to communicate with plugin");
|
||||||
}
|
}
|
||||||
return Future.value(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a new priority notification
|
/// Shows a new priority notification
|
||||||
|
@ -274,6 +285,7 @@ class BackgroundService {
|
||||||
case "onAssetsChanged":
|
case "onAssetsChanged":
|
||||||
final Future<bool> translationsLoaded = loadTranslations();
|
final Future<bool> translationsLoaded = loadTranslations();
|
||||||
try {
|
try {
|
||||||
|
_clearErrorNotifications();
|
||||||
final bool hasAccess = await acquireLock();
|
final bool hasAccess = await acquireLock();
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
debugPrint("[_callHandler] could not acquire lock, exiting");
|
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||||
|
@ -313,19 +325,23 @@ class BackgroundService {
|
||||||
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
|
||||||
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||||
BackupService backupService = BackupService(apiService);
|
BackupService backupService = BackupService(apiService);
|
||||||
|
AppSettingsService settingsService = AppSettingsService();
|
||||||
|
|
||||||
final Box<HiveBackupAlbums> box =
|
final Box<HiveBackupAlbums> box =
|
||||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||||
if (backupAlbumInfo == null) {
|
if (backupAlbumInfo == null) {
|
||||||
_clearErrorNotifications();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
await PhotoManager.setIgnorePermissionCheck(true);
|
||||||
|
|
||||||
do {
|
do {
|
||||||
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
|
final bool backupOk = await _runBackup(
|
||||||
|
backupService,
|
||||||
|
settingsService,
|
||||||
|
backupAlbumInfo,
|
||||||
|
);
|
||||||
if (backupOk) {
|
if (backupOk) {
|
||||||
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||||
await box.put(
|
await box.put(
|
||||||
|
@ -346,9 +362,14 @@ class BackgroundService {
|
||||||
|
|
||||||
Future<bool> _runBackup(
|
Future<bool> _runBackup(
|
||||||
BackupService backupService,
|
BackupService backupService,
|
||||||
|
AppSettingsService settingsService,
|
||||||
HiveBackupAlbums backupAlbumInfo,
|
HiveBackupAlbums backupAlbumInfo,
|
||||||
) async {
|
) async {
|
||||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||||
|
final bool notifyTotalProgress = settingsService
|
||||||
|
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||||
|
final bool notifySingleProgress = settingsService
|
||||||
|
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||||
|
|
||||||
if (_canceledBySystem) {
|
if (_canceledBySystem) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -372,22 +393,29 @@ class BackgroundService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toUpload.isEmpty) {
|
if (toUpload.isEmpty) {
|
||||||
_clearErrorNotifications();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
_assetsToUploadCount = toUpload.length;
|
||||||
|
_uploadedAssetsCount = 0;
|
||||||
|
_updateNotification(
|
||||||
|
title: "backup_background_service_in_progress_notification".tr(),
|
||||||
|
content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
|
||||||
|
progress: 0,
|
||||||
|
max: notifyTotalProgress ? _assetsToUploadCount : 0,
|
||||||
|
indeterminate: !notifyTotalProgress,
|
||||||
|
onlyIfFG: !notifyTotalProgress,
|
||||||
|
);
|
||||||
|
|
||||||
_cancellationToken = CancellationToken();
|
_cancellationToken = CancellationToken();
|
||||||
final bool ok = await backupService.backupAsset(
|
final bool ok = await backupService.backupAsset(
|
||||||
toUpload,
|
toUpload,
|
||||||
_cancellationToken!,
|
_cancellationToken!,
|
||||||
_onAssetUploaded,
|
notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
|
||||||
_onProgress,
|
notifySingleProgress ? _onProgress : (sent, total) {},
|
||||||
_onSetCurrentBackupAsset,
|
notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
|
||||||
_onBackupError,
|
_onBackupError,
|
||||||
);
|
);
|
||||||
if (ok) {
|
if (!ok && !_cancellationToken!.isCancelled) {
|
||||||
_clearErrorNotifications();
|
|
||||||
} else {
|
|
||||||
_showErrorNotification(
|
_showErrorNotification(
|
||||||
title: "backup_background_service_error_title".tr(),
|
title: "backup_background_service_error_title".tr(),
|
||||||
content: "backup_background_service_backup_failed_message".tr(),
|
content: "backup_background_service_backup_failed_message".tr(),
|
||||||
|
@ -396,16 +424,43 @@ class BackgroundService {
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
String _formatAssetBackupProgress() {
|
||||||
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
|
||||||
|
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onProgress(int sent, int total) {}
|
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||||
|
debugPrint("Uploaded $deviceAssetId from $deviceId");
|
||||||
|
_uploadedAssetsCount++;
|
||||||
|
_updateNotification(
|
||||||
|
progress: _uploadedAssetsCount,
|
||||||
|
max: _assetsToUploadCount,
|
||||||
|
content: _formatAssetBackupProgress(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onProgress(int sent, int total) {
|
||||||
|
final int now = Timeline.now;
|
||||||
|
// limit updates to 10 per second (or Android drops important notifications)
|
||||||
|
if (now > _lastDetailProgressUpdate + 100000) {
|
||||||
|
final String msg = _humanReadableBytesProgress(sent, total);
|
||||||
|
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
|
||||||
|
if (msg != _lastPrintedProgress) {
|
||||||
|
_lastDetailProgressUpdate = now;
|
||||||
|
_lastPrintedProgress = msg;
|
||||||
|
_updateNotification(
|
||||||
|
progress: sent,
|
||||||
|
max: total,
|
||||||
|
isDetail: true,
|
||||||
|
content: msg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
|
||||||
_showErrorNotification(
|
_showErrorNotification(
|
||||||
title: "Upload failed",
|
title: "backup_background_service_upload_failure_notification"
|
||||||
content: "backup_background_service_upload_failure_notification"
|
|
||||||
.tr(args: [errorAssetInfo.fileName]),
|
.tr(args: [errorAssetInfo.fileName]),
|
||||||
individualTag: errorAssetInfo.id,
|
individualTag: errorAssetInfo.id,
|
||||||
);
|
);
|
||||||
|
@ -413,14 +468,17 @@ class BackgroundService {
|
||||||
|
|
||||||
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
|
||||||
_updateNotification(
|
_updateNotification(
|
||||||
title: "backup_background_service_in_progress_notification".tr(),
|
title: "backup_background_service_current_upload_notification"
|
||||||
content: "backup_background_service_current_upload_notification"
|
|
||||||
.tr(args: [currentUploadAsset.fileName]),
|
.tr(args: [currentUploadAsset.fileName]),
|
||||||
|
content: "",
|
||||||
|
isDetail: true,
|
||||||
|
progress: 0,
|
||||||
|
max: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isErrorGracePeriodExceeded() {
|
bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
|
||||||
final int value = AppSettingsService()
|
final int value = appSettingsService
|
||||||
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
|
||||||
if (value == 0) {
|
if (value == 0) {
|
||||||
return true;
|
return true;
|
||||||
|
@ -445,6 +503,26 @@ class BackgroundService {
|
||||||
assert(false, "Invalid value");
|
assert(false, "Invalid value");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
|
||||||
|
static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
|
||||||
|
String unit = "KB"; // Kilobyte
|
||||||
|
if (bytesTotal >= 0x40000000) {
|
||||||
|
unit = "GB"; // Gigabyte
|
||||||
|
bytes >>= 20;
|
||||||
|
bytesTotal >>= 20;
|
||||||
|
} else if (bytesTotal >= 0x100000) {
|
||||||
|
unit = "MB"; // Megabyte
|
||||||
|
bytes >>= 10;
|
||||||
|
bytesTotal >>= 10;
|
||||||
|
} else if (bytesTotal < 0x400) {
|
||||||
|
return "$bytes / $bytesTotal B";
|
||||||
|
}
|
||||||
|
final int percent = (bytes * 100) ~/ bytesTotal;
|
||||||
|
final String done = numberFormat.format(bytes / 1024.0);
|
||||||
|
final String total = numberFormat.format(bytesTotal / 1024.0);
|
||||||
|
return "$percent% ($done/$total$unit)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/src/widgets/framework.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
@ -15,7 +12,9 @@ import 'disable_multi_select_button.dart';
|
||||||
import 'draggable_scrollbar_custom.dart';
|
import 'draggable_scrollbar_custom.dart';
|
||||||
|
|
||||||
typedef ImmichAssetGridSelectionListener = void Function(
|
typedef ImmichAssetGridSelectionListener = void Function(
|
||||||
bool, Set<AssetResponseDto>);
|
bool,
|
||||||
|
Set<AssetResponseDto>,
|
||||||
|
);
|
||||||
|
|
||||||
class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
|
@ -23,7 +22,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
|
|
||||||
bool _scrolling = false;
|
bool _scrolling = false;
|
||||||
Set<String> _selectedAssets = HashSet();
|
final Set<String> _selectedAssets = HashSet();
|
||||||
|
|
||||||
List<AssetResponseDto> get _assets {
|
List<AssetResponseDto> get _assets {
|
||||||
return widget.renderList
|
return widget.renderList
|
||||||
|
@ -86,7 +85,9 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThumbnailOrPlaceholder(
|
Widget _buildThumbnailOrPlaceholder(
|
||||||
AssetResponseDto asset, bool placeholder) {
|
AssetResponseDto asset,
|
||||||
|
bool placeholder,
|
||||||
|
) {
|
||||||
if (placeholder) {
|
if (placeholder) {
|
||||||
return const DecoratedBox(
|
return const DecoratedBox(
|
||||||
decoration: BoxDecoration(color: Colors.grey),
|
decoration: BoxDecoration(color: Colors.grey),
|
||||||
|
@ -104,7 +105,10 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAssetRow(
|
Widget _buildAssetRow(
|
||||||
BuildContext context, RenderAssetGridRow row, bool scrolling) {
|
BuildContext context,
|
||||||
|
RenderAssetGridRow row,
|
||||||
|
bool scrolling,
|
||||||
|
) {
|
||||||
double size = _getItemSize(context);
|
double size = _getItemSize(context);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
|
@ -117,7 +121,9 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
margin: EdgeInsets.only(
|
margin: EdgeInsets.only(
|
||||||
top: widget.margin, right: last ? 0.0 : widget.margin),
|
top: widget.margin,
|
||||||
|
right: last ? 0.0 : widget.margin,
|
||||||
|
),
|
||||||
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
@ -125,7 +131,10 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTitle(
|
Widget _buildTitle(
|
||||||
BuildContext context, String title, List<AssetResponseDto> assets) {
|
BuildContext context,
|
||||||
|
String title,
|
||||||
|
List<AssetResponseDto> assets,
|
||||||
|
) {
|
||||||
return DailyTitleText(
|
return DailyTitleText(
|
||||||
isoDate: title,
|
isoDate: title,
|
||||||
multiselectEnabled: widget.selectionActive,
|
multiselectEnabled: widget.selectionActive,
|
||||||
|
@ -186,7 +195,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAssetGrid() {
|
Widget _buildAssetGrid() {
|
||||||
final useDragScrolling = _assets.length > 100;
|
final useDragScrolling = _assets.length >= 20;
|
||||||
|
|
||||||
void dragScrolling(bool active) {
|
void dragScrolling(bool active) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
@ -218,7 +227,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(ImmichAssetGrid oldWidget) {
|
void didUpdateWidget(ImmichAssetGrid oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
@ -248,14 +256,14 @@ class ImmichAssetGrid extends StatefulWidget {
|
||||||
final ImmichAssetGridSelectionListener? listener;
|
final ImmichAssetGridSelectionListener? listener;
|
||||||
final bool selectionActive;
|
final bool selectionActive;
|
||||||
|
|
||||||
ImmichAssetGrid({
|
const ImmichAssetGrid({
|
||||||
super.key,
|
super.key,
|
||||||
required this.renderList,
|
required this.renderList,
|
||||||
required this.assetsPerRow,
|
required this.assetsPerRow,
|
||||||
required this.showStorageIndicator,
|
required this.showStorageIndicator,
|
||||||
this.listener,
|
this.listener,
|
||||||
this.margin = 5.0,
|
this.margin = 5.0,
|
||||||
this.selectionActive = false
|
this.selectionActive = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -6,7 +6,11 @@ enum AppSettingsEnum<T> {
|
||||||
themeMode<String>("themeMode", "system"), // "light","dark","system"
|
themeMode<String>("themeMode", "system"), // "light","dark","system"
|
||||||
tilesPerRow<int>("tilesPerRow", 4),
|
tilesPerRow<int>("tilesPerRow", 4),
|
||||||
uploadErrorNotificationGracePeriod<int>(
|
uploadErrorNotificationGracePeriod<int>(
|
||||||
"uploadErrorNotificationGracePeriod", 2),
|
"uploadErrorNotificationGracePeriod",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
|
||||||
|
backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
|
||||||
storageIndicator<bool>("storageIndicator", true),
|
storageIndicator<bool>("storageIndicator", true),
|
||||||
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||||
imageCacheSize<int>("imageCacheSize", 350),
|
imageCacheSize<int>("imageCacheSize", 350),
|
||||||
|
|
|
@ -15,12 +15,20 @@ class NotificationSetting extends HookConsumerWidget {
|
||||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||||
|
|
||||||
final sliderValue = useState(0.0);
|
final sliderValue = useState(0.0);
|
||||||
|
final totalProgressValue =
|
||||||
|
useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
|
||||||
|
final singleProgressValue =
|
||||||
|
useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
sliderValue.value = appSettingService
|
sliderValue.value = appSettingService
|
||||||
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
|
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
|
||||||
.toDouble();
|
.toDouble();
|
||||||
|
totalProgressValue.value = appSettingService
|
||||||
|
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
|
||||||
|
singleProgressValue.value = appSettingService
|
||||||
|
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -42,6 +50,22 @@ class NotificationSetting extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
children: [
|
children: [
|
||||||
|
_buildSwitchListTile(
|
||||||
|
context,
|
||||||
|
appSettingService,
|
||||||
|
totalProgressValue,
|
||||||
|
AppSettingsEnum.backgroundBackupTotalProgress,
|
||||||
|
title: 'setting_notifications_total_progress_title'.tr(),
|
||||||
|
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
|
||||||
|
),
|
||||||
|
_buildSwitchListTile(
|
||||||
|
context,
|
||||||
|
appSettingService,
|
||||||
|
singleProgressValue,
|
||||||
|
AppSettingsEnum.backgroundBackupSingleProgress,
|
||||||
|
title: 'setting_notifications_single_progress_title'.tr(),
|
||||||
|
subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
isThreeLine: false,
|
isThreeLine: false,
|
||||||
dense: true,
|
dense: true,
|
||||||
|
@ -53,7 +77,9 @@ class NotificationSetting extends HookConsumerWidget {
|
||||||
value: sliderValue.value,
|
value: sliderValue.value,
|
||||||
onChanged: (double v) => sliderValue.value = v,
|
onChanged: (double v) => sliderValue.value = v,
|
||||||
onChangeEnd: (double v) => appSettingService.setSetting(
|
onChangeEnd: (double v) => appSettingService.setSetting(
|
||||||
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()),
|
AppSettingsEnum.uploadErrorNotificationGracePeriod,
|
||||||
|
v.toInt(),
|
||||||
|
),
|
||||||
max: 5.0,
|
max: 5.0,
|
||||||
divisions: 5,
|
divisions: 5,
|
||||||
label: formattedValue,
|
label: formattedValue,
|
||||||
|
@ -65,6 +91,28 @@ class NotificationSetting extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SwitchListTile _buildSwitchListTile(
|
||||||
|
BuildContext context,
|
||||||
|
AppSettingsService appSettingService,
|
||||||
|
ValueNotifier<bool> valueNotifier,
|
||||||
|
AppSettingsEnum settingsEnum, {
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
}) {
|
||||||
|
return SwitchListTile(
|
||||||
|
key: Key(settingsEnum.name),
|
||||||
|
value: valueNotifier.value,
|
||||||
|
onChanged: (value) {
|
||||||
|
valueNotifier.value = value;
|
||||||
|
appSettingService.setSetting(settingsEnum, value);
|
||||||
|
},
|
||||||
|
activeColor: Theme.of(context).primaryColor,
|
||||||
|
dense: true,
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: subtitle != null ? Text(subtitle) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _formatSliderValue(double v) {
|
String _formatSliderValue(double v) {
|
||||||
if (v == 0.0) {
|
if (v == 0.0) {
|
||||||
return 'setting_notifications_notify_immediately'.tr();
|
return 'setting_notifications_notify_immediately'.tr();
|
||||||
|
|
|
@ -8,6 +8,7 @@ doc/AdminSignupResponseDto.md
|
||||||
doc/AlbumApi.md
|
doc/AlbumApi.md
|
||||||
doc/AlbumCountResponseDto.md
|
doc/AlbumCountResponseDto.md
|
||||||
doc/AlbumResponseDto.md
|
doc/AlbumResponseDto.md
|
||||||
|
doc/AllJobStatusResponseDto.md
|
||||||
doc/AssetApi.md
|
doc/AssetApi.md
|
||||||
doc/AssetCountByTimeBucket.md
|
doc/AssetCountByTimeBucket.md
|
||||||
doc/AssetCountByTimeBucketResponseDto.md
|
doc/AssetCountByTimeBucketResponseDto.md
|
||||||
|
@ -33,6 +34,12 @@ doc/DeviceTypeEnum.md
|
||||||
doc/ExifResponseDto.md
|
doc/ExifResponseDto.md
|
||||||
doc/GetAssetByTimeBucketDto.md
|
doc/GetAssetByTimeBucketDto.md
|
||||||
doc/GetAssetCountByTimeBucketDto.md
|
doc/GetAssetCountByTimeBucketDto.md
|
||||||
|
doc/JobApi.md
|
||||||
|
doc/JobCommand.md
|
||||||
|
doc/JobCommandDto.md
|
||||||
|
doc/JobCounts.md
|
||||||
|
doc/JobId.md
|
||||||
|
doc/JobStatusResponseDto.md
|
||||||
doc/LoginCredentialDto.md
|
doc/LoginCredentialDto.md
|
||||||
doc/LoginResponseDto.md
|
doc/LoginResponseDto.md
|
||||||
doc/LogoutResponseDto.md
|
doc/LogoutResponseDto.md
|
||||||
|
@ -59,6 +66,7 @@ lib/api/album_api.dart
|
||||||
lib/api/asset_api.dart
|
lib/api/asset_api.dart
|
||||||
lib/api/authentication_api.dart
|
lib/api/authentication_api.dart
|
||||||
lib/api/device_info_api.dart
|
lib/api/device_info_api.dart
|
||||||
|
lib/api/job_api.dart
|
||||||
lib/api/server_info_api.dart
|
lib/api/server_info_api.dart
|
||||||
lib/api/user_api.dart
|
lib/api/user_api.dart
|
||||||
lib/api_client.dart
|
lib/api_client.dart
|
||||||
|
@ -74,6 +82,7 @@ lib/model/add_users_dto.dart
|
||||||
lib/model/admin_signup_response_dto.dart
|
lib/model/admin_signup_response_dto.dart
|
||||||
lib/model/album_count_response_dto.dart
|
lib/model/album_count_response_dto.dart
|
||||||
lib/model/album_response_dto.dart
|
lib/model/album_response_dto.dart
|
||||||
|
lib/model/all_job_status_response_dto.dart
|
||||||
lib/model/asset_count_by_time_bucket.dart
|
lib/model/asset_count_by_time_bucket.dart
|
||||||
lib/model/asset_count_by_time_bucket_response_dto.dart
|
lib/model/asset_count_by_time_bucket_response_dto.dart
|
||||||
lib/model/asset_count_by_user_id_response_dto.dart
|
lib/model/asset_count_by_user_id_response_dto.dart
|
||||||
|
@ -96,6 +105,11 @@ lib/model/device_type_enum.dart
|
||||||
lib/model/exif_response_dto.dart
|
lib/model/exif_response_dto.dart
|
||||||
lib/model/get_asset_by_time_bucket_dto.dart
|
lib/model/get_asset_by_time_bucket_dto.dart
|
||||||
lib/model/get_asset_count_by_time_bucket_dto.dart
|
lib/model/get_asset_count_by_time_bucket_dto.dart
|
||||||
|
lib/model/job_command.dart
|
||||||
|
lib/model/job_command_dto.dart
|
||||||
|
lib/model/job_counts.dart
|
||||||
|
lib/model/job_id.dart
|
||||||
|
lib/model/job_status_response_dto.dart
|
||||||
lib/model/login_credential_dto.dart
|
lib/model/login_credential_dto.dart
|
||||||
lib/model/login_response_dto.dart
|
lib/model/login_response_dto.dart
|
||||||
lib/model/logout_response_dto.dart
|
lib/model/logout_response_dto.dart
|
||||||
|
|
Binary file not shown.
BIN
mobile/openapi/doc/AllJobStatusResponseDto.md
Normal file
BIN
mobile/openapi/doc/AllJobStatusResponseDto.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/CreateJobDto.md
Normal file
BIN
mobile/openapi/doc/CreateJobDto.md
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/doc/JobApi.md
Normal file
BIN
mobile/openapi/doc/JobApi.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/JobCommand.md
Normal file
BIN
mobile/openapi/doc/JobCommand.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/JobCommandDto.md
Normal file
BIN
mobile/openapi/doc/JobCommandDto.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/JobCounts.md
Normal file
BIN
mobile/openapi/doc/JobCounts.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/JobId.md
Normal file
BIN
mobile/openapi/doc/JobId.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/JobStatusResponseDto.md
Normal file
BIN
mobile/openapi/doc/JobStatusResponseDto.md
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/JobType.md
Normal file
BIN
mobile/openapi/doc/JobType.md
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/api/job_api.dart
Normal file
BIN
mobile/openapi/lib/api/job_api.dart
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/all_job_status_response_dto.dart
Normal file
BIN
mobile/openapi/lib/model/all_job_status_response_dto.dart
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/create_job_dto.dart
Normal file
BIN
mobile/openapi/lib/model/create_job_dto.dart
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/job_command.dart
Normal file
BIN
mobile/openapi/lib/model/job_command.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/job_command_dto.dart
Normal file
BIN
mobile/openapi/lib/model/job_command_dto.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/job_counts.dart
Normal file
BIN
mobile/openapi/lib/model/job_counts.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/job_id.dart
Normal file
BIN
mobile/openapi/lib/model/job_id.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/job_status_response_dto.dart
Normal file
BIN
mobile/openapi/lib/model/job_status_response_dto.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/job_type.dart
Normal file
BIN
mobile/openapi/lib/model/job_type.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/all_job_status_response_dto_test.dart
Normal file
BIN
mobile/openapi/test/all_job_status_response_dto_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/create_job_dto_test.dart
Normal file
BIN
mobile/openapi/test/create_job_dto_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/job_api_test.dart
Normal file
BIN
mobile/openapi/test/job_api_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/job_command_dto_test.dart
Normal file
BIN
mobile/openapi/test/job_command_dto_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/job_command_test.dart
Normal file
BIN
mobile/openapi/test/job_command_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/job_counts_test.dart
Normal file
BIN
mobile/openapi/test/job_counts_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/job_id_test.dart
Normal file
BIN
mobile/openapi/test/job_id_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/job_status_response_dto_test.dart
Normal file
BIN
mobile/openapi/test/job_status_response_dto_test.dart
Normal file
Binary file not shown.
BIN
mobile/openapi/test/job_type_test.dart
Normal file
BIN
mobile/openapi/test/job_type_test.dart
Normal file
Binary file not shown.
|
@ -2,7 +2,7 @@ name: immich_mobile
|
||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.30.0+46
|
version: 1.31.0+49
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
upload/
|
upload/
|
||||||
dist/
|
dist/
|
||||||
|
.reverse-geocoding-dump
|
||||||
|
|
|
@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
|
||||||
|
|
||||||
RUN npm prune --production
|
RUN npm prune --production
|
||||||
|
|
||||||
|
VOLUME /usr/src/app/upload
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
|
@ -134,6 +134,9 @@ describe('Album service', () => {
|
||||||
getAssetByTimeBucket: jest.fn(),
|
getAssetByTimeBucket: jest.fn(),
|
||||||
getAssetByChecksum: jest.fn(),
|
getAssetByChecksum: jest.fn(),
|
||||||
getAssetCountByUserId: jest.fn(),
|
getAssetCountByUserId: jest.fn(),
|
||||||
|
getAssetWithNoEXIF: jest.fn(),
|
||||||
|
getAssetWithNoThumbnail: jest.fn(),
|
||||||
|
getAssetWithNoSmartInfo: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
|
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
|
||||||
|
|
|
@ -29,6 +29,9 @@ export interface IAssetRepository {
|
||||||
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
|
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
|
||||||
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
||||||
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
||||||
|
getAssetWithNoThumbnail(): Promise<AssetEntity[]>;
|
||||||
|
getAssetWithNoEXIF(): Promise<AssetEntity[]>;
|
||||||
|
getAssetWithNoSmartInfo(): Promise<AssetEntity[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
|
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
|
||||||
|
@ -40,6 +43,33 @@ export class AssetRepository implements IAssetRepository {
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
|
||||||
|
return await this.assetRepository
|
||||||
|
.createQueryBuilder('asset')
|
||||||
|
.leftJoinAndSelect('asset.smartInfo', 'si')
|
||||||
|
.where('asset.resizePath IS NOT NULL')
|
||||||
|
.andWhere('si.id IS NULL')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssetWithNoThumbnail(): Promise<AssetEntity[]> {
|
||||||
|
return await this.assetRepository
|
||||||
|
.createQueryBuilder('asset')
|
||||||
|
.where('asset.resizePath IS NULL')
|
||||||
|
.orWhere('asset.resizePath = :resizePath', { resizePath: '' })
|
||||||
|
.orWhere('asset.webpPath IS NULL')
|
||||||
|
.orWhere('asset.webpPath = :webpPath', { webpPath: '' })
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssetWithNoEXIF(): Promise<AssetEntity[]> {
|
||||||
|
return await this.assetRepository
|
||||||
|
.createQueryBuilder('asset')
|
||||||
|
.leftJoinAndSelect('asset.exifInfo', 'ei')
|
||||||
|
.where('ei."assetId" IS NULL')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
|
async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
|
||||||
// Get asset count by AssetType
|
// Get asset count by AssetType
|
||||||
const res = await this.assetRepository
|
const res = await this.assetRepository
|
||||||
|
|
|
@ -30,7 +30,7 @@ import { CommunicationGateway } from '../communication/communication.gateway';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
import { IAssetUploadedJob } from '@app/job/index';
|
import { IAssetUploadedJob } from '@app/job/index';
|
||||||
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
|
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
|
||||||
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
|
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
|
||||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
|
@ -59,7 +59,7 @@ export class AssetController {
|
||||||
private assetService: AssetService,
|
private assetService: AssetService,
|
||||||
private backgroundTaskService: BackgroundTaskService,
|
private backgroundTaskService: BackgroundTaskService,
|
||||||
|
|
||||||
@InjectQueue(assetUploadedQueueName)
|
@InjectQueue(QueueNameEnum.ASSET_UPLOADED)
|
||||||
private assetUploadedQueue: Queue<IAssetUploadedJob>,
|
private assetUploadedQueue: Queue<IAssetUploadedJob>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { BullModule } from '@nestjs/bull';
|
||||||
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
import { CommunicationModule } from '../communication/communication.module';
|
import { CommunicationModule } from '../communication/communication.module';
|
||||||
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
|
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
|
||||||
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
|
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
@ -16,7 +16,7 @@ import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
|
||||||
BackgroundTaskModule,
|
BackgroundTaskModule,
|
||||||
TypeOrmModule.forFeature([AssetEntity]),
|
TypeOrmModule.forFeature([AssetEntity]),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: assetUploadedQueueName,
|
name: QueueNameEnum.ASSET_UPLOADED,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
|
|
@ -107,6 +107,9 @@ describe('AssetService', () => {
|
||||||
getAssetByTimeBucket: jest.fn(),
|
getAssetByTimeBucket: jest.fn(),
|
||||||
getAssetByChecksum: jest.fn(),
|
getAssetByChecksum: jest.fn(),
|
||||||
getAssetCountByUserId: jest.fn(),
|
getAssetCountByUserId: jest.fn(),
|
||||||
|
getAssetWithNoEXIF: jest.fn(),
|
||||||
|
getAssetWithNoThumbnail: jest.fn(),
|
||||||
|
getAssetWithNoSmartInfo: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sui = new AssetService(assetRepositoryMock, a);
|
sui = new AssetService(assetRepositoryMock, a);
|
||||||
|
|
|
@ -9,10 +9,11 @@ export enum GetAssetThumbnailFormatEnum {
|
||||||
export class GetAssetThumbnailDto {
|
export class GetAssetThumbnailDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
enum: GetAssetThumbnailFormatEnum,
|
enum: GetAssetThumbnailFormatEnum,
|
||||||
default: GetAssetThumbnailFormatEnum.WEBP,
|
default: GetAssetThumbnailFormatEnum.WEBP,
|
||||||
required: false,
|
required: false,
|
||||||
enumName: 'ThumbnailFormat',
|
enumName: 'ThumbnailFormat',
|
||||||
})
|
})
|
||||||
format = GetAssetThumbnailFormatEnum.WEBP;
|
format: GetAssetThumbnailFormatEnum = GetAssetThumbnailFormatEnum.WEBP;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class ExifResponseDto {
|
export class ExifResponseDto {
|
||||||
id?: string | null = null;
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
|
id?: number | null = null;
|
||||||
make?: string | null = null;
|
make?: string | null = null;
|
||||||
model?: string | null = null;
|
model?: string | null = null;
|
||||||
imageName?: string | null = null;
|
imageName?: string | null = null;
|
||||||
exifImageWidth?: number | null = null;
|
exifImageWidth?: number | null = null;
|
||||||
exifImageHeight?: number | null = null;
|
exifImageHeight?: number | null = null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
fileSizeInByte?: number | null = null;
|
fileSizeInByte?: number | null = null;
|
||||||
orientation?: string | null = null;
|
orientation?: string | null = null;
|
||||||
dateTimeOriginal?: Date | null = null;
|
dateTimeOriginal?: Date | null = null;
|
||||||
|
@ -25,13 +29,13 @@ export class ExifResponseDto {
|
||||||
|
|
||||||
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
export function mapExif(entity: ExifEntity): ExifResponseDto {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: parseInt(entity.id),
|
||||||
make: entity.make,
|
make: entity.make,
|
||||||
model: entity.model,
|
model: entity.model,
|
||||||
imageName: entity.imageName,
|
imageName: entity.imageName,
|
||||||
exifImageWidth: entity.exifImageWidth,
|
exifImageWidth: entity.exifImageWidth,
|
||||||
exifImageHeight: entity.exifImageHeight,
|
exifImageHeight: entity.exifImageHeight,
|
||||||
fileSizeInByte: entity.fileSizeInByte,
|
fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
|
||||||
orientation: entity.orientation,
|
orientation: entity.orientation,
|
||||||
dateTimeOriginal: entity.dateTimeOriginal,
|
dateTimeOriginal: entity.dateTimeOriginal,
|
||||||
modifyDate: entity.modifyDate,
|
modifyDate: entity.modifyDate,
|
||||||
|
|
22
server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
Normal file
22
server/apps/immich/src/api-v1/job/dto/get-job.dto.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export enum JobId {
|
||||||
|
THUMBNAIL_GENERATION = 'thumbnail-generation',
|
||||||
|
METADATA_EXTRACTION = 'metadata-extraction',
|
||||||
|
VIDEO_CONVERSION = 'video-conversion',
|
||||||
|
MACHINE_LEARNING = 'machine-learning',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetJobDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(JobId, {
|
||||||
|
message: `params must be one of ${Object.values(JobId).join()}`,
|
||||||
|
})
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
enum: JobId,
|
||||||
|
enumName: 'JobId',
|
||||||
|
})
|
||||||
|
jobId!: JobId;
|
||||||
|
}
|
12
server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
Normal file
12
server/apps/immich/src/api-v1/job/dto/job-command.dto.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class JobCommandDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(['start', 'stop'])
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ['start', 'stop'],
|
||||||
|
enumName: 'JobCommand',
|
||||||
|
})
|
||||||
|
command!: string;
|
||||||
|
}
|
43
server/apps/immich/src/api-v1/job/job.controller.ts
Normal file
43
server/apps/immich/src/api-v1/job/job.controller.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { Controller, Get, Body, UseGuards, ValidationPipe, Put, Param } from '@nestjs/common';
|
||||||
|
import { JobService } from './job.service';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
|
import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
|
||||||
|
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
|
||||||
|
import { GetJobDto } from './dto/get-job.dto';
|
||||||
|
import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
|
||||||
|
|
||||||
|
import { JobCommandDto } from './dto/job-command.dto';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@UseGuards(AdminRolesGuard)
|
||||||
|
@ApiTags('Job')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('jobs')
|
||||||
|
export class JobController {
|
||||||
|
constructor(private readonly jobService: JobService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
|
||||||
|
return this.jobService.getAllJobsStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/:jobId')
|
||||||
|
getJobStatus(@Param(ValidationPipe) params: GetJobDto): Promise<JobStatusResponseDto> {
|
||||||
|
return this.jobService.getJobStatus(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('/:jobId')
|
||||||
|
async sendJobCommand(
|
||||||
|
@Param(ValidationPipe) params: GetJobDto,
|
||||||
|
@Body(ValidationPipe) body: JobCommandDto,
|
||||||
|
): Promise<number> {
|
||||||
|
if (body.command === 'start') {
|
||||||
|
return await this.jobService.startJob(params);
|
||||||
|
}
|
||||||
|
if (body.command === 'stop') {
|
||||||
|
return await this.jobService.stopJob(params);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
82
server/apps/immich/src/api-v1/job/job.module.ts
Normal file
82
server/apps/immich/src/api-v1/job/job.module.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JobService } from './job.service';
|
||||||
|
import { JobController } from './job.controller';
|
||||||
|
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
||||||
|
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { jwtConfig } from '../../config/jwt.config';
|
||||||
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { QueueNameEnum } from '@app/job';
|
||||||
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
|
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([UserEntity, AssetEntity, ExifEntity]),
|
||||||
|
ImmichJwtModule,
|
||||||
|
JwtModule.register(jwtConfig),
|
||||||
|
BullModule.registerQueue(
|
||||||
|
{
|
||||||
|
name: QueueNameEnum.THUMBNAIL_GENERATION,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: QueueNameEnum.ASSET_UPLOADED,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: QueueNameEnum.METADATA_EXTRACTION,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: QueueNameEnum.VIDEO_CONVERSION,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: QueueNameEnum.CHECKSUM_GENERATION,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: QueueNameEnum.MACHINE_LEARNING,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
controllers: [JobController],
|
||||||
|
providers: [
|
||||||
|
JobService,
|
||||||
|
ImmichJwtService,
|
||||||
|
{
|
||||||
|
provide: ASSET_REPOSITORY,
|
||||||
|
useClass: AssetRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class JobModule {}
|
180
server/apps/immich/src/api-v1/job/job.service.ts
Normal file
180
server/apps/immich/src/api-v1/job/job.service.ts
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
import {
|
||||||
|
exifExtractionProcessorName,
|
||||||
|
generateJPEGThumbnailProcessorName,
|
||||||
|
IMetadataExtractionJob,
|
||||||
|
IThumbnailGenerationJob,
|
||||||
|
IVideoTranscodeJob,
|
||||||
|
MachineLearningJobNameEnum,
|
||||||
|
QueueNameEnum,
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
|
} from '@app/job';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
|
import { Queue } from 'bull';
|
||||||
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
|
||||||
|
import { AssetType } from '@app/database/entities/asset.entity';
|
||||||
|
import { GetJobDto, JobId } from './dto/get-job.dto';
|
||||||
|
import { JobStatusResponseDto } from './response-dto/job-status-response.dto';
|
||||||
|
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JobService {
|
||||||
|
constructor(
|
||||||
|
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
|
||||||
|
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
|
||||||
|
|
||||||
|
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
|
||||||
|
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
||||||
|
|
||||||
|
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
|
||||||
|
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||||
|
|
||||||
|
@InjectQueue(QueueNameEnum.MACHINE_LEARNING)
|
||||||
|
private machineLearningQueue: Queue<IMachineLearningJob>,
|
||||||
|
|
||||||
|
@Inject(ASSET_REPOSITORY)
|
||||||
|
private _assetRepository: IAssetRepository,
|
||||||
|
) {
|
||||||
|
this.thumbnailGeneratorQueue.empty();
|
||||||
|
this.metadataExtractionQueue.empty();
|
||||||
|
this.videoConversionQueue.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
async startJob(jobDto: GetJobDto): Promise<number> {
|
||||||
|
switch (jobDto.jobId) {
|
||||||
|
case JobId.THUMBNAIL_GENERATION:
|
||||||
|
return this.runThumbnailGenerationJob();
|
||||||
|
case JobId.METADATA_EXTRACTION:
|
||||||
|
return this.runMetadataExtractionJob();
|
||||||
|
case JobId.VIDEO_CONVERSION:
|
||||||
|
return 0;
|
||||||
|
case JobId.MACHINE_LEARNING:
|
||||||
|
return this.runMachineLearningPipeline();
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('Invalid job id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
|
||||||
|
const thumbnailGeneratorJobCount = await this.thumbnailGeneratorQueue.getJobCounts();
|
||||||
|
const metadataExtractionJobCount = await this.metadataExtractionQueue.getJobCounts();
|
||||||
|
const videoConversionJobCount = await this.videoConversionQueue.getJobCounts();
|
||||||
|
const machineLearningJobCount = await this.machineLearningQueue.getJobCounts();
|
||||||
|
|
||||||
|
const response = new AllJobStatusResponseDto();
|
||||||
|
response.isThumbnailGenerationActive = Boolean(thumbnailGeneratorJobCount.waiting);
|
||||||
|
response.thumbnailGenerationQueueCount = thumbnailGeneratorJobCount;
|
||||||
|
response.isMetadataExtractionActive = Boolean(metadataExtractionJobCount.waiting);
|
||||||
|
response.metadataExtractionQueueCount = metadataExtractionJobCount;
|
||||||
|
response.isVideoConversionActive = Boolean(videoConversionJobCount.waiting);
|
||||||
|
response.videoConversionQueueCount = videoConversionJobCount;
|
||||||
|
response.isMachineLearningActive = Boolean(machineLearningJobCount.waiting);
|
||||||
|
response.machineLearningQueueCount = machineLearningJobCount;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJobStatus(query: GetJobDto): Promise<JobStatusResponseDto> {
|
||||||
|
const response = new JobStatusResponseDto();
|
||||||
|
if (query.jobId === JobId.THUMBNAIL_GENERATION) {
|
||||||
|
response.isActive = Boolean((await this.thumbnailGeneratorQueue.getJobCounts()).waiting);
|
||||||
|
response.queueCount = await this.thumbnailGeneratorQueue.getJobCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.jobId === JobId.METADATA_EXTRACTION) {
|
||||||
|
response.isActive = Boolean((await this.metadataExtractionQueue.getJobCounts()).waiting);
|
||||||
|
response.queueCount = await this.metadataExtractionQueue.getJobCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.jobId === JobId.VIDEO_CONVERSION) {
|
||||||
|
response.isActive = Boolean((await this.videoConversionQueue.getJobCounts()).waiting);
|
||||||
|
response.queueCount = await this.videoConversionQueue.getJobCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopJob(query: GetJobDto): Promise<number> {
|
||||||
|
switch (query.jobId) {
|
||||||
|
case JobId.THUMBNAIL_GENERATION:
|
||||||
|
this.thumbnailGeneratorQueue.empty();
|
||||||
|
return 0;
|
||||||
|
case JobId.METADATA_EXTRACTION:
|
||||||
|
this.metadataExtractionQueue.empty();
|
||||||
|
return 0;
|
||||||
|
case JobId.VIDEO_CONVERSION:
|
||||||
|
this.videoConversionQueue.empty();
|
||||||
|
return 0;
|
||||||
|
case JobId.MACHINE_LEARNING:
|
||||||
|
this.machineLearningQueue.empty();
|
||||||
|
return 0;
|
||||||
|
default:
|
||||||
|
throw new BadRequestException('Invalid job id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runThumbnailGenerationJob(): Promise<number> {
|
||||||
|
const jobCount = await this.thumbnailGeneratorQueue.getJobCounts();
|
||||||
|
|
||||||
|
if (jobCount.waiting > 0) {
|
||||||
|
throw new BadRequestException('Thumbnail generation job is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetsWithNoThumbnail = await this._assetRepository.getAssetWithNoThumbnail();
|
||||||
|
|
||||||
|
for (const asset of assetsWithNoThumbnail) {
|
||||||
|
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return assetsWithNoThumbnail.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMetadataExtractionJob(): Promise<number> {
|
||||||
|
const jobCount = await this.metadataExtractionQueue.getJobCounts();
|
||||||
|
|
||||||
|
if (jobCount.waiting > 0) {
|
||||||
|
throw new BadRequestException('Metadata extraction job is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetsWithNoExif = await this._assetRepository.getAssetWithNoEXIF();
|
||||||
|
for (const asset of assetsWithNoExif) {
|
||||||
|
if (asset.type === AssetType.VIDEO) {
|
||||||
|
await this.metadataExtractionQueue.add(
|
||||||
|
videoMetadataExtractionProcessorName,
|
||||||
|
{ asset, fileName: asset.id },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.metadataExtractionQueue.add(
|
||||||
|
exifExtractionProcessorName,
|
||||||
|
{ asset, fileName: asset.id },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assetsWithNoExif.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMachineLearningPipeline(): Promise<number> {
|
||||||
|
const jobCount = await this.machineLearningQueue.getJobCounts();
|
||||||
|
|
||||||
|
if (jobCount.waiting > 0) {
|
||||||
|
throw new BadRequestException('Metadata extraction job is already running');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetWithNoSmartInfo = await this._assetRepository.getAssetWithNoSmartInfo();
|
||||||
|
|
||||||
|
for (const asset of assetWithNoSmartInfo) {
|
||||||
|
await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
|
||||||
|
await this.machineLearningQueue.add(
|
||||||
|
MachineLearningJobNameEnum.OBJECT_DETECTION,
|
||||||
|
{ asset },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return assetWithNoSmartInfo.length;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class JobCounts {
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
active!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
completed!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
failed!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
delayed!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
waiting!: number;
|
||||||
|
}
|
||||||
|
export class AllJobStatusResponseDto {
|
||||||
|
isThumbnailGenerationActive!: boolean;
|
||||||
|
isMetadataExtractionActive!: boolean;
|
||||||
|
isVideoConversionActive!: boolean;
|
||||||
|
isMachineLearningActive!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: JobCounts,
|
||||||
|
})
|
||||||
|
thumbnailGenerationQueueCount!: JobCounts;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: JobCounts,
|
||||||
|
})
|
||||||
|
metadataExtractionQueueCount!: JobCounts;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: JobCounts,
|
||||||
|
})
|
||||||
|
videoConversionQueueCount!: JobCounts;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: JobCounts,
|
||||||
|
})
|
||||||
|
machineLearningQueueCount!: JobCounts;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
import Bull from 'bull';
|
||||||
|
|
||||||
|
export class JobStatusResponseDto {
|
||||||
|
isActive!: boolean;
|
||||||
|
queueCount!: Bull.JobCounts;
|
||||||
|
}
|
|
@ -5,13 +5,13 @@ export class ServerInfoResponseDto {
|
||||||
diskUse!: string;
|
diskUse!: string;
|
||||||
diskAvailable!: string;
|
diskAvailable!: string;
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
diskSizeRaw!: number;
|
diskSizeRaw!: number;
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
diskUseRaw!: number;
|
diskUseRaw!: number;
|
||||||
|
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||||
diskAvailableRaw!: number;
|
diskAvailableRaw!: number;
|
||||||
|
|
||||||
@ApiProperty({ type: 'number', format: 'float' })
|
@ApiProperty({ type: 'number', format: 'float' })
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { AppController } from './app.controller';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
||||||
import { DatabaseModule } from '@app/database';
|
import { DatabaseModule } from '@app/database';
|
||||||
|
import { JobModule } from './api-v1/job/job.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -55,6 +56,8 @@ import { DatabaseModule } from '@app/database';
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
|
||||||
ScheduleTasksModule,
|
ScheduleTasksModule,
|
||||||
|
|
||||||
|
JobModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|
|
@ -10,7 +10,7 @@ export interface IServerVersion {
|
||||||
|
|
||||||
export const serverVersion: IServerVersion = {
|
export const serverVersion: IServerVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 30,
|
minor: 31,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
build: 46,
|
build: 49,
|
||||||
};
|
};
|
||||||
|
|
|
@ -46,6 +46,14 @@ export class BackgroundTaskProcessor {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.encodedVideoPath) {
|
||||||
|
fs.unlink(asset.encodedVideoPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log('error deleting ', asset.encodedVideoPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,18 +3,14 @@ import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { ScheduleTasksService } from './schedule-tasks.service';
|
import { ScheduleTasksService } from './schedule-tasks.service';
|
||||||
import {
|
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
|
||||||
metadataExtractionQueueName,
|
|
||||||
thumbnailGeneratorQueueName,
|
|
||||||
videoConversionQueueName,
|
|
||||||
} from '@app/job/constants/queue-name.constant';
|
|
||||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: videoConversionQueueName,
|
name: QueueNameEnum.VIDEO_CONVERSION,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
@ -22,7 +18,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: thumbnailGeneratorQueueName,
|
name: QueueNameEnum.THUMBNAIL_GENERATION,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
@ -31,7 +27,7 @@ import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
}),
|
}),
|
||||||
|
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: metadataExtractionQueueName,
|
name: QueueNameEnum.METADATA_EXTRACTION,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
|
|
@ -12,11 +12,9 @@ import {
|
||||||
generateWEBPThumbnailProcessorName,
|
generateWEBPThumbnailProcessorName,
|
||||||
IMetadataExtractionJob,
|
IMetadataExtractionJob,
|
||||||
IVideoTranscodeJob,
|
IVideoTranscodeJob,
|
||||||
metadataExtractionQueueName,
|
|
||||||
mp4ConversionProcessorName,
|
mp4ConversionProcessorName,
|
||||||
|
QueueNameEnum,
|
||||||
reverseGeocodingProcessorName,
|
reverseGeocodingProcessorName,
|
||||||
thumbnailGeneratorQueueName,
|
|
||||||
videoConversionQueueName,
|
|
||||||
videoMetadataExtractionProcessorName,
|
videoMetadataExtractionProcessorName,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
@ -30,13 +28,13 @@ export class ScheduleTasksService {
|
||||||
@InjectRepository(ExifEntity)
|
@InjectRepository(ExifEntity)
|
||||||
private exifRepository: Repository<ExifEntity>,
|
private exifRepository: Repository<ExifEntity>,
|
||||||
|
|
||||||
@InjectQueue(thumbnailGeneratorQueueName)
|
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
|
||||||
private thumbnailGeneratorQueue: Queue,
|
private thumbnailGeneratorQueue: Queue,
|
||||||
|
|
||||||
@InjectQueue(videoConversionQueueName)
|
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
|
||||||
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||||
|
|
||||||
@InjectQueue(metadataExtractionQueueName)
|
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
|
||||||
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
||||||
|
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
@ -108,11 +106,11 @@ export class ScheduleTasksService {
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||||
async extractExif() {
|
async extractExif() {
|
||||||
const exifAssets = await this.assetRepository.find({
|
const exifAssets = await this.assetRepository
|
||||||
where: {
|
.createQueryBuilder('asset')
|
||||||
exifInfo: IsNull(),
|
.leftJoinAndSelect('asset.exifInfo', 'ei')
|
||||||
},
|
.where('ei."assetId" IS NULL')
|
||||||
});
|
.getMany();
|
||||||
|
|
||||||
for (const asset of exifAssets) {
|
for (const asset of exifAssets) {
|
||||||
if (asset.type === AssetType.VIDEO) {
|
if (asset.type === AssetType.VIDEO) {
|
||||||
|
|
|
@ -4,13 +4,7 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
import {
|
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
|
||||||
assetUploadedQueueName,
|
|
||||||
generateChecksumQueueName,
|
|
||||||
metadataExtractionQueueName,
|
|
||||||
thumbnailGeneratorQueueName,
|
|
||||||
videoConversionQueueName,
|
|
||||||
} from '@app/job/constants/queue-name.constant';
|
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
@ -19,6 +13,7 @@ import { CommunicationModule } from '../../immich/src/api-v1/communication/commu
|
||||||
import { MicroservicesService } from './microservices.service';
|
import { MicroservicesService } from './microservices.service';
|
||||||
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
|
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
|
||||||
import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
|
import { GenerateChecksumProcessor } from './processors/generate-checksum.processor';
|
||||||
|
import { MachineLearningProcessor } from './processors/machine-learning.processor';
|
||||||
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
|
||||||
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
||||||
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
||||||
|
@ -42,7 +37,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue(
|
BullModule.registerQueue(
|
||||||
{
|
{
|
||||||
name: thumbnailGeneratorQueueName,
|
name: QueueNameEnum.THUMBNAIL_GENERATION,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
@ -50,7 +45,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: assetUploadedQueueName,
|
name: QueueNameEnum.ASSET_UPLOADED,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
@ -58,7 +53,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: metadataExtractionQueueName,
|
name: QueueNameEnum.METADATA_EXTRACTION,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
@ -66,7 +61,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: videoConversionQueueName,
|
name: QueueNameEnum.VIDEO_CONVERSION,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
@ -74,7 +69,15 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: generateChecksumQueueName,
|
name: QueueNameEnum.CHECKSUM_GENERATION,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: QueueNameEnum.MACHINE_LEARNING,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
|
@ -92,6 +95,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||||
MetadataExtractionProcessor,
|
MetadataExtractionProcessor,
|
||||||
VideoTranscodeProcessor,
|
VideoTranscodeProcessor,
|
||||||
GenerateChecksumProcessor,
|
GenerateChecksumProcessor,
|
||||||
|
MachineLearningProcessor,
|
||||||
ConfigService,
|
ConfigService,
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [],
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { generateChecksumQueueName } from '@app/job';
|
import { QueueNameEnum } from '@app/job';
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
|
@ -6,14 +6,18 @@ import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MicroservicesService implements OnModuleInit {
|
export class MicroservicesService implements OnModuleInit {
|
||||||
constructor (
|
constructor(
|
||||||
@InjectQueue(generateChecksumQueueName)
|
@InjectQueue(QueueNameEnum.CHECKSUM_GENERATION)
|
||||||
private generateChecksumQueue: Queue,
|
private generateChecksumQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.generateChecksumQueue.add({}, {
|
await this.generateChecksumQueue.add(
|
||||||
jobId: randomUUID(), delay: 10000 // wait for migration
|
{},
|
||||||
});
|
{
|
||||||
|
jobId: randomUUID(),
|
||||||
|
delay: 10000, // wait for migration
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,30 +4,27 @@ import {
|
||||||
IMetadataExtractionJob,
|
IMetadataExtractionJob,
|
||||||
IThumbnailGenerationJob,
|
IThumbnailGenerationJob,
|
||||||
IVideoTranscodeJob,
|
IVideoTranscodeJob,
|
||||||
assetUploadedQueueName,
|
|
||||||
metadataExtractionQueueName,
|
|
||||||
thumbnailGeneratorQueueName,
|
|
||||||
videoConversionQueueName,
|
|
||||||
assetUploadedProcessorName,
|
assetUploadedProcessorName,
|
||||||
exifExtractionProcessorName,
|
exifExtractionProcessorName,
|
||||||
generateJPEGThumbnailProcessorName,
|
generateJPEGThumbnailProcessorName,
|
||||||
mp4ConversionProcessorName,
|
mp4ConversionProcessorName,
|
||||||
videoMetadataExtractionProcessorName,
|
videoMetadataExtractionProcessorName,
|
||||||
|
QueueNameEnum,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||||
import { Job, Queue } from 'bull';
|
import { Job, Queue } from 'bull';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
@Processor(assetUploadedQueueName)
|
@Processor(QueueNameEnum.ASSET_UPLOADED)
|
||||||
export class AssetUploadedProcessor {
|
export class AssetUploadedProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectQueue(thumbnailGeneratorQueueName)
|
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
|
||||||
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
|
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
|
||||||
|
|
||||||
@InjectQueue(metadataExtractionQueueName)
|
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
|
||||||
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
||||||
|
|
||||||
@InjectQueue(videoConversionQueueName)
|
@InjectQueue(QueueNameEnum.VIDEO_CONVERSION)
|
||||||
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { generateChecksumQueueName } from '@app/job';
|
import { QueueNameEnum } from '@app/job';
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
@ -8,7 +8,7 @@ import fs from 'node:fs';
|
||||||
import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
|
import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
|
||||||
|
|
||||||
// TODO: just temporary task to generate previous uploaded assets.
|
// TODO: just temporary task to generate previous uploaded assets.
|
||||||
@Processor(generateChecksumQueueName)
|
@Processor(QueueNameEnum.CHECKSUM_GENERATION)
|
||||||
export class GenerateChecksumProcessor {
|
export class GenerateChecksumProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
|
@ -33,7 +33,7 @@ export class GenerateChecksumProcessor {
|
||||||
const assets = await this.assetRepository.find({
|
const assets = await this.assetRepository.find({
|
||||||
where: whereStat,
|
where: whereStat,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
order: { id: 'ASC' }
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!assets?.length) {
|
if (!assets?.length) {
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||||
|
import { MachineLearningJobNameEnum, QueueNameEnum } from '@app/job';
|
||||||
|
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
|
||||||
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Job } from 'bull';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
@Processor(QueueNameEnum.MACHINE_LEARNING)
|
||||||
|
export class MachineLearningProcessor {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SmartInfoEntity)
|
||||||
|
private smartInfoRepository: Repository<SmartInfoEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Process({ name: MachineLearningJobNameEnum.IMAGE_TAGGING, concurrency: 2 })
|
||||||
|
async tagImage(job: Job<IMachineLearningJob>) {
|
||||||
|
const { asset } = job.data;
|
||||||
|
|
||||||
|
const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
|
||||||
|
thumbnailPath: asset.resizePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status == 201 && res.data.length > 0) {
|
||||||
|
const smartInfo = new SmartInfoEntity();
|
||||||
|
smartInfo.assetId = asset.id;
|
||||||
|
smartInfo.tags = [...res.data];
|
||||||
|
|
||||||
|
await this.smartInfoRepository.upsert(smartInfo, {
|
||||||
|
conflictPaths: ['assetId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Process({ name: MachineLearningJobNameEnum.OBJECT_DETECTION, concurrency: 2 })
|
||||||
|
async detectObject(job: Job<IMachineLearningJob>) {
|
||||||
|
try {
|
||||||
|
const { asset }: { asset: AssetEntity } = job.data;
|
||||||
|
|
||||||
|
const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
|
||||||
|
thumbnailPath: asset.resizePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status == 201 && res.data.length > 0) {
|
||||||
|
const smartInfo = new SmartInfoEntity();
|
||||||
|
smartInfo.assetId = asset.id;
|
||||||
|
smartInfo.objects = [...res.data];
|
||||||
|
|
||||||
|
await this.smartInfoRepository.upsert(smartInfo, {
|
||||||
|
conflictPaths: ['assetId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +1,19 @@
|
||||||
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
|
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
|
||||||
import {
|
import {
|
||||||
IExifExtractionProcessor,
|
IExifExtractionProcessor,
|
||||||
IVideoLengthExtractionProcessor,
|
IVideoLengthExtractionProcessor,
|
||||||
exifExtractionProcessorName,
|
exifExtractionProcessorName,
|
||||||
imageTaggingProcessorName,
|
|
||||||
objectDetectionProcessorName,
|
|
||||||
videoMetadataExtractionProcessorName,
|
videoMetadataExtractionProcessorName,
|
||||||
metadataExtractionQueueName,
|
|
||||||
reverseGeocodingProcessorName,
|
reverseGeocodingProcessorName,
|
||||||
IReverseGeocodingProcessor,
|
IReverseGeocodingProcessor,
|
||||||
|
QueueNameEnum,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import axios from 'axios';
|
|
||||||
import { Job } from 'bull';
|
import { Job } from 'bull';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
|
@ -43,20 +39,20 @@ function geocoderLookup(points: { latitude: number; longitude: number }[]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
geocoder.lookUp(points, 1, (err, addresses) => {
|
geocoder.lookUp(points, 1, (err, addresses) => {
|
||||||
resolve(addresses[0][0]);
|
resolve(addresses[0][0] as GeoData);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
|
const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
|
||||||
|
|
||||||
export interface AdminCode {
|
export type AdminCode = {
|
||||||
name: string;
|
name: string;
|
||||||
asciiName: string;
|
asciiName: string;
|
||||||
geoNameId: string;
|
geoNameId: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface GeoData {
|
export type GeoData = {
|
||||||
geoNameId: string;
|
geoNameId: string;
|
||||||
name: string;
|
name: string;
|
||||||
asciiName: string;
|
asciiName: string;
|
||||||
|
@ -67,8 +63,8 @@ export interface GeoData {
|
||||||
featureCode: string;
|
featureCode: string;
|
||||||
countryCode: string;
|
countryCode: string;
|
||||||
cc2?: any;
|
cc2?: any;
|
||||||
admin1Code?: AdminCode;
|
admin1Code?: AdminCode | string;
|
||||||
admin2Code?: AdminCode;
|
admin2Code?: AdminCode | string;
|
||||||
admin3Code?: any;
|
admin3Code?: any;
|
||||||
admin4Code?: any;
|
admin4Code?: any;
|
||||||
population: string;
|
population: string;
|
||||||
|
@ -77,9 +73,9 @@ export interface GeoData {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
modificationDate: string;
|
modificationDate: string;
|
||||||
distance: number;
|
distance: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
@Processor(metadataExtractionQueueName)
|
@Processor(QueueNameEnum.METADATA_EXTRACTION)
|
||||||
export class MetadataExtractionProcessor {
|
export class MetadataExtractionProcessor {
|
||||||
private isGeocodeInitialized = false;
|
private isGeocodeInitialized = false;
|
||||||
private logLevel: ImmichLogLevel;
|
private logLevel: ImmichLogLevel;
|
||||||
|
@ -91,12 +87,9 @@ export class MetadataExtractionProcessor {
|
||||||
@InjectRepository(ExifEntity)
|
@InjectRepository(ExifEntity)
|
||||||
private exifRepository: Repository<ExifEntity>,
|
private exifRepository: Repository<ExifEntity>,
|
||||||
|
|
||||||
@InjectRepository(SmartInfoEntity)
|
|
||||||
private smartInfoRepository: Repository<SmartInfoEntity>,
|
|
||||||
|
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
|
if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
|
||||||
Logger.log('Initialising Reverse Geocoding');
|
Logger.log('Initialising Reverse Geocoding');
|
||||||
geocoderInit({
|
geocoderInit({
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
@ -109,7 +102,8 @@ export class MetadataExtractionProcessor {
|
||||||
alternateNames: false,
|
alternateNames: false,
|
||||||
},
|
},
|
||||||
countries: [],
|
countries: [],
|
||||||
dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'),
|
dumpDirectory:
|
||||||
|
configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || process.cwd() + '/.reverse-geocoding-dump/',
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
this.isGeocodeInitialized = true;
|
this.isGeocodeInitialized = true;
|
||||||
Logger.log('Reverse Geocoding Initialised');
|
Logger.log('Reverse Geocoding Initialised');
|
||||||
|
@ -129,10 +123,22 @@ export class MetadataExtractionProcessor {
|
||||||
const city = geoCodeInfo.name;
|
const city = geoCodeInfo.name;
|
||||||
|
|
||||||
let state = '';
|
let state = '';
|
||||||
if (geoCodeInfo.admin2Code?.name) state += geoCodeInfo.admin2Code.name;
|
|
||||||
if (geoCodeInfo.admin1Code?.name) {
|
if (geoCodeInfo.admin2Code) {
|
||||||
if (geoCodeInfo.admin2Code?.name) state += ', ';
|
const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
|
||||||
state += geoCodeInfo.admin1Code.name;
|
state += adminCode2.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geoCodeInfo.admin1Code) {
|
||||||
|
const adminCode1 = geoCodeInfo.admin1Code as AdminCode;
|
||||||
|
|
||||||
|
if (geoCodeInfo.admin2Code) {
|
||||||
|
const adminCode2 = geoCodeInfo.admin2Code as AdminCode;
|
||||||
|
if (adminCode2.name) {
|
||||||
|
state += ', ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state += adminCode1.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { country, state, city };
|
return { country, state, city };
|
||||||
|
@ -273,48 +279,6 @@ export class MetadataExtractionProcessor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Process({ name: imageTaggingProcessorName, concurrency: 2 })
|
|
||||||
async tagImage(job: Job) {
|
|
||||||
const { asset }: { asset: AssetEntity } = job.data;
|
|
||||||
|
|
||||||
const res = await axios.post('http://immich-machine-learning:3003/image-classifier/tag-image', {
|
|
||||||
thumbnailPath: asset.resizePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status == 201 && res.data.length > 0) {
|
|
||||||
const smartInfo = new SmartInfoEntity();
|
|
||||||
smartInfo.assetId = asset.id;
|
|
||||||
smartInfo.tags = [...res.data];
|
|
||||||
|
|
||||||
await this.smartInfoRepository.upsert(smartInfo, {
|
|
||||||
conflictPaths: ['assetId'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Process({ name: objectDetectionProcessorName, concurrency: 2 })
|
|
||||||
async detectObject(job: Job) {
|
|
||||||
try {
|
|
||||||
const { asset }: { asset: AssetEntity } = job.data;
|
|
||||||
|
|
||||||
const res = await axios.post('http://immich-machine-learning:3003/object-detection/detect-object', {
|
|
||||||
thumbnailPath: asset.resizePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status == 201 && res.data.length > 0) {
|
|
||||||
const smartInfo = new SmartInfoEntity();
|
|
||||||
smartInfo.assetId = asset.id;
|
|
||||||
smartInfo.objects = [...res.data];
|
|
||||||
|
|
||||||
await this.smartInfoRepository.upsert(smartInfo, {
|
|
||||||
conflictPaths: ['assetId'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(`Failed to trigger object detection pipe line ${String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
@Process({ name: videoMetadataExtractionProcessorName, concurrency: 2 })
|
||||||
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||||
const { asset, fileName } = job.data;
|
const { asset, fileName } = job.data;
|
||||||
|
|
|
@ -5,11 +5,9 @@ import {
|
||||||
WebpGeneratorProcessor,
|
WebpGeneratorProcessor,
|
||||||
generateJPEGThumbnailProcessorName,
|
generateJPEGThumbnailProcessorName,
|
||||||
generateWEBPThumbnailProcessorName,
|
generateWEBPThumbnailProcessorName,
|
||||||
imageTaggingProcessorName,
|
|
||||||
objectDetectionProcessorName,
|
|
||||||
metadataExtractionQueueName,
|
|
||||||
thumbnailGeneratorQueueName,
|
|
||||||
JpegGeneratorProcessor,
|
JpegGeneratorProcessor,
|
||||||
|
QueueNameEnum,
|
||||||
|
MachineLearningJobNameEnum,
|
||||||
} from '@app/job';
|
} from '@app/job';
|
||||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
@ -25,8 +23,9 @@ import sharp from 'sharp';
|
||||||
import { Repository } from 'typeorm/repository/Repository';
|
import { Repository } from 'typeorm/repository/Repository';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
|
import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
|
||||||
|
import { IMachineLearningJob } from '@app/job/interfaces/machine-learning.interface';
|
||||||
|
|
||||||
@Processor(thumbnailGeneratorQueueName)
|
@Processor(QueueNameEnum.THUMBNAIL_GENERATION)
|
||||||
export class ThumbnailGeneratorProcessor {
|
export class ThumbnailGeneratorProcessor {
|
||||||
private logLevel: ImmichLogLevel;
|
private logLevel: ImmichLogLevel;
|
||||||
|
|
||||||
|
@ -34,13 +33,13 @@ export class ThumbnailGeneratorProcessor {
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
|
|
||||||
@InjectQueue(thumbnailGeneratorQueueName)
|
@InjectQueue(QueueNameEnum.THUMBNAIL_GENERATION)
|
||||||
private thumbnailGeneratorQueue: Queue,
|
private thumbnailGeneratorQueue: Queue,
|
||||||
|
|
||||||
private wsCommunicationGateway: CommunicationGateway,
|
private wsCommunicationGateway: CommunicationGateway,
|
||||||
|
|
||||||
@InjectQueue(metadataExtractionQueueName)
|
@InjectQueue(QueueNameEnum.MACHINE_LEARNING)
|
||||||
private metadataExtractionQueue: Queue,
|
private machineLearningQueue: Queue<IMachineLearningJob>,
|
||||||
|
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
|
@ -62,7 +61,7 @@ export class ThumbnailGeneratorProcessor {
|
||||||
|
|
||||||
const temp = asset.originalPath.split('/');
|
const temp = asset.originalPath.split('/');
|
||||||
const originalFilename = temp[temp.length - 1].split('.')[0];
|
const originalFilename = temp[temp.length - 1].split('.')[0];
|
||||||
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
|
||||||
|
|
||||||
if (asset.type == AssetType.IMAGE) {
|
if (asset.type == AssetType.IMAGE) {
|
||||||
try {
|
try {
|
||||||
|
@ -80,8 +79,12 @@ export class ThumbnailGeneratorProcessor {
|
||||||
asset.resizePath = jpegThumbnailPath;
|
asset.resizePath = jpegThumbnailPath;
|
||||||
|
|
||||||
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
|
||||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
await this.machineLearningQueue.add(
|
||||||
|
MachineLearningJobNameEnum.OBJECT_DETECTION,
|
||||||
|
{ asset },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,8 +113,12 @@ export class ThumbnailGeneratorProcessor {
|
||||||
asset.resizePath = jpegThumbnailPath;
|
asset.resizePath = jpegThumbnailPath;
|
||||||
|
|
||||||
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||||
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
|
await this.machineLearningQueue.add(MachineLearningJobNameEnum.IMAGE_TAGGING, { asset }, { jobId: randomUUID() });
|
||||||
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
|
await this.machineLearningQueue.add(
|
||||||
|
MachineLearningJobNameEnum.OBJECT_DETECTION,
|
||||||
|
{ asset },
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
|
||||||
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
|
import { QueueNameEnum } from '@app/job';
|
||||||
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
|
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
|
||||||
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
|
|
||||||
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
|
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
|
||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
@ -11,7 +11,7 @@ import ffmpeg from 'fluent-ffmpeg';
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
@Processor(videoConversionQueueName)
|
@Processor(QueueNameEnum.VIDEO_CONVERSION)
|
||||||
export class VideoTranscodeProcessor {
|
export class VideoTranscodeProcessor {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,5 +1,20 @@
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { ConfigModuleOptions } from '@nestjs/config';
|
import { ConfigModuleOptions } from '@nestjs/config';
|
||||||
import Joi from 'joi';
|
import Joi from 'joi';
|
||||||
|
import { createSecretKey, generateKeySync } from 'node:crypto'
|
||||||
|
|
||||||
|
const jwtSecretValidator: Joi.CustomValidator<string> = (value, ) => {
|
||||||
|
const key = createSecretKey(value, "base64")
|
||||||
|
const keySizeBits = (key.symmetricKeySize ?? 0) * 8
|
||||||
|
|
||||||
|
if (keySizeBits < 128) {
|
||||||
|
const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64')
|
||||||
|
Logger.warn("The current JWT_SECRET key is insecure. It should be at least 128 bits long!")
|
||||||
|
Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export const immichAppConfig: ConfigModuleOptions = {
|
export const immichAppConfig: ConfigModuleOptions = {
|
||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
|
@ -9,7 +24,7 @@ export const immichAppConfig: ConfigModuleOptions = {
|
||||||
DB_USERNAME: Joi.string().required(),
|
DB_USERNAME: Joi.string().required(),
|
||||||
DB_PASSWORD: Joi.string().required(),
|
DB_PASSWORD: Joi.string().required(),
|
||||||
DB_DATABASE_NAME: Joi.string().required(),
|
DB_DATABASE_NAME: Joi.string().required(),
|
||||||
JWT_SECRET: Joi.string().required(),
|
JWT_SECRET: Joi.string().required().custom(jwtSecretValidator),
|
||||||
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
||||||
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
|
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
|
||||||
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
||||||
|
|
|
@ -20,5 +20,12 @@ export const generateWEBPThumbnailProcessorName = 'generate-webp-thumbnail';
|
||||||
export const exifExtractionProcessorName = 'exif-extraction';
|
export const exifExtractionProcessorName = 'exif-extraction';
|
||||||
export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
|
export const videoMetadataExtractionProcessorName = 'extract-video-metadata';
|
||||||
export const reverseGeocodingProcessorName = 'reverse-geocoding';
|
export const reverseGeocodingProcessorName = 'reverse-geocoding';
|
||||||
export const objectDetectionProcessorName = 'detect-object';
|
|
||||||
export const imageTaggingProcessorName = 'tag-image';
|
/**
|
||||||
|
* Machine learning Queue Jobs
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum MachineLearningJobNameEnum {
|
||||||
|
OBJECT_DETECTION = 'detect-object',
|
||||||
|
IMAGE_TAGGING = 'tag-image',
|
||||||
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue