1
0
Fork 0
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:
Alex Tran 2022-10-14 14:52:00 -05:00
commit 4f2c08525f
113 changed files with 1824 additions and 395 deletions

View file

@ -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

View file

@ -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 }}

View file

@ -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
View 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'

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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" />

View file

@ -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();
}
}

View file

@ -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)

View file

@ -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
/** /**

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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
}
} }
} }

View file

@ -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')

View file

@ -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

View file

@ -0,0 +1 @@
* Improve scroll thumb date info

View file

@ -0,0 +1 @@
* Fixed parsing date error prevent timeline to be loaded.

View file

@ -0,0 +1,2 @@
* Fixed run background service after being killed
* Added background backup progress notifications

View file

@ -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>

View file

@ -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",

View file

@ -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;

View file

@ -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>

View file

@ -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,

View file

@ -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>

View file

@ -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

View file

@ -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';

View file

@ -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

View file

@ -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),

View file

@ -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();

View file

@ -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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/JobId.md Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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"

View file

@ -1,4 +1,4 @@
node_modules/ node_modules/
upload/ upload/
dist/ dist/
.reverse-geocoding-dump

View file

@ -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

View file

@ -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);

View file

@ -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

View file

@ -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>,
) {} ) {}

View file

@ -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,

View file

@ -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);

View file

@ -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;
} }

View file

@ -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,

View 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;
}

View 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;
}

View 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;
}
}

View 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 {}

View 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;
}
}

View file

@ -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;
}

View file

@ -0,0 +1,6 @@
import Bull from 'bull';
export class JobStatusResponseDto {
isActive!: boolean;
queueCount!: Bull.JobCounts;
}

View file

@ -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' })

View file

@ -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: [],

View file

@ -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,
}; };

View file

@ -46,6 +46,14 @@ export class BackgroundTaskProcessor {
} }
}); });
} }
if (asset.encodedVideoPath) {
fs.unlink(asset.encodedVideoPath, (err) => {
if (err) {
console.log('error deleting ', asset.encodedVideoPath);
}
});
}
} }
} }
} }

View file

@ -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,

View file

@ -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) {

View file

@ -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: [],

View file

@ -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
},
);
} }
} }

View file

@ -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>,
) {} ) {}

View file

@ -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) {

View file

@ -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)}`);
}
}
}

View file

@ -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;

View file

@ -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)));
} }

View file

@ -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

View file

@ -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'),

View file

@ -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