Compare commits

..

34 commits
main ... 4779

Author SHA1 Message Date
Fynn Petersen-Frey
14ce849f90 update generated Isar code 2023-11-13 18:52:16 +01:00
martabal
49502f3230
fix: tests 2023-11-13 17:44:06 +01:00
martabal
fdf4f93208
fix: mobile 2023-11-13 17:12:32 +01:00
martabal
5d6bfa5a3e
merge main 2023-11-13 17:11:46 +01:00
martabal
5c37b27fcf
pr feedback 2023-11-13 17:01:48 +01:00
martabal
0ed5dc869a
pr feedback 2023-11-13 17:00:29 +01:00
martabal
ac9e2cd316
pr feedback 2023-11-13 16:46:30 +01:00
martabal
af0f2f005b
fix: e2e test 2023-11-13 12:41:20 +01:00
shalong-tanwen
543ff6f7fd conflict changes 2023-11-13 16:47:09 +05:30
martabal
f249a3761b
chore: regenerate api 2023-11-13 12:07:19 +01:00
martabal
b96f04efec
merge main 2023-11-13 11:59:47 +01:00
shalong-tanwen
1404b6441d conflict changes 2023-11-08 19:31:34 +05:30
martabal
91f8297a61
chore: regenerate api 2023-11-08 14:48:50 +01:00
martabal
97206faadb
merge main 2023-11-08 14:47:58 +01:00
shalong-tanwen
e3d6f7adb3 feat(mobile): user avatar colors 2023-11-06 01:30:22 +05:30
martabal
000e1f17c5
merge main 2023-11-05 18:33:30 +01:00
martabal
ee4120c5f7
merge main 2023-11-05 18:32:07 +01:00
martabal
6faa597aaf
fix: tests 2023-11-04 17:14:24 +01:00
martabal
21210ca297
fix: svelte file in correct folder 2023-11-04 15:47:09 +01:00
martabal
11f1ade8f2
pr feedback 2023-11-04 15:39:48 +01:00
martabal
40c1bfa27b
merge main 2023-11-04 15:23:49 +01:00
martabal
6c2bf550bc
chore: regenerate api 2023-11-04 15:22:37 +01:00
martabal
f28c369c16
merge main 2023-11-04 15:21:58 +01:00
martabal
fb9b854bf1
merge main 2023-11-04 15:21:33 +01:00
martabal
fe9348e049
remove autoColor from UserAvatar 2023-11-01 22:57:55 +01:00
martabal
8a6af72588
fix: do not use fix height and width 2023-11-01 21:20:29 +01:00
martabal
610b03d16c
pr feedback 2023-11-01 21:02:34 +01:00
martabal
3d6eadb595
fix: tests 2023-11-01 20:32:03 +01:00
martabal
68ebcf218d
fix: tests 2023-11-01 20:23:11 +01:00
martabal
46d640b7a1
pr feedback 2023-11-01 20:13:21 +01:00
martabal
839cc4f3f8
fix: tests 2023-11-01 19:14:46 +01:00
martabal
4f77d06592
feat: random avatar color on user creation 2023-11-01 19:06:35 +01:00
martabal
8c1e782f8f
fix: tests 2023-11-01 18:52:23 +01:00
martabal
cda2ff3d1f
feat: user avatar color 2023-11-01 18:22:49 +01:00
675 changed files with 9568 additions and 23586 deletions

View file

@ -1,20 +0,0 @@
.vscode/
cli/
design/
docker/
docs/
fastlane/
machine-learning/
misc/
mobile/
server/node_modules
server/coverage/
server/.reverse-geocoding-dump/
server/upload/
server/dist/
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/

4
.gitattributes vendored
View file

@ -5,8 +5,6 @@ mobile/openapi/**/*.dart linguist-generated=true
mobile/openapi/.openapi-generator/FILES -diff -merge mobile/openapi/.openapi-generator/FILES -diff -merge
mobile/openapi/.openapi-generator/FILES linguist-generated=true mobile/openapi/.openapi-generator/FILES linguist-generated=true
mobile/lib/**/*.g.dart -diff -merge
mobile/lib/**/*.g.dart linguist-generated=true
cli/src/api/open-api/**/*.md -diff -merge cli/src/api/open-api/**/*.md -diff -merge
cli/src/api/open-api/**/*.md linguist-generated=true cli/src/api/open-api/**/*.md linguist-generated=true
@ -17,5 +15,3 @@ web/src/api/open-api/**/*.md -diff -merge
web/src/api/open-api/**/*.md linguist-generated=true web/src/api/open-api/**/*.md linguist-generated=true
web/src/api/open-api/**/*.ts -diff -merge web/src/api/open-api/**/*.ts -diff -merge
web/src/api/open-api/**/*.ts linguist-generated=true web/src/api/open-api/**/*.ts linguist-generated=true
*.sh text eol=lf

2
.github/FUNDING.yml vendored
View file

@ -1,5 +1,5 @@
# These are supported funding model platforms # These are supported funding model platforms
github: immich-app github: alextran1502
liberapay: alex.tran1502 liberapay: alex.tran1502
custom: https://www.buymeacoffee.com/altran1502 custom: https://www.buymeacoffee.com/altran1502

View file

@ -20,7 +20,7 @@ jobs:
name: Build and sign Android name: Build and sign Android
# Skip when PR from a fork # Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' }} if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' }}
runs-on: macos-13 runs-on: macos-12
steps: steps:
- name: Determine ref - name: Determine ref
@ -35,7 +35,7 @@ jobs:
with: with:
ref: ${{ steps.get-ref.outputs.ref }} ref: ${{ steps.get-ref.outputs.ref }}
- uses: actions/setup-java@v4 - uses: actions/setup-java@v3
with: with:
distribution: "zulu" distribution: "zulu"
java-version: "12.x" java-version: "12.x"

View file

@ -1,4 +1,4 @@
name: Cache Cleanup name: Clean up actions cache on PR close
on: on:
pull_request: pull_request:
types: types:
@ -10,7 +10,6 @@ concurrency:
jobs: jobs:
cleanup: cleanup:
name: Cleanup
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code - name: Check out code

View file

@ -1,23 +0,0 @@
name: CLI Release
on:
workflow_dispatch:
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./cli
steps:
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View file

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.head.repo.fork }} if: ${{ !github.event.pull_request.head.repo.fork }}
steps: steps:
- uses: actions/github-script@v7 - uses: actions/github-script@v6
with: with:
github-token: ${{ secrets.GH_TOKEN }} github-token: ${{ secrets.GH_TOKEN }}
script: | script: |

View file

@ -5,7 +5,7 @@
# #
# This workflow will not trigger runs on forked repos. # This workflow will not trigger runs on forked repos.
name: Docker Cleanup name: Cleanup Old Docker Images
on: on:
pull_request: pull_request:
@ -29,11 +29,14 @@ jobs:
include: include:
- primary-name: "immich-server" - primary-name: "immich-server"
- primary-name: "immich-machine-learning" - primary-name: "immich-machine-learning"
- primary-name: "immich-web"
- primary-name: "immich-proxy"
env: env:
# Requires a personal access token with the OAuth scope delete:packages # Requires a personal access token with the OAuth scope delete:packages
TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }} TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }}
steps: steps:
- name: Clean temporary images -
name: Clean temporary images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.4.0 uses: stumpylog/image-cleaner-action/ephemeral@v0.4.0
with: with:
@ -57,12 +60,15 @@ jobs:
include: include:
- primary-name: "immich-server" - primary-name: "immich-server"
- primary-name: "immich-machine-learning" - primary-name: "immich-machine-learning"
- primary-name: "immich-web"
- primary-name: "immich-proxy"
- primary-name: "immich-build-cache" - primary-name: "immich-build-cache"
env: env:
# Requires a personal access token with the OAuth scope delete:packages # Requires a personal access token with the OAuth scope delete:packages
TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }} TOKEN: ${{ secrets.PACKAGE_DELETE_TOKEN }}
steps: steps:
- name: Clean untagged images -
name: Clean untagged images
if: "${{ env.TOKEN != '' }}" if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.4.0 uses: stumpylog/image-cleaner-action/untagged@v0.4.0
with: with:

View file

@ -1,4 +1,4 @@
name: Docker name: Build and Push Docker Images
on: on:
workflow_dispatch: workflow_dispatch:
@ -18,19 +18,22 @@ permissions:
jobs: jobs:
build_and_push: build_and_push:
name: Build and Push
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
# Prevent a failure in one image from stopping the other builds # Prevent a failure in one image from stopping the other builds
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- context: "web"
image: "immich-web"
platforms: "linux/amd64,linux/arm64"
- context: "machine-learning" - context: "machine-learning"
file: "machine-learning/Dockerfile"
image: "immich-machine-learning" image: "immich-machine-learning"
platforms: "linux/amd64,linux/arm64" platforms: "linux/amd64,linux/arm64"
- context: "." - context: "nginx"
file: "server/Dockerfile" image: "immich-proxy"
platforms: "linux/amd64,linux/arm64"
- context: "server"
image: "immich-server" image: "immich-server"
platforms: "linux/arm64,linux/amd64" platforms: "linux/arm64,linux/amd64"
@ -97,10 +100,9 @@ jobs:
fi fi
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.0.0
with: with:
context: ${{ matrix.context }} context: ${{ matrix.context }}
file: ${{ matrix.file }}
platforms: ${{ matrix.platforms }} platforms: ${{ matrix.platforms }}
# Skip pushing when PR from a fork # Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }} push: ${{ !github.event.pull_request.head.repo.fork }}

View file

@ -32,8 +32,3 @@ jobs:
- name: Run dart analyze - name: Run dart analyze
run: dart analyze --fatal-infos run: dart analyze --fatal-infos
working-directory: ./mobile working-directory: ./mobile
# Enable after riverpod generator migration is completed
# - name: Run dart custom lint
# run: dart run custom_lint
# working-directory: ./mobile

View file

@ -11,7 +11,7 @@ concurrency:
jobs: jobs:
e2e-tests: e2e-tests:
name: Server (e2e) name: Run end-to-end test suites
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -24,7 +24,7 @@ jobs:
run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
doc-tests: doc-tests:
name: Docs name: Run documentation checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -45,12 +45,8 @@ jobs:
run: npm run check run: npm run check
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run build
run: npm run build
if: ${{ !cancelled() }}
server-unit-tests: server-unit-tests:
name: Server name: Run server unit test suites and checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -80,7 +76,7 @@ jobs:
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
cli-unit-tests: cli-unit-tests:
name: CLI name: Run cli test suites
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -101,16 +97,12 @@ jobs:
run: npm run format run: npm run format
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage - name: Run unit tests & coverage
run: npm run test:cov run: npm run test:cov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
web-unit-tests: web-unit-tests:
name: Web name: Run web unit test suites and checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -144,7 +136,7 @@ jobs:
# if: ${{ !cancelled() }} # if: ${{ !cancelled() }}
mobile-unit-tests: mobile-unit-tests:
name: Mobile name: Run mobile unit tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -158,7 +150,7 @@ jobs:
run: flutter test -j 1 run: flutter test -j 1
ml-unit-tests: ml-unit-tests:
name: Machine Learning name: Run ML unit tests and checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@ -176,19 +168,19 @@ jobs:
poetry install --with dev poetry install --with dev
- name: Lint with ruff - name: Lint with ruff
run: | run: |
poetry run ruff check --format=github app export poetry run ruff check --format=github app
- name: Check black formatting - name: Check black formatting
run: | run: |
poetry run black --check app export poetry run black --check app
- name: Run mypy type checking - name: Run mypy type checking
run: | run: |
poetry run mypy --install-types --non-interactive --strict app/ export/ poetry run mypy --install-types --non-interactive app/
- name: Run tests and coverage - name: Run tests and coverage
run: | run: |
poetry run pytest --cov app poetry run pytest --cov app
generated-api-up-to-date: generated-api-up-to-date:
name: OpenAPI Clients name: Check generated files are up-to-date
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -209,11 +201,11 @@ jobs:
exit 1 exit 1
generated-typeorm-migrations-up-to-date: generated-typeorm-migrations-up-to-date:
name: TypeORM Checks name: Check generated TypeORM migrations are up-to-date
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: postgres:
image: postgres@sha256:6dfee32131933ab4ca25a00360c3f427fdc134de56f9a90c6c9a4956b48aea85 image: postgres
env: env:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres
@ -236,7 +228,7 @@ jobs:
- name: Install server dependencies - name: Install server dependencies
run: npm ci run: npm ci
- name: Build the app - name: Build the
run: npm run build run: npm run build
- name: Run existing migrations - name: Run existing migrations
@ -252,30 +244,13 @@ jobs:
with: with:
files: | files: |
server/src/infra/migrations/ server/src/infra/migrations/
- name: Verify migration files have not changed - name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
run: | run: |
echo "ERROR: Generated migration files not up to date!" echo "ERROR: Generated files not up to date!"
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}" echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
exit 1 exit 1
- name: Run SQL generation
run: npm run sql:generate
- name: Find file changes
uses: tj-actions/verify-changed-files@v13.1
id: verify-changed-sql-files
with:
files: |
server/src/infra/sql
- name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
run: |
echo "ERROR: Generated SQL files not up to date!"
echo "Changed files: ${{ steps.verify-changed-sql-files.outputs.changed_files }}"
exit 1
# mobile-integration-tests: # mobile-integration-tests:
# name: Run mobile end-to-end integration tests # name: Run mobile end-to-end integration tests
# runs-on: macos-latest # runs-on: macos-latest

View file

@ -26,10 +26,7 @@ prod-scale:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api: api:
npm --prefix server run api:generate cd ./server && npm run api:generate
sql:
npm --prefix server run sql:generate
attach-server: attach-server:
docker exec -it docker_immich-server_1 sh docker exec -it docker_immich-server_1 sh

View file

@ -18,16 +18,14 @@
</a> </a>
<br/> <br/>
<p align="center"> <p align="center">
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_zh_CN.md">中文</a> <a href="README_it_IT.md">Italiano</a>
</p> </p>
## Disclaimer ## Disclaimer
@ -87,7 +85,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Virtual scroll | Yes | Yes | | Virtual scroll | Yes | Yes |
| OAuth support | Yes | Yes | | OAuth support | Yes | Yes |
| API Keys | N/A | Yes | | API Keys | N/A | Yes |
| LivePhoto/MotionPhoto backup and playback | Yes | Yes | | LivePhoto backup and playback | iOS | Yes |
| User-defined storage structure | Yes | Yes | | User-defined storage structure | Yes | Yes |
| Public Sharing | No | Yes | | Public Sharing | No | Yes |
| Archive and Favorites | Yes | Yes | | Archive and Favorites | Yes | Yes |
@ -97,7 +95,6 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Memories (x years ago) | Yes | Yes | | Memories (x years ago) | Yes | Yes |
| Offline support | Yes | No | | Offline support | Yes | No |
| Read-only gallery | Yes | Yes | | Read-only gallery | Yes | Yes |
| Stacked Photos | Yes | Yes |
## Support the project ## Support the project
@ -114,7 +111,6 @@ If you feel like this is the right cause and the app is something you are seeing
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
## Contributors ## Contributors
<a href="https://github.com/alextran1502/immich/graphs/contributors"> <a href="https://github.com/alextran1502/immich/graphs/contributors">

View file

@ -19,15 +19,13 @@
<br/> <br/>
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Español</a> <a href="README_ca_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_zh_CN.md">中文</a> <a href="README_it_IT.md">Italiano</a>
</p> </p>
## Avís legal ## Avís legal
@ -111,4 +109,4 @@ Si creieu que aquesta és una causa justa i l'aplicació és alguna cosa que us
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View file

@ -1,122 +0,0 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Lizenz: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login mit eigener URL">
</p>
<h3 align="center">Immich - Hoch performante, selbst gehostete Backup-Lösung für Fotos und Videos</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Haupt-Screenshot">
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
</p>
## Warnung
- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
- ⚠️ Erwarte Fehler und Änderungen mit Breaking-Changes.
- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.**
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
## Inhalt
- [Offizielle Dokumentation](https://immich.app/docs)
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
- [Demo](#demo)
- [Funktionen](#funktionen)
- [Einführung](https://immich.app/docs/overview/introduction)
- [Installation](https://immich.app/docs/install/requirements)
- [Beitragsrichtlinien](https://immich.app/docs/overview/support-the-project)
- [Unterstütze das Projekt](#unterstütze-das-projekt)
## Dokumentation
Die Hauptdokumentation, inklusive Installationsanleitungen, ist unter https://immich.app zu finden.
## Demo
Die Web-Demo kannst Du unter https://demo.immich.app finden.
Für die Handy-App kannst Du `https://demo.immich.app/api` als `Server Endpoint URL` angeben.
```bash title="Demo Credential"
Die Anmeldedaten
email: demo@immich.app
passwort: demo
```
```
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
## Funktionen
| Funktionen | Mobil | Web |
| ---------------------------------------------------- | ------ | ----- |
| Fotos & Videos hochladen und ansehen | Ja | Ja |
| Automatisches Backup wenn die App geöffnet ist | Ja | n. a. |
| Selektive Auswahl von Alben zum Sichern | Ja | n. a. |
| Fotos und Videos auf das Gerät herunterladen | Ja | Ja |
| Unterstützt mehrere Benutzer | Ja | Ja |
| Album und geteilte Alben | Ja | Ja |
| Scrollleiste | Ja | Ja |
| Unterstützt RAW Formate | Ja | Ja |
| Metadaten anzeigen (EXIF, Karte) | Ja | Ja |
| Suchen nach Metadaten, Objekten, Gesichtern und CLIP | Ja | Ja |
| Administrative Funktionen (Benutzerverwaltung) | Nein | Ja |
| Backup im Hintergrund | Ja | n. a. |
| Virtuelles Scrollen | Ja | Ja |
| OAuth Unterstützung | Ja | Ja |
| API-Schlüssel | n. a. | Ja |
| LivePhoto/MotionPhoto Backup und Wiedergabe | Ja | Ja |
| Benutzerdefinierte Speicherstruktur | Ja | Ja |
| Öffentliches Teilen | Nein | Ja |
| Archive und Favoriten | Ja | Ja |
| Globale Karte | Ja | Ja |
| Teilen mit Partner | Ja | Ja |
| Gesichtserkennung und Gruppierung | Ja | Ja |
| Rückblicke (heute vor x Jahren) | Ja | Ja |
| Offline Unterstützung | Ja | Nein |
| Schreibgeschützte Gallerie | Ja | Ja |
| Gestapelte Bilder | Ja | Ja |
## Unterstütze das Projekt
Ich habe mich diesem Projekt verpflichtet und werde nicht aufgeben. Ich werde die Dokumentation weiter aktualisieren, neue Funktionen hinzufügen und Fehler beheben. Allerdings kann ich das nicht alleine schaffen. Daher brauche ich Eure Unterstützung, um mir zusätzliche Motivation zu geben, weiterzumachen.
Wie unsere Gastgeber in der [selfhosted.show - In der Episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) gesagt haben, ist dies ein riesiges Unterfangen, welchem das Team und ich uns annehmen. In Zukunft würde ich liebend gerne Vollzeit an dem Projekt arbeiten und bitte daher um Eure Unterstützung.
Wenn Du denkst, dass dies die richtige Sache ist und dich selbst die App für eine längere Zeit nutzen siehst, dann denke bitte darüber nach, das Projekt mit einer der unten aufgelisteten Optionen zu unterstützen.
### Spenden
- [Monatliche Spende](https://github.com/sponsors/immich-app) via GitHub Sponsors
- [Einmalige Spende](https://github.com/sponsors/immich-app?frequency=one-time&sponsor=immich-app) via GitHub Sponsors
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
## Mitwirkende
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

View file

@ -19,15 +19,12 @@
<br/> <br/>
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a> <a href="README_it_IT.md">Italiano</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
</p> </p>
## Descargo de responsabilidad ## Descargo de responsabilidad
@ -112,4 +109,3 @@ Si consideras que esta es una causa justa y la aplicación es algo que te gustar
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View file

@ -18,16 +18,14 @@
</a> </a>
<br/> <br/>
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_it_IT.md">Italiano</a> <a href="README_fr_FR.md">Français</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_zh_CN.md">中文</a> <a href="README_it_IT.md">Italiano</a>
</p> </p>
## Clause de non-responsabilité ## Clause de non-responsabilité
@ -113,4 +111,3 @@ Si vous estimez que c'est pour la bonne cause et que vous prévoyez d'utiliser l
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View file

@ -19,15 +19,13 @@
<br/> <br/>
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_zh_CN.md">中文</a>
</p> </p>
## Declino di responsabilità ## Declino di responsabilità
@ -113,4 +111,3 @@ Se pensi che Immich sia una buona causa e che l'app sia qualcosa che useresti ne
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View file

@ -18,16 +18,13 @@
</a> </a>
<br/> <br/>
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a> <a href="README_it_IT.md">Italiano</a>
<a href="README_zh_CN.md">中文</a>
</p> </p>
## 免責事項 ## 免責事項

View file

@ -1,117 +0,0 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - 고성능 자체 호스팅 사진 및 동영상 백업 솔루션</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
</p>
## 주의 사항
- ⚠️ 이 프로젝트는 **매우 활발히** 개발 중입니다.
- ⚠️ 버그 및 잦은 변경 사항이 있을 수 있습니다.
- ⚠️ **사진과 동영상을 저장하는 유일한 방법으로 사용하지 마세요.**
- ⚠️ 중요한 사진과 동영상을 위해 항상 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 백업 계획을 따르세요!
## 목차
- [공식 문서](https://immich.app/docs)
- [로드맵](https://github.com/orgs/immich-app/projects/1)
- [데모](#demo)
- [기능](#features)
- [소개](https://immich.app/docs/overview/introduction)
- [설치](https://immich.app/docs/install/requirements)
- [기여 가이드](https://immich.app/docs/overview/support-the-project)
- [프로젝트 지원](#support-the-project)
## 문서
설치 가이드를 포함한 주요 문서는 https://immich.app 에서 확인할 수 있습니다.
## 데모
https://demo.immich.app 에서 웹 데모를 체험할 수 있습니다.
모바일 앱의 경우 `서버 엔드포인트 URL``https://demo.immich.app`를 입력합니다.
```bash title="Demo Credential"
자격 증명
email: demo@immich.app
password: demo
```
```
사양: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
## 기능
| 기능 | 모바일 | 웹 |
| ------------------------------------ | ----- | ----- |
| 사진, 동영상 업로드 및 보기 | 예 | 예 |
| 앱을 열 때 자동으로 백업 | 예 | N/A |
| 백업용 앨범 선택 | 예 | N/A |
| 로컬 기기로 사진 및 동영상 다운로드 | 예 | 예 |
| 다른 사용자 추가 | 예 | 예 |
| 앨범 및 공유 앨범 | 예 | 예 |
| 스와이프/드래그 가능한 스크롤 바 | 예 | 예 |
| RAW 포맷 지원 | 예 | 예 |
| 메타데이터 보기 (EXIF, 위치) | 예 | 예 |
| 메타데이터, 사물, 얼굴 및 클립으로 검색 | 예 | 예 |
| 관리 기능 (사용자 관리) | 아니요 | 예 |
| 백그라운드 백업 | 예 | N/A |
| 가상 스크롤 | 예 | 예 |
| OAuth 지원 | 예 | 예 |
| API 키 | N/A | 예 |
| 라이브 포토/모션 포토 백업 및 재생 | 예 | 예 |
| 사용자 정의 스토리지 구조 | 예 | 예 |
| 모든 사용자와 공유 | 아니요 | 예 |
| 아카이브 및 즐겨찾기 | 예 |예|
| 글로벌 지도 | 예 | 예 |
| 특정 사용자와 공유 | 예 | 예 |
| 얼굴 인식 및 클러스터링 | 예 | 예 |
| 추억 (~년 전) | 예 | 예 |
| 오프라인 지원 | 예 | 아니요 |
| 읽기 전용 갤러리 | 예 | 예 |
| 사진 스택 | 예 | 예 |
## 프로젝트 지원
저는 이 프로젝트에 전념해왔고, 앞으로도 멈추지 않을 것입니다. 문서를 업데이트하고, 새로운 기능을 추가하고, 버그를 수정하려 합니다. 하지만 혼자서는 할 수 없습니다. 계속해서 나아갈 수 있는 추가적인 동기부여를 위해 당신의 도움이 필요합니다.
[selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) 진행자가 말했듯이, 우리가 하고 있는 것은 대규모 프로젝트입니다. 언젠가는 이 일을 풀타임으로 하는 것을 희망하며, 이를 실현하기 위해 당신의 도움이 필요합니다.
만약 이에 동의하거나 이 앱을 장기간 사용하고자 한다면, 아래의 수단을 통해 이 프로젝트를 지원해 주세요.
### 후원
- GitHub 스폰서를 통한 [정기 후원](https://github.com/sponsors/alextran1502)
- GitHub 스폰서를 통한 [일시 후원](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View file

@ -18,16 +18,14 @@
</a> </a>
<br/> <br/>
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_ja_JP.md">日本語</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a> <a href="README_it_IT.md">Italiano</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
</p> </p>
## Disclaimer ## Disclaimer
@ -113,4 +111,3 @@ Als je denkt dat dit het juiste doel is en de app iets is dat je jezelf al heel
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View file

@ -19,15 +19,13 @@
<br/> <br/>
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_zh_CN.md">中文</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## Feragatname ## Feragatname
@ -110,4 +108,3 @@ Eğer bu size doğru bir amaç gibi geliyorsa ve uygulamanın uzun bir süre boy
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View file

@ -23,17 +23,16 @@
<p align="center"> <p align="center">
<a href="README.md">English</a> <a href="README.md">English</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a> <a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a> <a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a> <a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a> <a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p> </p>
## 免责声明 ## 免责声明
- ⚠️ 本项目正在 **非常活跃** 地开发中。 - ⚠️ 本项目正在 **非常活跃** 地开发中。

View file

@ -1,10 +0,0 @@
**/*.spec.js
.editorconfig
.eslintignore
.eslintrc.js
.prettierignore
.prettierrc
package-lock.json
testSetup.js
tsconfig.json
tsconfig.build.json

View file

@ -1,19 +1,46 @@
A command-line interface for interfacing with the self-hosted photo manager [Immich](https://immich.app/). A command-line interface for interfacing with Immich
Please see the [Immich CLI documentation](https://immich.app/docs/features/command-line-interface). # Getting started
# For developers $ ts-node cli/src
To run the Immich CLI from source, run the following in the cli folder: To start using the CLI, you need to login with an API key first:
$ npm run build $ ts-node cli/src login-key https://your-immich-instance/api your-api-key
$ ts-node .
You'll need ts-node, the easiest way to install it is to use npm: NOTE: This will store your api key under ~/.config/immich/auth.yml
$ npm i -g ts-node Next, you can run commands:
You can also build and install the CLI using $ ts-node cli/src server-info
$ npm run build When you're done, log out to remove the credentials from your filesystem
$ npm install -g .
$ ts-node cli/src logout
# Usage
```
Usage: immich [options] [command]
Immich command line interface
Options:
-h, --help display help for command
Commands:
upload [options] [paths...] Upload assets
import [options] [paths...] Import existing assets
server-info Display server information
login-key [instanceUrl] [apiKey] Login using an API key
help [command] display help for command
```
# Todo
- Sidecar should check both .jpg.xmp and .xmp
- Sidecar check could be case-insensitive
# Known issues
- Upload can't use sdk due to multiple issues

1385
cli/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,14 @@
{ {
"name": "@immich/cli", "name": "immich-cli",
"version": "2.0.4",
"description": "Command Line Interface (CLI) for Immich",
"main": "dist/index.js",
"bin": {
"immich": "./dist/src/index.js"
},
"license": "MIT",
"keywords": [
"immich",
"cli"
],
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.4.0",
"byte-size": "^8.1.1", "byte-size": "^8.1.1",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"commander": "^11.0.0", "commander": "^11.0.0",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"glob": "^10.3.1", "glob": "^10.3.1",
"picomatch": "^2.3.1",
"systeminformation": "^5.18.4",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
"devDependencies": { "devDependencies": {
@ -29,14 +20,14 @@
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^5.48.1",
"chai": "^4.3.7", "chai": "^4.3.7",
"eslint": "^8.43.0", "eslint": "^8.43.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^27.2.2", "eslint-plugin-jest": "^27.2.2",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unicorn": "^49.0.0", "eslint-plugin-unicorn": "^47.0.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"jest-extended": "^4.0.0", "jest-extended": "^4.0.0",
"jest-message-util": "^29.5.0", "jest-message-util": "^29.5.0",
@ -46,17 +37,15 @@
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tslib": "^2.5.3", "tslib": "^2.5.3",
"typescript": "^5.0.0" "typescript": "^4.9.4"
}, },
"scripts": { "scripts": {
"build": "tsc --project tsconfig.build.json", "build": "tsc --project tsconfig.build.json",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0", "lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"prepack": "npm run build", "prepack": "yarn build ",
"test": "jest", "test": "jest",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"format": "prettier --check .", "format": "prettier --check ."
"format:fix": "prettier --write .",
"check": "tsc --noEmit"
}, },
"jest": { "jest": {
"clearMocks": true, "clearMocks": true,
@ -73,15 +62,7 @@
"collectCoverageFrom": [ "collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s" "<rootDir>/src/**/*.(t|j)s"
], ],
"moduleNameMapper": {
"^@api(|/.*)$": "<rootDir>/src/api/$1"
},
"coverageDirectory": "./coverage", "coverageDirectory": "./coverage",
"testEnvironment": "node" "testEnvironment": "node"
},
"repository": {
"type": "git",
"url": "github:immich-app/immich",
"directory": "cli"
} }
} }

View file

@ -0,0 +1,3 @@
// ./__mocks__/axios.js
import mockAxios from 'jest-mock-axios';
export default mockAxios;

View file

@ -11,7 +11,6 @@ import {
UserApi, UserApi,
} from './open-api'; } from './open-api';
import { ApiConfiguration } from '../cores/api-configuration'; import { ApiConfiguration } from '../cores/api-configuration';
import FormData from 'form-data';
export class ImmichApi { export class ImmichApi {
public userApi: UserApi; public userApi: UserApi;
@ -36,7 +35,6 @@ export class ImmichApi {
'x-api-key': apiKey, 'x-api-key': apiKey,
}, },
}, },
formDataCtor: FormData,
}); });
this.userApi = new UserApi(this.config); this.userApi = new UserApi(this.config);

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.89.0 * The version of the OpenAPI document: 1.85.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.89.0 * The version of the OpenAPI document: 1.85.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.89.0 * The version of the OpenAPI document: 1.85.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.89.0 * The version of the OpenAPI document: 1.85.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -9,6 +9,7 @@ import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
export abstract class BaseCommand { export abstract class BaseCommand {
protected sessionService!: SessionService; protected sessionService!: SessionService;
protected immichApi!: ImmichApi; protected immichApi!: ImmichApi;
protected deviceId!: string;
protected user!: UserResponseDto; protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto; protected serverVersion!: ServerVersionResponseDto;

View file

@ -1,19 +1,15 @@
import { BaseCommand } from '../cli/base-command'; import { BaseCommand } from '../cli/base-command';
export default class ServerInfo extends BaseCommand { export default class ServerInfo extends BaseCommand {
static description = 'Display server information';
static enableJsonFlag = true;
public async run() { public async run() {
console.log('Getting server information');
await this.connect(); await this.connect();
const { data: versionInfo } = await this.immichApi.serverInfoApi.getServerVersion(); const { data: versionInfo } = await this.immichApi.serverInfoApi.getServerVersion();
console.log(`Server is running version ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`); console.log(versionInfo);
const { data: supportedmedia } = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
console.log(`Supported image types: ${supportedmedia.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Supported video types: ${supportedmedia.video.map((extension) => extension.replace('.', ''))}`);
const { data: statistics } = await this.immichApi.assetApi.getAssetStatistics();
console.log(`Images: ${statistics.images}, Videos: ${statistics.videos}, Total: ${statistics.total}`);
} }
} }

View file

@ -1,38 +1,43 @@
import { Asset } from '../cores/models/asset'; import { BaseCommand } from '../cli/base-command';
import { CrawlService } from '../services'; import { CrawledAsset } from '../cores/models/crawled-asset';
import { CrawlService, UploadService } from '../services';
import * as si from 'systeminformation';
import FormData from 'form-data';
import { UploadOptionsDto } from '../cores/dto/upload-options-dto'; import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto'; import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
import cliProgress from 'cli-progress'; import cliProgress from 'cli-progress';
import byteSize from 'byte-size'; import byteSize from 'byte-size';
import { BaseCommand } from '../cli/base-command';
import axios, { AxiosRequestConfig } from 'axios';
import FormData from 'form-data';
export default class Upload extends BaseCommand { export default class Upload extends BaseCommand {
private crawlService = new CrawlService();
private uploadService!: UploadService;
deviceId!: string;
uploadLength!: number; uploadLength!: number;
dryRun = false;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> { public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
await this.connect(); await this.connect();
const deviceId = 'CLI'; const uuid = await si.uuid();
this.deviceId = uuid.os || 'CLI';
this.uploadService = new UploadService(this.immichApi.apiConfiguration);
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes(); this.dryRun = options.dryRun;
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
const crawlOptions = new CrawlOptionsDto(); const crawlOptions = new CrawlOptionsDto();
crawlOptions.pathsToCrawl = paths; crawlOptions.pathsToCrawl = paths;
crawlOptions.recursive = options.recursive; crawlOptions.recursive = options.recursive;
crawlOptions.exclusionPatterns = options.exclusionPatterns; crawlOptions.excludePatterns = options.excludePatterns;
const crawledFiles: string[] = await crawlService.crawl(crawlOptions); const crawledFiles: string[] = await this.crawlService.crawl(crawlOptions);
if (crawledFiles.length === 0) { if (crawledFiles.length === 0) {
console.log('No assets found, exiting'); console.log('No assets found, exiting');
return; return;
} }
const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId)); const assetsToUpload = crawledFiles.map((path) => new CrawledAsset(path));
const uploadProgress = new cliProgress.SingleBar( const uploadProgress = new cliProgress.SingleBar(
{ {
@ -53,108 +58,118 @@ export default class Upload extends BaseCommand {
totalSize += asset.fileSize; totalSize += asset.fileSize;
} }
const existingAlbums = (await this.immichApi.albumApi.getAllAlbums()).data;
uploadProgress.start(totalSize, 0); uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
try { for (const asset of assetsToUpload) {
for (const asset of assetsToUpload) { uploadProgress.update({
uploadProgress.update({ filename: asset.path,
filename: asset.path, });
});
let skipUpload = false; try {
if (!options.skipHash) { if (options.import) {
const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] }; const importData = {
assetPath: asset.path,
sidecarPath: asset.sidecarPath,
deviceAssetId: asset.deviceAssetId,
deviceId: this.deviceId,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: false,
isReadOnly: options.readOnly,
};
const checkResponse = await this.immichApi.assetApi.checkBulkUpload({ if (!this.dryRun) {
assetBulkUploadCheckDto, await this.uploadService.import(importData);
});
skipUpload = checkResponse.data.results[0].action === 'reject';
}
if (!skipUpload) {
if (!options.dryRun) {
const formData = asset.getUploadFormData();
const res = await this.uploadAsset(formData);
if (options.album && asset.albumName) {
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
if (!album) {
const res = await this.immichApi.albumApi.createAlbum({
createAlbumDto: { albumName: asset.albumName },
});
album = res.data;
existingAlbums.push(album);
}
await this.immichApi.albumApi.addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: [res.data.id] } });
}
} }
} else {
totalSizeUploaded += asset.fileSize; await this.uploadAsset(asset, options.skipHash);
uploadCounter++;
} }
} catch (error) {
sizeSoFar += asset.fileSize; uploadProgress.stop();
throw error;
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
} }
} finally {
uploadProgress.stop(); sizeSoFar += asset.fileSize;
if (!asset.skipped) {
totalSizeUploaded += asset.fileSize;
uploadCounter++;
}
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
} }
uploadProgress.stop();
let messageStart; let messageStart;
if (options.dryRun) { if (this.dryRun) {
messageStart = 'Would have'; messageStart = 'Would have ';
} else { } else {
messageStart = 'Successfully'; messageStart = 'Successfully ';
} }
if (uploadCounter === 0) { if (options.import) {
console.log('All assets were already uploaded, nothing to do.'); console.log(`${messageStart} imported ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
} else { } else {
console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`); if (uploadCounter === 0) {
} console.log('All assets were already uploaded, nothing to do.');
if (options.delete) {
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
} else { } else {
console.log('Deleting assets that have been uploaded...'); console.log(`${messageStart} uploaded ${uploadCounter} assets (${byteSize(totalSizeUploaded)})`);
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic); }
deletionProgress.start(crawledFiles.length, 0); if (options.delete) {
if (this.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
} else {
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(crawledFiles.length, 0);
for (const asset of assetsToUpload) { for (const asset of assetsToUpload) {
if (!options.dryRun) { if (!this.dryRun) {
await asset.delete(); await asset.delete();
}
deletionProgress.increment();
} }
deletionProgress.increment(); deletionProgress.stop();
console.log('Deletion complete');
} }
deletionProgress.stop();
console.log('Deletion complete');
} }
} }
} }
private async uploadAsset(data: FormData): Promise<axios.AxiosResponse> { private async uploadAsset(asset: CrawledAsset, skipHash = false) {
const url = this.immichApi.apiConfiguration.instanceUrl + '/asset/upload'; await asset.readData();
const config: AxiosRequestConfig = { let skipUpload = false;
method: 'post', if (!skipHash) {
maxRedirects: 0, const checksum = await asset.hash();
url,
headers: {
'x-api-key': this.immichApi.apiConfiguration.apiKey,
...data.getHeaders(),
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
data,
};
const res = await axios(config); const checkResponse = await this.uploadService.checkIfAssetAlreadyExists(asset.path, checksum);
return res; skipUpload = checkResponse.data.results[0].action === 'reject';
}
if (skipUpload) {
asset.skipped = true;
} else {
const uploadFormData = new FormData();
uploadFormData.append('deviceAssetId', asset.deviceAssetId);
uploadFormData.append('deviceId', this.deviceId);
uploadFormData.append('fileCreatedAt', asset.fileCreatedAt);
uploadFormData.append('fileModifiedAt', asset.fileModifiedAt);
uploadFormData.append('isFavorite', String(false));
uploadFormData.append('assetData', asset.assetData, { filename: asset.path });
if (asset.sidecarData) {
uploadFormData.append('sidecarData', asset.sidecarData, {
filename: asset.sidecarPath,
contentType: 'application/xml',
});
}
if (!this.dryRun) {
await this.uploadService.upload(uploadFormData);
}
}
} }
} }

View file

@ -0,0 +1,59 @@
// Check asset-upload.config.spec.ts for complete list
// TODO: we should get this list from the server via API in the future
// Videos
const videos = ['mp4', 'webm', 'mov', '3gp', 'avi', 'm2ts', 'mts', 'mpg', 'flv', 'mkv', 'wmv'];
// Images
const heic = ['heic', 'heif'];
const jpeg = ['jpg', 'jpeg'];
const png = ['png'];
const gif = ['gif'];
const tiff = ['tif', 'tiff'];
const webp = ['webp'];
const dng = ['dng'];
const other = [
'3fr',
'ari',
'arw',
'avif',
'cap',
'cin',
'cr2',
'cr3',
'crw',
'dcr',
'nef',
'erf',
'fff',
'iiq',
'jxl',
'k25',
'kdc',
'mrw',
'orf',
'ori',
'pef',
'psd',
'raf',
'raw',
'rwl',
'sr2',
'srf',
'srw',
'orf',
'ori',
'x3f',
];
export const ACCEPTED_FILE_EXTENSIONS = [
...videos,
...jpeg,
...png,
...heic,
...gif,
...tiff,
...webp,
...dng,
...other,
];

View file

@ -1,6 +1,6 @@
export class CrawlOptionsDto { export class CrawlOptionsDto {
pathsToCrawl!: string[]; pathsToCrawl!: string[];
recursive? = false; recursive = false;
includeHidden? = false; includeHidden = false;
exclusionPatterns?: string[]; excludePatterns!: string[];
} }

View file

@ -1,9 +1,9 @@
export class UploadOptionsDto { export class UploadOptionsDto {
recursive = false; recursive = false;
exclusionPatterns!: string[]; excludePatterns!: string[];
dryRun = false; dryRun = false;
skipHash = false; skipHash = false;
delete = false; delete = false;
import = false;
readOnly = true; readOnly = true;
album = false;
} }

View file

@ -1 +1,2 @@
export * from './constants';
export * from './models'; export * from './models';

View file

@ -1,100 +0,0 @@
import * as fs from 'fs';
import { basename } from 'node:path';
import crypto from 'crypto';
import Os from 'os';
import FormData from 'form-data';
export class Asset {
readonly path: string;
readonly deviceId!: string;
assetData?: fs.ReadStream;
deviceAssetId?: string;
fileCreatedAt?: string;
fileModifiedAt?: string;
sidecarData?: fs.ReadStream;
sidecarPath?: string;
fileSize!: number;
albumName?: string;
constructor(path: string, deviceId: string) {
this.path = path;
this.deviceId = deviceId;
}
async process() {
const stats = await fs.promises.stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size;
this.albumName = this.extractAlbumName();
this.assetData = this.getReadStream(this.path);
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
try {
fs.accessSync(sideCarPath, fs.constants.R_OK);
this.sidecarData = this.getReadStream(sideCarPath);
} catch (error) {}
}
getUploadFormData(): FormData {
if (!this.assetData) throw new Error('Asset data not set');
if (!this.deviceAssetId) throw new Error('Device asset id not set');
if (!this.fileCreatedAt) throw new Error('File created at not set');
if (!this.fileModifiedAt) throw new Error('File modified at not set');
if (!this.deviceId) throw new Error('Device id not set');
const data: any = {
assetData: this.assetData as any,
deviceAssetId: this.deviceAssetId,
deviceId: this.deviceId,
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: String(false),
};
const formData = new FormData();
for (const prop in data) {
formData.append(prop, data[prop]);
}
if (this.sidecarData) {
formData.append('sidecarData', this.sidecarData);
}
return formData;
}
private getReadStream(path: string): fs.ReadStream {
return fs.createReadStream(path);
}
async delete(): Promise<void> {
return fs.promises.unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = crypto.createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = fs.createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
return await sha1(this.path);
}
private extractAlbumName(): string {
if (Os.platform() === 'win32') {
return this.path.split('\\').slice(-2)[0];
} else {
return this.path.split('/').slice(-2)[0];
}
}
}

View file

@ -0,0 +1,58 @@
import * as fs from 'fs';
import { basename } from 'node:path';
import crypto from 'crypto';
export class CrawledAsset {
public path: string;
public assetData?: fs.ReadStream;
public deviceAssetId?: string;
public fileCreatedAt?: string;
public fileModifiedAt?: string;
public sidecarData?: Buffer;
public sidecarPath?: string;
public fileSize!: number;
public skipped = false;
constructor(path: string) {
this.path = path;
}
async readData() {
this.assetData = fs.createReadStream(this.path);
}
async process() {
const stats = await fs.promises.stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
this.fileCreatedAt = stats.mtime.toISOString();
this.fileModifiedAt = stats.mtime.toISOString();
this.fileSize = stats.size;
// TODO: doesn't xmp replace the file extension? Will need investigation
const sideCarPath = `${this.path}.xmp`;
try {
fs.accessSync(sideCarPath, fs.constants.R_OK);
this.sidecarData = await fs.promises.readFile(sideCarPath);
this.sidecarPath = sideCarPath;
} catch (error) {}
}
async delete(): Promise<void> {
return fs.promises.unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = crypto.createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = fs.createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
return await sha1(this.path);
}
}

View file

@ -1 +1 @@
export * from './asset'; export * from './crawled-asset';

View file

@ -1,13 +1,9 @@
#! /usr/bin/env node
import { program, Option } from 'commander'; import { program, Option } from 'commander';
import Upload from './commands/upload'; import Upload from './commands/upload';
import ServerInfo from './commands/server-info'; import ServerInfo from './commands/server-info';
import LoginKey from './commands/login/key'; import LoginKey from './commands/login/key';
import Logout from './commands/logout';
import { version } from '../package.json';
program.name('immich').description('Immich command line interface').version(version); program.name('immich').description('Immich command line interface');
program program
.command('upload') .command('upload')
@ -16,11 +12,6 @@ program
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS')) .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(
new Option('-a, --album', 'Automatically create albums based on folder name')
.env('IMMICH_AUTO_CREATE_ALBUM')
.default(false),
)
.addOption( .addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done") new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN') .env('IMMICH_DRY_RUN')
@ -29,13 +20,33 @@ program
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.argument('[paths...]', 'One or more paths to assets to be uploaded') .argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => { .action(async (paths, options) => {
options.exclusionPatterns = options.ignore; options.excludePatterns = options.ignore;
await new Upload().run(paths, options);
});
program
.command('import')
.description('Import existing assets')
.usage('[options] [paths...]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN')
.default(false),
)
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
.argument('[paths...]', 'One or more paths to assets to be imported')
.action(async (paths, options) => {
options.import = true;
options.excludePatterns = options.ignore;
await new Upload().run(paths, options); await new Upload().run(paths, options);
}); });
program program
.command('server-info') .command('server-info')
.description('Display server information') .description('Display server information')
.action(async () => { .action(async () => {
await new ServerInfo().run(); await new ServerInfo().run();
}); });
@ -49,11 +60,4 @@ program
await new LoginKey().run(paths, options); await new LoginKey().run(paths, options);
}); });
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new Logout().run();
});
program.parse(process.argv); program.parse(process.argv);

View file

@ -1,206 +1,235 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { CrawlService } from './crawl.service';
import mockfs from 'mock-fs'; import mockfs from 'mock-fs';
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto'; import { toIncludeSameMembers } from 'jest-extended';
import { CrawlService } from '.'; import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
interface Test { const matchers = require('jest-extended');
test: string; expect.extend(matchers);
options: CrawlOptionsDto;
files: Record<string, boolean>;
}
const cwd = process.cwd(); const crawlService = new CrawlService();
const tests: Test[] = [ describe('CrawlService', () => {
{ beforeAll(() => {
test: 'should return empty when crawling an empty path list', // Write a dummy output before mock-fs to prevent some annoying errors
options: { console.log();
pathsToCrawl: [], });
},
files: {},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should exclude by file extension',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.tif'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by file extension without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.TIF'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by folder',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/raw/**'],
},
files: {
'/photos/image.jpg': true,
'/photos/raw/image.jpg': false,
'/photos/raw2/image.jpg': true,
'/photos/folder/raw/image.jpg': false,
'/photos/crawl/image.jpg': true,
},
},
{
test: 'should crawl multiple paths',
options: {
pathsToCrawl: ['/photos/', '/images/', '/albums/'],
},
files: {
'/photos/image1.jpg': true,
'/images/image2.jpg': true,
'/albums/image3.jpg': true,
},
},
{
test: 'should support globbing paths',
options: {
pathsToCrawl: ['/photos*'],
},
files: {
'/photos1/image1.jpg': true,
'/photos2/image2.jpg': true,
'/images/image3.jpg': false,
},
},
{
test: 'should crawl a single path without trailing slash',
options: {
pathsToCrawl: ['/photos'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/subfolder/image1.jpg': true,
'/photos/subfolder/image2.jpg': true,
'/image1.jpg': false,
},
},
{
test: 'should filter file extensions',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.txt': false,
'/photos/1': false,
},
},
{
test: 'should include photo and video extensions',
options: {
pathsToCrawl: ['/photos/', '/videos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.jpeg': true,
'/photos/image.heic': true,
'/photos/image.heif': true,
'/photos/image.png': true,
'/photos/image.gif': true,
'/photos/image.tif': true,
'/photos/image.tiff': true,
'/photos/image.webp': true,
'/photos/image.dng': true,
'/photos/image.nef': true,
'/videos/video.mp4': true,
'/videos/video.mov': true,
'/videos/video.webm': true,
},
},
{
test: 'should check file extensions without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.Jpg': true,
'/photos/image.jpG': true,
'/photos/image.JPG': true,
'/photos/image.jpEg': true,
'/photos/image.TIFF': true,
'/photos/image.tif': true,
'/photos/image.dng': true,
'/photos/image.NEF': true,
},
},
{
test: 'should normalize the path',
options: {
pathsToCrawl: ['/photos/1/../2'],
},
files: {
'/photos/1/image.jpg': false,
'/photos/2/image.jpg': true,
},
},
{
test: 'should return absolute paths',
options: {
pathsToCrawl: ['photos'],
},
files: {
[`${cwd}/photos/1.jpg`]: true,
[`${cwd}/photos/2.jpg`]: true,
[`/photos/3.jpg`]: false,
},
},
];
describe(CrawlService.name, () => { it('should crawl a single directory', async () => {
const sut = new CrawlService( mockfs({
['.jpg', '.jpeg', '.png', '.heif', '.heic', '.tif', '.nef', '.webp', '.tiff', '.dng', '.gif'], '/photos/image.jpg': '',
['.mov', '.mp4', '.webm'], });
);
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a single file', async () => {
mockfs({
'/photos/image.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/image.jpg'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a file and a directory', async () => {
mockfs({
'/photos/image.jpg': '',
'/images/photo.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/image.jpg', '/images/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg', '/images/photo.jpg']);
});
it('should exclude by file extension', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.tif': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.excludePatterns = ['**/*.tif'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should exclude by file extension without case sensitivity', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.tif': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.excludePatterns = ['**/*.TIF'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should exclude by folder', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/raw/image.jpg': '',
'/photos/raw2/image.jpg': '',
'/photos/folder/raw/image.jpg': '',
'/photos/crawl/image.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.excludePatterns = ['**/raw/**'];
options.recursive = true;
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg', '/photos/raw2/image.jpg', '/photos/crawl/image.jpg']);
});
it('should crawl multiple paths', async () => {
mockfs({
'/photos/image1.jpg': '',
'/images/image2.jpg': '',
'/albums/image3.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/', '/images/', '/albums/'];
options.recursive = false;
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image1.jpg', '/images/image2.jpg', '/albums/image3.jpg']);
});
it('should crawl a single path without trailing slash', async () => {
mockfs({
'/photos/image.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a single path without recursion', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/subfolder/image1.jpg': '',
'/photos/subfolder/image2.jpg': '',
'/image1.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should crawl a single path with recursion', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/subfolder/image1.jpg': '',
'/photos/subfolder/image2.jpg': '',
'/image1.jpg': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
options.recursive = true;
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers([
'/photos/image.jpg',
'/photos/subfolder/image1.jpg',
'/photos/subfolder/image2.jpg',
]);
});
it('should filter file extensions', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.txt': '',
'/photos/1': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers(['/photos/image.jpg']);
});
it('should include photo and video extensions', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.jpeg': '',
'/photos/image.heic': '',
'/photos/image.heif': '',
'/photos/image.png': '',
'/photos/image.gif': '',
'/photos/image.tif': '',
'/photos/image.tiff': '',
'/photos/image.webp': '',
'/photos/image.dng': '',
'/photos/image.nef': '',
'/videos/video.mp4': '',
'/videos/video.mov': '',
'/videos/video.webm': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/', '/videos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers([
'/photos/image.jpg',
'/photos/image.jpeg',
'/photos/image.heic',
'/photos/image.heif',
'/photos/image.png',
'/photos/image.gif',
'/photos/image.tif',
'/photos/image.tiff',
'/photos/image.webp',
'/photos/image.dng',
'/photos/image.nef',
'/videos/video.mp4',
'/videos/video.mov',
'/videos/video.webm',
]);
});
it('should check file extensions without case sensitivity', async () => {
mockfs({
'/photos/image.jpg': '',
'/photos/image.Jpg': '',
'/photos/image.jpG': '',
'/photos/image.JPG': '',
'/photos/image.jpEg': '',
'/photos/image.TIFF': '',
'/photos/image.tif': '',
'/photos/image.dng': '',
'/photos/image.NEF': '',
});
const options = new CrawlOptionsDto();
options.pathsToCrawl = ['/photos/'];
const paths: string[] = await crawlService.crawl(options);
expect(paths).toIncludeSameMembers([
'/photos/image.jpg',
'/photos/image.Jpg',
'/photos/image.jpG',
'/photos/image.JPG',
'/photos/image.jpEg',
'/photos/image.TIFF',
'/photos/image.tif',
'/photos/image.dng',
'/photos/image.NEF',
]);
});
afterEach(() => { afterEach(() => {
mockfs.restore(); mockfs.restore();
}); });
describe('crawl', () => {
for (const { test, options, files } of tests) {
it(test, async () => {
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
const actual = await sut.crawl(options);
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => file);
expect(actual.sort()).toEqual(expected.sort());
});
}
});
}); });

View file

@ -1,28 +1,47 @@
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto'; import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto';
import { ACCEPTED_FILE_EXTENSIONS } from '../cores';
import { glob } from 'glob'; import { glob } from 'glob';
import * as fs from 'fs';
export class CrawlService { export class CrawlService {
private readonly extensions!: string[]; public async crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
const pathsToCrawl: string[] = crawlOptions.pathsToCrawl;
constructor(image: string[], video: string[]) { const directories: string[] = [];
this.extensions = image.concat(video).map((extension) => extension.replace('.', '')); const crawledFiles: string[] = [];
}
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> { for await (const currentPath of pathsToCrawl) {
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; const stats = await fs.promises.stat(currentPath);
if (!pathsToCrawl) { if (stats.isFile() || stats.isSymbolicLink()) {
return Promise.resolve([]); crawledFiles.push(currentPath);
} else {
directories.push(currentPath);
}
} }
const base = pathsToCrawl.length === 1 ? pathsToCrawl[0] : `{${pathsToCrawl.join(',')}}`; let searchPattern: string;
const extensions = `*{${this.extensions}}`; if (directories.length === 1) {
searchPattern = directories[0];
} else if (directories.length === 0) {
return crawledFiles;
} else {
searchPattern = '{' + directories.join(',') + '}';
}
return glob(`${base}/**/${extensions}`, { if (crawlOptions.recursive) {
absolute: true, searchPattern = searchPattern + '/**/';
}
searchPattern = `${searchPattern}/*.{${ACCEPTED_FILE_EXTENSIONS.join(',')}}`;
const globbedFiles = await glob(searchPattern, {
nocase: true, nocase: true,
nodir: true, nodir: true,
dot: includeHidden, ignore: crawlOptions.excludePatterns,
ignore: exclusionPatterns,
}); });
const returnedFiles = crawledFiles.concat(globbedFiles);
returnedFiles.sort();
return returnedFiles;
} }
} }

View file

@ -1 +1,2 @@
export * from './upload.service';
export * from './crawl.service'; export * from './crawl.service';

View file

@ -46,7 +46,7 @@ export class SessionService {
// Check if server and api key are valid // Check if server and api key are valid
const { data: userInfo } = await this.api.userApi.getMyUserInfo().catch((error) => { const { data: userInfo } = await this.api.userApi.getMyUserInfo().catch((error) => {
throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`); throw new LoginError(`Failed to connect to the server: ${error.message}`);
}); });
console.log(`Logged in as ${userInfo.email}`); console.log(`Logged in as ${userInfo.email}`);
@ -78,7 +78,7 @@ export class SessionService {
private async ping(): Promise<void> { private async ping(): Promise<void> {
const { data: pingResponse } = await this.api.serverInfoApi.pingServer().catch((error) => { const { data: pingResponse } = await this.api.serverInfoApi.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${this.api.apiConfiguration.instanceUrl}: ${error.message}`); throw new Error(`Failed to connect to the server: ${error.message}`);
}); });
if (pingResponse.res !== 'pong') { if (pingResponse.res !== 'pong') {

View file

@ -0,0 +1,24 @@
import { UploadService } from './upload.service';
import axios from 'axios';
import FormData from 'form-data';
import { ApiConfiguration } from '../cores/api-configuration';
jest.mock('axios', () => jest.fn());
describe('UploadService', () => {
let uploadService: UploadService;
beforeEach(() => {
const apiConfiguration = new ApiConfiguration('https://example.com/api', 'key');
uploadService = new UploadService(apiConfiguration);
});
it('should call axios', async () => {
const data = new FormData();
await uploadService.upload(data);
expect(axios).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,65 @@
import axios, { AxiosRequestConfig } from 'axios';
import FormData from 'form-data';
import { ApiConfiguration } from '../cores/api-configuration';
export class UploadService {
private readonly uploadConfig: AxiosRequestConfig<any>;
private readonly checkAssetExistenceConfig: AxiosRequestConfig<any>;
private readonly importConfig: AxiosRequestConfig<any>;
constructor(apiConfiguration: ApiConfiguration) {
this.uploadConfig = {
method: 'post',
maxRedirects: 0,
url: `${apiConfiguration.instanceUrl}/asset/upload`,
headers: {
'x-api-key': apiConfiguration.apiKey,
},
maxContentLength: Number.POSITIVE_INFINITY,
maxBodyLength: Number.POSITIVE_INFINITY,
};
this.importConfig = {
method: 'post',
maxRedirects: 0,
url: `${apiConfiguration.instanceUrl}/asset/import`,
headers: {
'x-api-key': apiConfiguration.apiKey,
'Content-Type': 'application/json',
},
maxContentLength: Number.POSITIVE_INFINITY,
maxBodyLength: Number.POSITIVE_INFINITY,
};
this.checkAssetExistenceConfig = {
method: 'post',
maxRedirects: 0,
url: `${apiConfiguration.instanceUrl}/asset/bulk-upload-check`,
headers: {
'x-api-key': apiConfiguration.apiKey,
'Content-Type': 'application/json',
},
};
}
public checkIfAssetAlreadyExists(path: string, checksum: string) {
this.checkAssetExistenceConfig.data = JSON.stringify({ assets: [{ id: path, checksum: checksum }] });
// TODO: retry on 500 errors?
return axios(this.checkAssetExistenceConfig);
}
public upload(data: FormData) {
this.uploadConfig.data = data;
// TODO: retry on 500 errors?
return axios(this.uploadConfig);
}
public import(data: any) {
this.importConfig.data = data;
// TODO: retry on 500 errors?
return axios(this.importConfig);
}
}

View file

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "Node16", "module": "commonjs",
"strict": true, "strict": true,
"declaration": true, "declaration": true,
"removeComments": true, "removeComments": true,
@ -8,7 +8,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2022", "target": "es2017",
"moduleResolution": "node16", "moduleResolution": "node16",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",

View file

@ -1,5 +0,0 @@
> [!CAUTION]
> Make sure to use the docker-compose.yml of the current release:
> https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
>
> The compose file on main may not be compatible with the latest release.

View file

@ -6,34 +6,31 @@ version: "3.8"
name: immich-dev name: immich-dev
x-server-build: &server-common
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile
target: dev
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
environment:
- NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
services: services:
immich-server: immich-server:
container_name: immich_server container_name: immich_server
image: immich-server-dev:latest
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:debug immich command: npm run start:debug immich
<<: *server-common volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
ports: ports:
- 3001:3001 - 3001:3001
- 9230:9230 - 9230:9230
env_file:
- .env
environment:
- NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on: depends_on:
- redis - redis
- database - database
@ -41,13 +38,30 @@ services:
immich-microservices: immich-microservices:
container_name: immich_microservices container_name: immich_microservices
command: npm run start:debug microservices image: immich-microservices:latest
<<: *server-common
# extends: # extends:
# file: hwaccel.yml # file: hwaccel.yml
# service: hwaccel # service: hwaccel
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:debug microservices
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports: ports:
- 9231:9230 - 9231:9230
environment:
- NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on: depends_on:
- database - database
- immich-server - immich-server
@ -59,11 +73,12 @@ services:
build: build:
context: ../web context: ../web
dockerfile: Dockerfile dockerfile: Dockerfile
command: "node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000" target: dev
command: npm run dev --host
env_file: env_file:
- .env - .env
ports: ports:
- 2283:3000 - 3000:3000
- 24678:24678 - 24678:24678
volumes: volumes:
- ../web:/usr/src/app - ../web:/usr/src/app
@ -108,11 +123,11 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 image: redis:6.2-alpine@sha256:3995fe6ea6a619313e31046bd3c8643f9e70f8f2b294ff82659d409b47d06abb
database: database:
container_name: immich_postgres container_name: immich_postgres
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
env_file: env_file:
- .env - .env
environment: environment:
@ -124,5 +139,22 @@ services:
ports: ports:
- 5432:5432 - 5432:5432
immich-proxy:
container_name: immich_proxy
image: immich-proxy-dev:latest
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
build:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:8080
depends_on:
- immich-server
- immich-web
restart: unless-stopped
volumes: volumes:
model-cache: model-cache:

View file

@ -2,25 +2,19 @@ version: "3.8"
name: immich-prod name: immich-prod
x-server-build: &server-common
image: immich-server:latest
build:
context: ../
dockerfile: server/Dockerfile
volumes:
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
restart: always
services: services:
immich-server: immich-server:
container_name: immich_server container_name: immich_server
image: immich-server:latest
build:
context: ../server
dockerfile: Dockerfile
command: [ "./start-server.sh" ] command: [ "./start-server.sh" ]
<<: *server-common volumes:
ports: - ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- 2283:3001 - /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on: depends_on:
- redis - redis
- database - database
@ -28,15 +22,35 @@ services:
immich-microservices: immich-microservices:
container_name: immich_microservices container_name: immich_microservices
command: [ "./start-microservices.sh" ] image: immich-microservices:latest
<<: *server-common
# extends: # extends:
# file: hwaccel.yml # file: hwaccel.yml
# service: hwaccel # service: hwaccel
build:
context: ../server
dockerfile: Dockerfile
command: [ "./start-microservices.sh" ]
volumes:
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on: depends_on:
- redis
- database - database
- immich-server
- typesense - typesense
restart: always
immich-web:
container_name: immich_web
image: immich-web:latest
build:
context: ../web
dockerfile: Dockerfile
env_file:
- .env
restart: always
depends_on:
- immich-server - immich-server
immich-machine-learning: immich-machine-learning:
@ -65,12 +79,12 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 image: redis:6.2-alpine@sha256:3995fe6ea6a619313e31046bd3c8643f9e70f8f2b294ff82659d409b47d06abb
restart: always restart: always
database: database:
container_name: immich_postgres container_name: immich_postgres
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
env_file: env_file:
- .env - .env
environment: environment:
@ -81,5 +95,23 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data - ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
restart: always restart: always
immich-proxy:
container_name: immich_proxy
image: immich-proxy:latest
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
build:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:8080
logging:
driver: none
depends_on:
- immich-server
restart: always
volumes: volumes:
model-cache: model-cache:

View file

@ -6,9 +6,9 @@ services:
immich-server: immich-server:
image: immich-server-dev:latest image: immich-server-dev:latest
build: build:
context: ../ context: ../server
dockerfile: server/Dockerfile dockerfile: Dockerfile
target: dev target: builder
command: npm run test:e2e command: npm run test:e2e
volumes: volumes:
- ../server:/usr/src/app - ../server:/usr/src/app
@ -23,7 +23,7 @@ services:
- database - database
database: database:
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
command: -c fsync=off command: -c fsync=off
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres

View file

@ -1,13 +1,5 @@
version: "3.8" version: "3.8"
#
# WARNING: Make sure to use the docker-compose.yml of the current release:
#
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
#
# The compose file on main may not be compatible with the latest release.
#
name: immich name: immich
services: services:
@ -20,8 +12,6 @@ services:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
env_file: env_file:
- .env - .env
ports:
- 2283:3001
depends_on: depends_on:
- redis - redis
- database - database
@ -55,6 +45,13 @@ services:
- .env - .env
restart: always restart: always
immich-web:
container_name: immich_web
image: ghcr.io/immich-app/immich-web:${IMMICH_VERSION:-release}
env_file:
- .env
restart: always
typesense: typesense:
container_name: immich_typesense container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
@ -69,12 +66,12 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 image: redis:6.2-alpine@sha256:3995fe6ea6a619313e31046bd3c8643f9e70f8f2b294ff82659d409b47d06abb
restart: always restart: always
database: database:
container_name: immich_postgres container_name: immich_postgres
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 image: postgres:14-alpine@sha256:874f566dd512d79cf74f59754833e869ae76ece96716d153b0fa3e64aec88d92
env_file: env_file:
- .env - .env
environment: environment:
@ -85,6 +82,16 @@ services:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
restart: always restart: always
immich-proxy:
container_name: immich_proxy
image: ghcr.io/immich-app/immich-proxy:${IMMICH_VERSION:-release}
ports:
- 2283:8080
depends_on:
- immich-server
- immich-web
restart: always
volumes: volumes:
pgdata: pgdata:
model-cache: model-cache:

View file

@ -12,9 +12,9 @@ sidebar_position: 7
| ![cloud-cross](/img/cloud-off.svg) | Asset is only available locally and has not yet been backed up | | ![cloud-cross](/img/cloud-off.svg) | Asset is only available locally and has not yet been backed up |
| ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up in the cloud/server and still available in original on the device | | ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up in the cloud/server and still available in original on the device |
### Can I add my existing photo library? ### How can I sync an existing directory with Immich's server?
Yes, with an [external library](/docs/features/libraries.md). Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich.
### Why are only photos and not videos being uploaded to Immich? ### Why are only photos and not videos being uploaded to Immich?

View file

@ -1,6 +1,21 @@
# Reverse Proxy # Reverse Proxy
Users can deploy a custom reverse proxy that forwards requests to Immich. This way, the reverse proxy can handle TLS termination, load balancing, or other advanced features. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich. When deploying Immich it is important to understand that a reverse proxy is required in front of the server and web container. The reverse proxy acts as an intermediary between the user and container, forwarding requests to the correct container based on the URL path.
## Default Reverse Proxy
Immich provides a default nginx reverse proxy preconfigured to perform the correct routing and set the necessary headers for the server and web container to use. These headers are crucial to redirect to the correct URL and determine the client's IP address.
## Using a Different Reverse Proxy
While the reverse proxy provided by Immich works well for basic deployments, some users may want to use a different reverse proxy. Fortunately, Immich is flexible enough to accommodate different reverse proxies. Users can either:
1. Add another reverse proxy on top of Immich's reverse proxy
2. Completely replace the default reverse proxy
## Adding a Custom Reverse Proxy
Users can deploy a custom reverse proxy that forwards requests to Immich's reverse proxy. This way, the new reverse proxy can handle TLS termination, load balancing, or other advanced features, while still delegating routing decisions to Immich's reverse proxy. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. Additionally, your reverse proxy should allow for big enough uploads. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
### Nginx example config ### Nginx example config
@ -28,3 +43,7 @@ server {
} }
} }
``` ```
## Replacing the Default Reverse Proxy
Replacing Immich's default reverse proxy is an advanced deployment and support may be limited. When replacing Immich's default proxy it is important to ensure that requests to `/api/*` are routed to the server container and all other requests to the web container. Additionally, the previously mentioned headers should be configured accordingly. You may find our [nginx configuration file](https://github.com/immich-app/immich/blob/main/nginx/templates/default.conf.template) a helpful reference.

View file

@ -34,7 +34,7 @@ The web app is a [TypeScript](https://www.typescriptlang.org/) project that uses
### CLI ### CLI
The Immich CLI is an [npm](https://www.npmjs.com/) package that lets users control their Immich instance from the command line. It uses the API to perform various tasks, especially uploading assets. See the [CLI documentation](/docs/features/command-line-interface.md) for more information. The CLI is a [TypeScript](https://www.typescriptlang.org/) project that parses command line arguments to programmatically upload/import assets to an Immich server. See [Bulk Upload](/docs/features/bulk-upload.md) for more information about its usage.
## Server ## Server

View file

@ -17,5 +17,6 @@ Our [GitHub Repository](https://github.com/immich-app/immich) is a [monorepo](ht
| `machine-learning/` | Source code for the `immich-machine-learning` docker image | | `machine-learning/` | Source code for the `immich-machine-learning` docker image |
| `misc/release/` | Scripts for version pumps and draft releases | | `misc/release/` | Scripts for version pumps and draft releases |
| `mobile/` | Source code for the mobile app, both Android and iOS | | `mobile/` | Source code for the mobile app, both Android and iOS |
| `nginx/` | Source code for the `immich-proxy` docker image |
| `server/` | Source code for the `immich-server` docker image | | `server/` | Source code for the `immich-server` docker image |
| `web/` | Source code for the `web` | | `web/` | Source code for the `immich-web` docker image |

View file

@ -52,7 +52,7 @@ If you only want to do web development connected to an existing, remote backend,
3. Start the web development server 3. Start the web development server
``` ```
IMMICH_SERVER_URL=https://demo.immich.app/api npm run dev PUBLIC_IMMICH_SERVER_URL=https://demo.immich.app/api npm run dev
``` ```
## IDE setup ## IDE setup
@ -61,15 +61,9 @@ IMMICH_SERVER_URL=https://demo.immich.app/api npm run dev
Setting these in the IDE give a better developer experience, auto-formatting code on save, and providing instant feedback on lint issues. Setting these in the IDE give a better developer experience, auto-formatting code on save, and providing instant feedback on lint issues.
### Dart Code Metris
The mobile app uses DCM (Dart Code Metrics) for linting and metrics calculation. Please refer to the [Getting Started](https://dcm.dev/docs/getting-started/#installation) page for more information on setting up DCM
Note: Activating the license is not required.
### VSCode ### VSCode
Install `Flutter`, `DCM`, `Prettier`, `ESLint` and `Svelte` extensions. Install `Flutter`, `Prettier`, `ESLint` and `Svelte` extensions.
in User `settings.json` (`cmd + shift + p` and search for `Open User Settings JSON`) add the following: in User `settings.json` (`cmd + shift + p` and search for `Open User Settings JSON`) add the following:

View file

@ -13,3 +13,7 @@ Running Immich on Windows can be frustrating and there are lots of ways it can g
### NTFS Mounted Volumes ### NTFS Mounted Volumes
The docker-compose.dev.yml and docker-compose.prod.yml use volume mounts for the postgres database. On start-up, postgres will try to `chown` the data directory, but fail. See [this post](https://forums.docker.com/t/data-directory-var-lib-postgresql-data-pgdata-has-wrong-ownership/17963/24) for more information about this issue and possible solutions. The docker-compose.dev.yml and docker-compose.prod.yml use volume mounts for the postgres database. On start-up, postgres will try to `chown` the data directory, but fail. See [this post](https://forums.docker.com/t/data-directory-var-lib-postgresql-data-pgdata-has-wrong-ownership/17963/24) for more information about this issue and possible solutions.
### `Cannot read properties of null (reading 'split')`
This error occurs when trying to access the app via port `3000` instead of `2283`. During development `immich-proxy` runs on port 2283, while `immich-web` runs on `3000`.

View file

@ -0,0 +1,113 @@
# Bulk Upload (Using the CLI)
You can use the CLI to upload an existing gallery to the Immich server
[Immich CLI Repository](https://github.com/immich-app/CLI)
:::tip Google Photos Takeout
If you are looking to import your Google Photos takeout, we recommed this community maintained tool [immich-go](https://github.com/simulot/immich-go)
:::
## Requirements
- Node.js 16 or above
- Npm
## Installation
```bash
npm i -g immich
```
Pre-installed on the `immich-server` container and can be easily accessed through
```
immich
```
### Options
| Parameter | Description |
| ---------------- | ------------------------------------------------------------------- |
| --yes / -y | Assume yes on all interactive prompts |
| --recursive / -r | Include subfolders |
| --delete / -da | Delete local assets after upload |
| --key / -k | User's API key |
| --server / -s | Immich's server address |
| --threads / -t | Number of threads to use (Default 5) |
| --album/ -al | Create albums for assets based on the parent folder or a given name |
## Quick Start
Specify user's credential, Immich's server address and port and the directory you would like to upload videos/photos from.
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg
```
By default, subfolders are not included. To upload a directory including subfolder, use the --recursive option:
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/
```
### Obtain the API Key
The API key can be obtained in the user setting panel on the web interface.
![Obtain Api Key](./img/obtain-api-key.png)
---
### Run via Docker
You can run the CLI inside of a docker container to avoid needing to install anything.
:::caution Running inside Docker
Be aware that as this runs inside a container, you need to mount the folder from which you want to import into the container.
:::
```bash title="Upload current directory"
cd /DIRECTORY/WITH/IMAGES
docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
```bash title="Upload target directory"
docker run -it --rm -v "/DIRECTORY/WITH/IMAGES:/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
```bash title="Create an alias"
alias immich='docker run -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest'
immich upload --recursive --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api
```
:::tip Internal networking
If you are running the CLI container on the same machine as your Immich server, you may not be able to reach the external address. In that case, try the following steps:
1. Find the internal Docker network used by Immich via `docker network ls`.
2. Adapt the above command to pass the `--network <immich_network>` argument to `docker run`, substituting `<immich_network>` with the result from step 1.
3. Use `--server http://immich-server:3001` for the upload command instead of the external address.
```bash title="Upload to internal address"
docker run --network immich_default -it --rm -v "$(pwd):/import" ghcr.io/immich-app/immich-cli:latest upload --recursive --key HFEJ38DNSDUEG --server http://immich-server:3001
```
:::
### Run from source
```bash title="Clone Repository"
git clone https://github.com/immich-app/CLI
```
```bash title="Install dependencies"
npm install
```
```bash title="Build the project"
npm run build
```
```bash title="Run the command"
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
```

View file

@ -1,139 +0,0 @@
# The Immich CLI
Immich has a CLI that allows you to perform certain actions from the command line. This CLI replaces the [legacy CLI](https://github.com/immich-app/CLI) that was previously available. The CLI is hosted in the [cli folder of the the main Immich github repository](https://github.com/immich-app/immich/tree/main/cli).
## Features
- Upload photos and videos to Immich
- Check server version
More features are planned for the future.
:::tip Google Photos Takeout
If you are looking to import your Google Photos takeout, we recommed this community maintained tool [immich-go](https://github.com/simulot/immich-go)
:::
## Requirements
- Node.js 20.0 or above
- Npm
## Installation
```bash
npm i -g @immich/cli
```
NOTE: if you previously installed the legacy CLI, you will need to uninstall it first:
```bash
npm uninstall -g immich
```
## Usage
```
immich
```
```
Usage: immich [options] [command]
Immich command line interface
Options:
-h, --help display help for command
Commands:
upload [options] [paths...] Upload assets
server-info Display server information
login-key [instanceUrl] [apiKey] Login using an API key
logout Remove stored credentials
help [command] display help for command
```
## Commands
The upload command supports the following options:
```
Usage: immich upload [options] [paths...]
Upload assets
Arguments:
paths One or more paths to assets to be uploaded
Options:
-r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE)
-i, --ignore [paths...] Paths to ignore (env: IMMICH_IGNORE_PATHS)
-h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH)
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
--help display help for command
```
Note that the above options can read from environment variables as well.
## Quick Start
You begin by authenticating to your Immich server.
```bash
immich login-key [instanceUrl] [apiKey]
```
For instance,
```bash
immich login-key http://192.168.1.216:2283/api HFEJ38DNSDUEG
```
This will store your credentials in a file in your home directory. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually.
Once you are authenticated, you can upload assets to your Immich server.
```bash
immich upload file1.jpg file2.jpg
```
By default, subfolders are not included. To upload a directory including subfolder, use the --recursive option:
```bash
immich upload --recursive directory/
```
If you are unsure what will happen, you can use the `--dry-run` option to see what would happen without actually performing any actions.
```bash
immich upload --dry-run --recursive directory/
```
By default, the upload command will hash the files before uploading them. This is to avoid uploading the same file multiple times. If you are sure that the files are unique, you can skip this step by passing the `--skip-hash` option. Note that Immich always performs its own deduplication through hashing, so this is merely a performance consideration. If you have good bandwidth it might be faster to skip hashing.
```bash
immich upload --skip-hash --recursive directory/
```
You can automatically create albums based on the folder name by passing the `--album` option. This will automatically create albums for each uploaded asset based on the name of the folder they are in.
```bash
immich upload --album --recursive directory/
```
It is possible to skip assets matching a glob pattern by passing the `--ignore` option. See [the library documentation](docs/features/libraries.md) on how to use glob patterns. You can add several exclusion patterns if needed.
```bash
immich upload --ignore **/Raw/** --recursive directory/
```
```bash
immich upload --ignore **/Raw/** **/*.tif --recursive directory/
```
### Obtain the API Key
The API key can be obtained in the user setting panel on the web interface.
![Obtain Api Key](./img/obtain-api-key.png)

View file

@ -4,6 +4,10 @@ import MobileAppBackup from '../partials/_mobile-app-backup.md';
# Mobile App # Mobile App
:::tip
To upload from other devices, try using the [Bulk Upload CLI](/docs/features/bulk-upload.md).
:::
## Download ## Download
<MobileAppDownload /> <MobileAppDownload />

View file

@ -14,6 +14,8 @@ docker exec -it <id or name> <command> # attach to a container with a c
docker exec -it immich_server sh docker exec -it immich_server sh
docker exec -it immich_microservices sh docker exec -it immich_microservices sh
docker exec -it immich_machine_learning sh docker exec -it immich_machine_learning sh
docker exec -it immich_web sh
docker exec -it immich_proxy sh
``` ```
## Logs ## Logs
@ -24,6 +26,8 @@ docker logs <id or name> # see the logs for a specific container (by id
docker logs immich_server docker logs immich_server
docker logs immich_microservices docker logs immich_microservices
docker logs immich_machine_learning docker logs immich_machine_learning
docker logs immich_web
docker logs immich_proxy
``` ```
:::tip Follow a log :::tip Follow a log

View file

@ -2,7 +2,7 @@
To alleviate [performance issues on low-memory systems](/docs/FAQ.md#why-is-immich-slow-on-low-memory-systems-like-the-raspberry-pi) like the Raspberry Pi, you may also host Immich's machine-learning container on a more powerful system (e.g. your laptop or desktop computer): To alleviate [performance issues on low-memory systems](/docs/FAQ.md#why-is-immich-slow-on-low-memory-systems-like-the-raspberry-pi) like the Raspberry Pi, you may also host Immich's machine-learning container on a more powerful system (e.g. your laptop or desktop computer):
- Set the URL in Machine Learning Settings on the Admin Settings page to point to the designated ML system, e.g. `http://workstation:3003`. - Set `IMMICH_MACHINE_LEARNING_URL` to point to the designated ML system, e.g. `http://workstation:3003`.
- Copy the following `docker-compose.yml` to your ML system. - Copy the following `docker-compose.yml` to your ML system.
- Start the container by running `docker-compose up -d` or `docker compose up -d` (depending on your Docker version). - Start the container by running `docker-compose up -d` or `docker compose up -d` (depending on your Docker version).

View file

@ -1,58 +0,0 @@
# Remote Access
This page gives a few pointers on how to access your Immich instance from outside your LAN.
:::danger
Never forward port 2283 directly to the internet without additional configuration. This will expose the web interface via http to the internet, making you succeptible to [man in the middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) attacks.
:::
## Option 1: VPN to home network
You may use a VPN service to open an encrypted connection to your Immich instance. OpenVPN and Wireguard are two popular VPN solutions. Here is a guide on setting up VPN access to your server - [Pihole documentation](https://docs.pi-hole.net/guides/vpn/wireguard/overview/)
### Pros:
- Simple to set up and very secure.
- Single point of potential failure, i.e., the VPN software itself. Even if there is a zero-day vulnerability on Immich, you will not be at risk.
- Both Wireguard and OpenVPN are independently security-audited, so the risk of serious zero-day exploits are minimal.
### Cons:
- If you don't have a static IP address, you would need to set up a [Dynamic DNS](https://www.cloudflare.com/learning/dns/glossary/dynamic-dns/). [DuckDNS](https://www.duckdns.org/) is a free DDNS provider.
- VPN software needs to be installed and active on both server-side and client-side.
- Requires you to open a port on your router to your server.
## Option 2: Tailscale
If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation).
### Pros
- Minimal configuration needed on server and client sides.
- You are protected against zero-day vulnerabilities on Immich.
### Cons
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
- Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy
A reverse proxy is a service that sits between web servers and clients. A reverse proxy can either be hosted on the server itself or remotely. Clients can connect to the reverse proxy via https, and the proxy relays data to Immich. This setup makes most sense if you have your own domain and want to access your Immich instance just like any other website, from outside your LAN. You can also use a DDNS provider like DuckDNS or no-ip if you don't have a domain. This configuration allows the Immich Android and iphone apps to connect to your server without a VPN or tailscale app on the client side.
If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](https://immich.app/docs/administration/reverse-proxy).
You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accesible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser.
A remote reverse proxy like [Cloudflare](https://www.cloudflare.com/learning/cdn/glossary/reverse-proxy/) increases security by hiding the server IP address, which makes targeted attacks like [DDoS](https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/) harder.
### Pros
- No additional software needs to be installed client-side
- If you only need access to the web interface remotely, it is possible to set up access controls that shield you from zero-day vulnerabilities on Immich. [Cloudflare Access](https://www.cloudflare.com/zero-trust/products/access/) has a generous free tier.
### Cons
- Complex configuration
- Depending on your configuration, both the Immich web interface and API may be exposed to the internet. Immich is under very active developement and the existence of severe security vulnerabilities cannot be ruled out.

View file

@ -122,6 +122,28 @@ TYPESENSE_API_KEY=some-random-text
PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server" PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
####################################################################################
# Alternative Service Addresses - Optional
#
# This is an advanced feature for users who may be running their immich services on different hosts.
# It will not change which address or port that services bind to within their containers, but it will change where other services look for their peers.
# Note: immich-microservices is bound to 3002, but no references are made
####################################################################################
IMMICH_WEB_URL=http://immich-web:3000
IMMICH_SERVER_URL=http://immich-server:3001
####################################################################################
# Alternative API's External Address - Optional
#
# This is an advanced feature used to control the public server endpoint returned to clients during Well-known discovery.
# You should only use this if you want mobile apps to access the immich API over a custom URL. Do not include trailing slash.
# NOTE: At this time, the web app will not be affected by this setting and will continue to use the relative path: /api
# Examples: http://localhost:3001, http://immich-api.example.com, etc
####################################################################################
#IMMICH_API_URL_EXTERNAL=http://localhost:3001
################################################################################### ###################################################################################
# Immich Version - Optional # Immich Version - Optional
# #
@ -144,7 +166,7 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
From the directory you created in Step 1, (which should now contain your customized `docker-compose.yml` and `.env` files) run `docker-compose up -d`. From the directory you created in Step 1, (which should now contain your customized `docker-compose.yml` and `.env` files) run `docker-compose up -d`.
```bash title="Start the containers using docker compose command" ```bash title="Start the containers using docker compose command"
docker compose up -d docker-compose up -d # or `docker compose up -d` based on your docker-compose version
``` ```
:::tip :::tip
@ -162,7 +184,7 @@ If `IMMICH_VERSION` is set, it will need to be updated to the latest or desired
When a new version of Immich is [released](https://github.com/immich-app/immich/releases), the application can be upgraded with the following commands, run in the directory with the `docker-compose.yml` file: When a new version of Immich is [released](https://github.com/immich-app/immich/releases), the application can be upgraded with the following commands, run in the directory with the `docker-compose.yml` file:
```bash title="Upgrade Immich" ```bash title="Upgrade Immich"
docker compose pull && docker compose up -d docker-compose pull && docker-compose up -d # Or `docker compose up -d`
``` ```
:::caution Automatic Updates :::caution Automatic Updates

View file

@ -63,6 +63,21 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `MACHINE_LEARNING_HOST` | Machine Learning Host | `0.0.0.0` | machine learning | | `MACHINE_LEARNING_HOST` | Machine Learning Host | `0.0.0.0` | machine learning |
| `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning | | `MACHINE_LEARNING_PORT` | Machine Learning Port | `3003` | machine learning |
## URLs
| Variable | Description | Default | Services |
| :------------------------- | :---------------------- | :-------------------------: | :--------- |
| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy |
| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy |
| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web |
| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web |
:::info
The above paths are modifying the internal paths of the containers.
:::
## Database ## Database
| Variable | Description | Default | Services | | Variable | Description | Default | Services |
@ -173,18 +188,19 @@ Typesense URL example JSON before encoding:
| Variable | Description | Default | Services | | Variable | Description | Default | Services |
| :----------------------------------------------- | :---------------------------------------------------------------- | :-----------------: | :--------------- | | :----------------------------------------------- | :---------------------------------------------------------------- | :-----------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if <= 0) | `300` | machine learning | | `MACHINE_LEARNING_MODEL_TTL`<sup>\*1</sup> | Inactivity time (s) before a model is unloaded (disabled if <= 0) | `0` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if <= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning | | `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if <= 0) | number of CPU cores | machine learning | | `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*2</sup> | Thread count of the request thread pool (disabled if <= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning | | `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning | | `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning | | `MACHINE_LEARNING_WORKERS`<sup>\*3</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` | machine learning | | `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. \*1: This is an experimental feature. It may result in increased memory use over time when loading models repeatedly.
\*2: Since each process duplicates models in memory, changing this is not recommended unless you have abundant memory to go around. \*2: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
\*3: Since each process duplicates models in memory, changing this is not recommended unless you have abundant memory to go around.
:::info :::info

View file

@ -98,12 +98,12 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
> Note: This can take several minutes depending on your Internet speed and Unraid hardware > Note: This can take several minutes depending on your Internet speed and Unraid hardware
9. Once on the Docker page you will see several Immich containers, one of them will be labelled `immich_web` and will have a port mapping. Visit the `IP:PORT` displayed in your web browser and you should see the Immich admin setup page. 9. Once on the Docker page you will see several Immich containers, one of them will be labelled `immich_proxy` and will have a port mapping. Visit the `IP:PORT` displayed in your web browser and you should see the Immich admin setup page.
<img <img
src={require('./img/unraid06.webp').default} src={require('./img/unraid06.webp').default}
width="80%" width="80%"
alt="Go to Docker Tab and visit the address listed next to immich-web" alt="Go to Docker Tab and visit the address listed next to immich-proxy"
/> />
<details > <details >
@ -112,12 +112,12 @@ alt="Go to Docker Tab and visit the address listed next to immich-web"
<img <img
src={require('./img/unraid07.webp').default} src={require('./img/unraid07.webp').default}
width="80%" width="80%"
alt="Go to Docker Tab and visit the address listed next to immich-web" alt="Go to Docker Tab and visit the address listed next to immich-proxy"
/> />
<img <img
src={require('./img/unraid08.webp').default} src={require('./img/unraid08.webp').default}
width="90%" width="90%"
alt="Go to Docker Tab and visit the address listed next to immich-web" alt="Go to Docker Tab and visit the address listed next to immich-proxy"
/> />
</details> </details>

View file

@ -12,8 +12,8 @@ If you feel like this is the right cause and the app is something you see yourse
## Donation ## Donation
- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/immich-app) - Monthly donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502)
- One-time donation via [GitHub Sponsors](https://github.com/sponsors/immich-app?frequency=one-time) - One-time donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
- [Librepay](https://liberapay.com/alex.tran1502/) - [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502) - [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX - Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

132
docs/package-lock.json generated
View file

@ -15,7 +15,7 @@
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"clsx": "^2.0.0", "clsx": "^1.2.1",
"docusaurus-lunr-search": "^2.3.2", "docusaurus-lunr-search": "^2.3.2",
"docusaurus-preset-openapi": "^0.6.3", "docusaurus-preset-openapi": "^0.6.3",
"postcss": "^8.4.25", "postcss": "^8.4.25",
@ -28,7 +28,7 @@
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^2.4.1", "@docusaurus/module-type-aliases": "^2.4.1",
"@tsconfig/docusaurus": "^1.0.5", "@tsconfig/docusaurus": "^1.0.5",
"prettier": "^3.0.0", "prettier": "^2.8.8",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"engines": { "engines": {
@ -2603,14 +2603,6 @@
"react-dom": "^16.8.4 || ^17.0.0" "react-dom": "^16.8.4 || ^17.0.0"
} }
}, },
"node_modules/@docusaurus/theme-classic/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/@docusaurus/theme-common": { "node_modules/@docusaurus/theme-common": {
"version": "2.4.3", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-2.4.3.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-2.4.3.tgz",
@ -2641,14 +2633,6 @@
"react-dom": "^16.8.4 || ^17.0.0" "react-dom": "^16.8.4 || ^17.0.0"
} }
}, },
"node_modules/@docusaurus/theme-common/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/@docusaurus/theme-search-algolia": { "node_modules/@docusaurus/theme-search-algolia": {
"version": "2.4.3", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.4.3.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.4.3.tgz",
@ -2679,14 +2663,6 @@
"react-dom": "^16.8.4 || ^17.0.0" "react-dom": "^16.8.4 || ^17.0.0"
} }
}, },
"node_modules/@docusaurus/theme-search-algolia/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/@docusaurus/theme-translations": { "node_modules/@docusaurus/theme-translations": {
"version": "2.4.3", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-2.4.3.tgz", "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-2.4.3.tgz",
@ -4972,9 +4948,9 @@
} }
}, },
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.0.0", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@ -6019,14 +5995,6 @@
"react-dom": "^16.8.4 || ^17" "react-dom": "^16.8.4 || ^17"
} }
}, },
"node_modules/docusaurus-lunr-search/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/docusaurus-plugin-openapi": { "node_modules/docusaurus-plugin-openapi": {
"version": "0.6.4", "version": "0.6.4",
"resolved": "https://registry.npmjs.org/docusaurus-plugin-openapi/-/docusaurus-plugin-openapi-0.6.4.tgz", "resolved": "https://registry.npmjs.org/docusaurus-plugin-openapi/-/docusaurus-plugin-openapi-0.6.4.tgz",
@ -6057,14 +6025,6 @@
"react-dom": "^16.8.4 || ^17.0.0" "react-dom": "^16.8.4 || ^17.0.0"
} }
}, },
"node_modules/docusaurus-plugin-openapi/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/docusaurus-plugin-openapi/node_modules/fs-extra": { "node_modules/docusaurus-plugin-openapi/node_modules/fs-extra": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -6138,14 +6098,6 @@
"react-dom": "^16.8.4 || ^17.0.0" "react-dom": "^16.8.4 || ^17.0.0"
} }
}, },
"node_modules/docusaurus-theme-openapi/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/dom-converter": { "node_modules/dom-converter": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@ -10849,15 +10801,15 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.1.0", "version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
"integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"dev": true, "dev": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin-prettier.js"
}, },
"engines": { "engines": {
"node": ">=14" "node": ">=10.13.0"
}, },
"funding": { "funding": {
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
@ -13614,9 +13566,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.3.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -16793,13 +16745,6 @@
"rtlcss": "^3.5.0", "rtlcss": "^3.5.0",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"utility-types": "^3.10.0" "utility-types": "^3.10.0"
},
"dependencies": {
"clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
}
} }
}, },
"@docusaurus/theme-common": { "@docusaurus/theme-common": {
@ -16823,13 +16768,6 @@
"tslib": "^2.4.0", "tslib": "^2.4.0",
"use-sync-external-store": "^1.2.0", "use-sync-external-store": "^1.2.0",
"utility-types": "^3.10.0" "utility-types": "^3.10.0"
},
"dependencies": {
"clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
}
} }
}, },
"@docusaurus/theme-search-algolia": { "@docusaurus/theme-search-algolia": {
@ -16853,13 +16791,6 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"utility-types": "^3.10.0" "utility-types": "^3.10.0"
},
"dependencies": {
"clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
}
} }
}, },
"@docusaurus/theme-translations": { "@docusaurus/theme-translations": {
@ -18584,9 +18515,9 @@
} }
}, },
"clsx": { "clsx": {
"version": "2.0.0", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==" "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
}, },
"collapse-white-space": { "collapse-white-space": {
"version": "1.0.6", "version": "1.0.6",
@ -19312,13 +19243,6 @@
"to-vfile": "^6.1.0", "to-vfile": "^6.1.0",
"unified": "^9.0.0", "unified": "^9.0.0",
"unist-util-is": "^4.0.2" "unist-util-is": "^4.0.2"
},
"dependencies": {
"clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
}
} }
}, },
"docusaurus-plugin-openapi": { "docusaurus-plugin-openapi": {
@ -19344,11 +19268,6 @@
"webpack": "^5.73.0" "webpack": "^5.73.0"
}, },
"dependencies": { "dependencies": {
"clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
},
"fs-extra": { "fs-extra": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -19402,13 +19321,6 @@
"react-redux": "^7.2.0", "react-redux": "^7.2.0",
"redux-devtools-extension": "^2.13.8", "redux-devtools-extension": "^2.13.8",
"webpack": "^5.73.0" "webpack": "^5.73.0"
},
"dependencies": {
"clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
}
} }
}, },
"dom-converter": { "dom-converter": {
@ -22751,9 +22663,9 @@
"integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==" "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA=="
}, },
"prettier": { "prettier": {
"version": "3.1.0", "version": "2.8.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
"integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
"dev": true "dev": true
}, },
"pretty-error": { "pretty-error": {
@ -24845,9 +24757,9 @@
} }
}, },
"typescript": { "typescript": {
"version": "5.3.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==" "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w=="
}, },
"ua-parser-js": { "ua-parser-js": {
"version": "1.0.36", "version": "1.0.36",

View file

@ -24,7 +24,7 @@
"@mdx-js/react": "^1.6.22", "@mdx-js/react": "^1.6.22",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"clsx": "^2.0.0", "clsx": "^1.2.1",
"docusaurus-lunr-search": "^2.3.2", "docusaurus-lunr-search": "^2.3.2",
"docusaurus-preset-openapi": "^0.6.3", "docusaurus-preset-openapi": "^0.6.3",
"postcss": "^8.4.25", "postcss": "^8.4.25",
@ -37,7 +37,7 @@
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^2.4.1", "@docusaurus/module-type-aliases": "^2.4.1",
"@tsconfig/docusaurus": "^1.0.5", "@tsconfig/docusaurus": "^1.0.5",
"prettier": "^3.0.0", "prettier": "^2.8.8",
"typescript": "^5.1.6" "typescript": "^5.1.6"
}, },
"browserslist": { "browserslist": {

View file

@ -3,7 +3,6 @@ import {
mdiAndroid, mdiAndroid,
mdiAppleIos, mdiAppleIos,
mdiArchiveOutline, mdiArchiveOutline,
mdiBash,
mdiBookSearchOutline, mdiBookSearchOutline,
mdiCakeVariant, mdiCakeVariant,
mdiCheckAll, mdiCheckAll,
@ -16,7 +15,6 @@ import {
mdiFile, mdiFile,
mdiFileSearch, mdiFileSearch,
mdiFolder, mdiFolder,
mdiForum,
mdiHeart, mdiHeart,
mdiImage, mdiImage,
mdiImageAlbum, mdiImageAlbum,
@ -43,7 +41,6 @@ import {
mdiText, mdiText,
mdiThemeLightDark, mdiThemeLightDark,
mdiTrashCanOutline, mdiTrashCanOutline,
mdiVectorCombine,
mdiVideo, mdiVideo,
mdiWeb, mdiWeb,
} from '@mdi/js'; } from '@mdi/js';
@ -52,34 +49,6 @@ import React from 'react';
import Timeline, { DateType, Item } from '../components/timeline'; import Timeline, { DateType, Item } from '../components/timeline';
const items: Item[] = [ const items: Item[] = [
{
icon: mdiVectorCombine,
description:
'The serving of the web app is merged into the server image, allowing us to remove two containers from the stack.',
title: 'Container consolidation',
release: 'v1.88.0',
tag: 'v1.88.0',
date: new Date(2023, 10, 20),
dateType: DateType.RELEASE,
},
{
icon: mdiBash,
description: 'Version 2 of the Immich CLI is released, replacing the legacy v1 CLI.',
title: 'CLI v2',
release: 'v1.88.0',
tag: 'v1.88.0',
date: new Date(2023, 10, 19),
dateType: DateType.RELEASE,
},
{
icon: mdiForum,
description: 'Comment a photo or a video in a shared album',
title: 'Activity',
release: 'v1.84.0',
tag: 'v1.84.0',
date: new Date(2023, 10, 1),
dateType: DateType.RELEASE,
},
{ {
icon: mdiStar, icon: mdiStar,
description: 'Reach 20K Stars on GitHub!', description: 'Reach 20K Stars on GitHub!',

View file

@ -61,12 +61,8 @@
.searchbox__input { .searchbox__input {
display: inline-block; display: inline-block;
box-sizing: border-box; box-sizing: border-box;
-webkit-transition: -webkit-transition: box-shadow 0.4s ease, background 0.4s ease;
box-shadow 0.4s ease, transition: box-shadow 0.4s ease, background 0.4s ease;
background 0.4s ease;
transition:
box-shadow 0.4s ease,
background 0.4s ease;
border: 0; border: 0;
border-radius: 16px; border-radius: 16px;
box-shadow: inset 0 0 0 1px #cccccc; box-shadow: inset 0 0 0 1px #cccccc;
@ -247,9 +243,7 @@
} }
.algolia-autocomplete .ds-dropdown-menu { .algolia-autocomplete .ds-dropdown-menu {
box-shadow: box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2), 0 2px 3px 0 rgba(0, 0, 0, 0.1);
0 1px 0 0 rgba(0, 0, 0, 0.2),
0 2px 3px 0 rgba(0, 0, 0, 0.1);
} }
@media (min-width: 601px) { @media (min-width: 601px) {

View file

@ -12,8 +12,7 @@
{ "source": "/docs/overview/logo-meaning", "destination": "/docs/overview/logo" }, { "source": "/docs/overview/logo-meaning", "destination": "/docs/overview/logo" },
{ "source": "/docs/overview/technology-stack", "destination": "/docs/developer/architecture" }, { "source": "/docs/overview/technology-stack", "destination": "/docs/developer/architecture" },
{ "source": "/docs/usage/automatic-backup", "destination": "/docs/features/automatic-backup" }, { "source": "/docs/usage/automatic-backup", "destination": "/docs/features/automatic-backup" },
{ "source": "/docs/usage/bulk-upload", "destination": "/docs/features/command-line-interface" }, { "source": "/docs/usage/bulk-upload", "destination": "/docs/features/bulk-upload" },
{ "source": "/docs/features/bulk-upload", "destination": "/docs/features/command-line-interface" },
{ "source": "/docs/usage/oauth", "destination": "/docs/administration/oauth" }, { "source": "/docs/usage/oauth", "destination": "/docs/administration/oauth" },
{ "source": "/docs/usage/post-installation", "destination": "/docs/install/post-install" }, { "source": "/docs/usage/post-installation", "destination": "/docs/install/post-install" },
{ "source": "/docs/usage/update", "destination": "/docs/install/docker-compose#step-4---upgrading" }, { "source": "/docs/usage/update", "destination": "/docs/install/docker-compose#step-4---upgrading" },

View file

@ -1,4 +1,4 @@
FROM python:3.11-bookworm@sha256:ba7a7ac30c38e119c4304f98ef0e188f90f4f67a958bb6899da9defb99bfb471 as builder FROM python:3.11-bookworm as builder
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml ./ COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
FROM python:3.11-slim-bookworm@sha256:cc758519481092eb5a4a5ab0c1b303e288880d59afc601958d19e95b300bc86b FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*

View file

@ -13,8 +13,7 @@ from .schemas import ModelType
class Settings(BaseSettings): class Settings(BaseSettings):
cache_folder: str = "/cache" cache_folder: str = "/cache"
model_ttl: int = 300 model_ttl: int = 0
model_ttl_poll_s: int = 10
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 3003 port: int = 3003
workers: int = 1 workers: int = 1

View file

@ -36,8 +36,7 @@ def deployed_app() -> TestClient:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def responses() -> dict[str, Any]: def responses() -> dict[str, Any]:
responses: dict[str, Any] = json.load(open("responses.json", "r")) return json.load(open("responses.json", "r"))
return responses
@pytest.fixture(scope="session") @pytest.fixture(scope="session")

View file

@ -1,9 +1,5 @@
import asyncio import asyncio
import gc
import os
import sys
import threading import threading
import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from zipfile import BadZipFile from zipfile import BadZipFile
@ -11,7 +7,7 @@ from zipfile import BadZipFile
import orjson import orjson
from fastapi import FastAPI, Form, HTTPException, UploadFile from fastapi import FastAPI, Form, HTTPException, UploadFile
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile # type: ignore
from starlette.formparsers import MultiPartParser from starlette.formparsers import MultiPartParser
from app.models.base import InferenceModel from app.models.base import InferenceModel
@ -24,7 +20,7 @@ from .schemas import (
TextResponse, TextResponse,
) )
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger MultiPartParser.max_file_size = 2**24 # spools to disk if payload is 16 MiB or larger
app = FastAPI() app = FastAPI()
@ -38,10 +34,7 @@ def init_state() -> None:
) )
# asyncio is a huge bottleneck for performance, so we use a thread pool to run blocking code # asyncio is a huge bottleneck for performance, so we use a thread pool to run blocking code
app.state.thread_pool = ThreadPoolExecutor(settings.request_threads) if settings.request_threads > 0 else None app.state.thread_pool = ThreadPoolExecutor(settings.request_threads) if settings.request_threads > 0 else None
app.state.lock = threading.Lock() app.state.locks = {model_type: threading.Lock() for model_type in ModelType}
app.state.last_called = None
if settings.model_ttl > 0 and settings.model_ttl_poll_s > 0:
asyncio.ensure_future(idle_shutdown_task())
log.info(f"Initialized request thread pool with {settings.request_threads} threads.") log.info(f"Initialized request thread pool with {settings.request_threads} threads.")
@ -86,9 +79,9 @@ async def predict(
async def run(model: InferenceModel, inputs: Any) -> Any: async def run(model: InferenceModel, inputs: Any) -> Any:
app.state.last_called = time.time()
if app.state.thread_pool is None: if app.state.thread_pool is None:
return model.predict(inputs) return model.predict(inputs)
return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs) return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
@ -97,7 +90,7 @@ async def load(model: InferenceModel) -> InferenceModel:
return model return model
def _load() -> None: def _load() -> None:
with app.state.lock: with app.state.locks[model.model_type]:
model.load() model.load()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -120,27 +113,3 @@ async def load(model: InferenceModel) -> InferenceModel:
else: else:
await loop.run_in_executor(app.state.thread_pool, _load) await loop.run_in_executor(app.state.thread_pool, _load)
return model return model
async def idle_shutdown_task() -> None:
while True:
log.debug("Checking for inactivity...")
if app.state.last_called is not None and time.time() - app.state.last_called > settings.model_ttl:
log.info("Shutting down due to inactivity.")
loop = asyncio.get_running_loop()
for task in asyncio.all_tasks(loop):
if task is not asyncio.current_task():
try:
task.cancel()
except asyncio.CancelledError:
pass
sys.stderr.close()
sys.stdout.close()
sys.stdout = sys.stderr = open(os.devnull, "w")
try:
await app.state.model_cache.cache.clear()
gc.collect()
loop.stop()
except asyncio.CancelledError:
pass
await asyncio.sleep(settings.model_ttl_poll_s)

View file

@ -8,7 +8,6 @@ from typing import Any
import onnxruntime as ort import onnxruntime as ort
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from typing_extensions import Buffer
from ..config import get_cache_dir, get_hf_model_name, log, settings from ..config import get_cache_dir, get_hf_model_name, log, settings
from ..schemas import ModelType from ..schemas import ModelType
@ -140,12 +139,11 @@ class InferenceModel(ABC):
# HF deep copies configs, so we need to make session options picklable # HF deep copies configs, so we need to make session options picklable
class PicklableSessionOptions(ort.SessionOptions): # type: ignore[misc] class PicklableSessionOptions(ort.SessionOptions):
def __getstate__(self) -> bytes: def __getstate__(self) -> bytes:
return pickle.dumps([(attr, getattr(self, attr)) for attr in dir(self) if not callable(getattr(self, attr))]) return pickle.dumps([(attr, getattr(self, attr)) for attr in dir(self) if not callable(getattr(self, attr))])
def __setstate__(self, state: Buffer) -> None: def __setstate__(self, state: Any) -> None:
self.__init__() # type: ignore[misc] self.__init__() # type: ignore
attrs: list[tuple[str, Any]] = pickle.loads(state) for attr, val in pickle.loads(state):
for attr, val in attrs:
setattr(self, attr, val) setattr(self, attr, val)

View file

@ -6,7 +6,7 @@ from aiocache.plugins import BasePlugin, TimingPlugin
from app.models import from_model_type from app.models import from_model_type
from ..schemas import ModelType, has_profiling from ..schemas import ModelType
from .base import InferenceModel from .base import InferenceModel
@ -50,20 +50,20 @@ class ModelCache:
key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}" key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
async with OptimisticLock(self.cache, key) as lock: async with OptimisticLock(self.cache, key) as lock:
model: InferenceModel | None = await self.cache.get(key) model = await self.cache.get(key)
if model is None: if model is None:
model = from_model_type(model_type, model_name, **model_kwargs) model = from_model_type(model_type, model_name, **model_kwargs)
await lock.cas(model, ttl=self.ttl) await lock.cas(model, ttl=self.ttl)
return model return model
async def get_profiling(self) -> dict[str, float] | None: async def get_profiling(self) -> dict[str, float] | None:
if not has_profiling(self.cache): if not hasattr(self.cache, "profiling"):
return None return None
return self.cache.profiling return self.cache.profiling # type: ignore
class RevalidationPlugin(BasePlugin): # type: ignore[misc] class RevalidationPlugin(BasePlugin):
"""Revalidates cache item's TTL after cache hit.""" """Revalidates cache item's TTL after cache hit."""
async def post_get( async def post_get(

View file

@ -51,7 +51,7 @@ class BaseCLIPEncoder(InferenceModel):
provider_options=self.provider_options, provider_options=self.provider_options,
) )
def _predict(self, image_or_text: Image.Image | str) -> ndarray_f32: def _predict(self, image_or_text: Image.Image | str) -> list[float]:
if isinstance(image_or_text, bytes): if isinstance(image_or_text, bytes):
image_or_text = Image.open(BytesIO(image_or_text)) image_or_text = Image.open(BytesIO(image_or_text))
@ -60,16 +60,16 @@ class BaseCLIPEncoder(InferenceModel):
if self.mode == "text": if self.mode == "text":
raise TypeError("Cannot encode image as text-only model") raise TypeError("Cannot encode image as text-only model")
outputs: ndarray_f32 = self.vision_model.run(None, self.transform(image_or_text))[0][0] outputs = self.vision_model.run(None, self.transform(image_or_text))
case str(): case str():
if self.mode == "vision": if self.mode == "vision":
raise TypeError("Cannot encode text as vision-only model") raise TypeError("Cannot encode text as vision-only model")
outputs = self.text_model.run(None, self.tokenize(image_or_text))[0][0] outputs = self.text_model.run(None, self.tokenize(image_or_text))
case _: case _:
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}") raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
return outputs return outputs[0][0].tolist()
@abstractmethod @abstractmethod
def tokenize(self, text: str) -> dict[str, ndarray_i32]: def tokenize(self, text: str) -> dict[str, ndarray_i32]:
@ -151,13 +151,11 @@ class OpenCLIPEncoder(BaseCLIPEncoder):
@cached_property @cached_property
def model_cfg(self) -> dict[str, Any]: def model_cfg(self) -> dict[str, Any]:
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open()) return json.load(self.model_cfg_path.open())
return model_cfg
@cached_property @cached_property
def preprocess_cfg(self) -> dict[str, Any]: def preprocess_cfg(self) -> dict[str, Any]:
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open()) return json.load(self.preprocess_cfg_path.open())
return preprocess_cfg
class MCLIPEncoder(OpenCLIPEncoder): class MCLIPEncoder(OpenCLIPEncoder):

View file

@ -8,7 +8,7 @@ from insightface.model_zoo import ArcFaceONNX, RetinaFace
from insightface.utils.face_align import norm_crop from insightface.utils.face_align import norm_crop
from app.config import clean_name from app.config import clean_name
from app.schemas import BoundingBox, Face, ModelType, ndarray_f32 from app.schemas import ModelType, ndarray_f32
from .base import InferenceModel from .base import InferenceModel
@ -52,7 +52,7 @@ class FaceRecognizer(InferenceModel):
) )
self.rec_model.prepare(ctx_id=0) self.rec_model.prepare(ctx_id=0)
def _predict(self, image: ndarray_f32 | bytes) -> list[Face]: def _predict(self, image: ndarray_f32 | bytes) -> list[dict[str, Any]]:
if isinstance(image, bytes): if isinstance(image, bytes):
image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR) image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR)
bboxes, kpss = self.det_model.detect(image) bboxes, kpss = self.det_model.detect(image)
@ -67,20 +67,21 @@ class FaceRecognizer(InferenceModel):
height, width, _ = image.shape height, width, _ = image.shape
for (x1, y1, x2, y2), score, kps in zip(bboxes, scores, kpss): for (x1, y1, x2, y2), score, kps in zip(bboxes, scores, kpss):
cropped_img = norm_crop(image, kps) cropped_img = norm_crop(image, kps)
embedding: ndarray_f32 = self.rec_model.get_feat(cropped_img)[0] embedding = self.rec_model.get_feat(cropped_img)[0].tolist()
face: Face = { results.append(
"imageWidth": width, {
"imageHeight": height, "imageWidth": width,
"boundingBox": { "imageHeight": height,
"x1": x1, "boundingBox": {
"y1": y1, "x1": x1,
"x2": x2, "y1": y1,
"y2": y2, "x2": x2,
}, "y2": y2,
"score": score, },
"embedding": embedding, "score": score,
} "embedding": embedding,
results.append(face) }
)
return results return results
@property @property

View file

@ -66,7 +66,7 @@ class ImageClassifier(InferenceModel):
def _predict(self, image: Image.Image | bytes) -> list[str]: def _predict(self, image: Image.Image | bytes) -> list[str]:
if isinstance(image, bytes): if isinstance(image, bytes):
image = Image.open(BytesIO(image)) image = Image.open(BytesIO(image))
predictions: list[dict[str, Any]] = self.model(image) predictions: list[dict[str, Any]] = self.model(image) # type: ignore
tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score] tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score]
return tags return tags

View file

@ -1,12 +1,17 @@
from enum import StrEnum from enum import StrEnum
from typing import Any, Protocol, TypeAlias, TypedDict, TypeGuard from typing import TypeAlias
import numpy as np import numpy as np
from pydantic import BaseModel from pydantic import BaseModel
ndarray_f32: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
ndarray_i64: TypeAlias = np.ndarray[int, np.dtype[np.int64]] def to_lower_camel(string: str) -> str:
ndarray_i32: TypeAlias = np.ndarray[int, np.dtype[np.int32]] tokens = [token.capitalize() if i > 0 else token for i, token in enumerate(string.split("_"))]
return "".join(tokens)
class TextModelRequest(BaseModel):
text: str
class TextResponse(BaseModel): class TextResponse(BaseModel):
@ -17,7 +22,7 @@ class MessageResponse(BaseModel):
message: str message: str
class BoundingBox(TypedDict): class BoundingBox(BaseModel):
x1: int x1: int
y1: int y1: int
x2: int x2: int
@ -30,17 +35,6 @@ class ModelType(StrEnum):
FACIAL_RECOGNITION = "facial-recognition" FACIAL_RECOGNITION = "facial-recognition"
class HasProfiling(Protocol): ndarray_f32: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
profiling: dict[str, float] ndarray_i64: TypeAlias = np.ndarray[int, np.dtype[np.int64]]
ndarray_i32: TypeAlias = np.ndarray[int, np.dtype[np.int32]]
class Face(TypedDict):
boundingBox: BoundingBox
embedding: ndarray_f32
imageWidth: int
imageHeight: int
score: float
def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
return hasattr(obj, "profiling") and type(obj.profiling) == dict

View file

@ -75,9 +75,9 @@ class TestCLIP:
embedding = clip_encoder.predict(pil_image) embedding = clip_encoder.predict(pil_image)
assert clip_encoder.mode == "vision" assert clip_encoder.mode == "vision"
assert isinstance(embedding, np.ndarray) assert isinstance(embedding, list)
assert embedding.shape[0] == clip_model_cfg["embed_dim"] assert len(embedding) == clip_model_cfg["embed_dim"]
assert embedding.dtype == np.float32 assert all([isinstance(num, float) for num in embedding])
clip_encoder.vision_model.run.assert_called_once() clip_encoder.vision_model.run.assert_called_once()
def test_basic_text( def test_basic_text(
@ -97,9 +97,9 @@ class TestCLIP:
embedding = clip_encoder.predict("test search query") embedding = clip_encoder.predict("test search query")
assert clip_encoder.mode == "text" assert clip_encoder.mode == "text"
assert isinstance(embedding, np.ndarray) assert isinstance(embedding, list)
assert embedding.shape[0] == clip_model_cfg["embed_dim"] assert len(embedding) == clip_model_cfg["embed_dim"]
assert embedding.dtype == np.float32 assert all([isinstance(num, float) for num in embedding])
clip_encoder.text_model.run.assert_called_once() clip_encoder.text_model.run.assert_called_once()
@ -133,9 +133,9 @@ class TestFaceRecognition:
for face in faces: for face in faces:
assert face["imageHeight"] == 800 assert face["imageHeight"] == 800
assert face["imageWidth"] == 600 assert face["imageWidth"] == 600
assert isinstance(face["embedding"], np.ndarray) assert isinstance(face["embedding"], list)
assert face["embedding"].shape[0] == 512 assert len(face["embedding"]) == 512
assert face["embedding"].dtype == np.float32 assert all([isinstance(num, float) for num in face["embedding"]])
det_model.detect.assert_called_once() det_model.detect.assert_called_once()
assert rec_model.get_feat.call_count == num_faces assert rec_model.get_feat.call_count == num_faces

View file

@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:e296d47be09fc5d260eba9b191f60496f028a4f3ec41e8a14d48c0bae2c60244 as builder FROM mambaorg/micromamba:bookworm-slim as builder
ENV NODE_ENV=production \ ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \ TRANSFORMERS_CACHE=/cache \

View file

@ -1,7 +1,6 @@
import tempfile import tempfile
import warnings import warnings
from dataclasses import dataclass, field from dataclasses import dataclass, field
from math import e
from pathlib import Path from pathlib import Path
import open_clip import open_clip
@ -70,12 +69,10 @@ def export_image_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig,
output_path = Path(output_path) output_path = Path(output_path)
def encode_image(image: torch.Tensor) -> torch.Tensor: def encode_image(image: torch.Tensor) -> torch.Tensor:
output = model.encode_image(image, normalize=True) return model.encode_image(image, normalize=True)
assert isinstance(output, torch.Tensor)
return output
args = (torch.randn(1, 3, model_cfg.image_size, model_cfg.image_size),) args = (torch.randn(1, 3, model_cfg.image_size, model_cfg.image_size),)
traced = torch.jit.trace(encode_image, args) # type: ignore[no-untyped-call] traced = torch.jit.trace(encode_image, args)
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning) warnings.simplefilter("ignore", UserWarning)
@ -94,12 +91,10 @@ def export_text_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, o
output_path = Path(output_path) output_path = Path(output_path)
def encode_text(text: torch.Tensor) -> torch.Tensor: def encode_text(text: torch.Tensor) -> torch.Tensor:
output = model.encode_text(text, normalize=True) return model.encode_text(text, normalize=True)
assert isinstance(output, torch.Tensor)
return output
args = (torch.ones(1, model_cfg.sequence_length, dtype=torch.int32),) args = (torch.ones(1, model_cfg.sequence_length, dtype=torch.int32),)
traced = torch.jit.trace(encode_text, args) # type: ignore[no-untyped-call] traced = torch.jit.trace(encode_text, args)
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning) warnings.simplefilter("ignore", UserWarning)

View file

@ -585,6 +585,68 @@ files = [
test = ["PyYAML", "mock", "pytest"] test = ["PyYAML", "mock", "pytest"]
yaml = ["PyYAML"] yaml = ["PyYAML"]
[[package]]
name = "contourpy"
version = "1.1.0"
description = "Python library for calculating contours of 2D quadrilateral grids"
optional = false
python-versions = ">=3.8"
files = [
{file = "contourpy-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:89f06eff3ce2f4b3eb24c1055a26981bffe4e7264acd86f15b97e40530b794bc"},
{file = "contourpy-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dffcc2ddec1782dd2f2ce1ef16f070861af4fb78c69862ce0aab801495dda6a3"},
{file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25ae46595e22f93592d39a7eac3d638cda552c3e1160255258b695f7b58e5655"},
{file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17cfaf5ec9862bc93af1ec1f302457371c34e688fbd381f4035a06cd47324f48"},
{file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18a64814ae7bce73925131381603fff0116e2df25230dfc80d6d690aa6e20b37"},
{file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c81f22b4f572f8a2110b0b741bb64e5a6427e0a198b2cdc1fbaf85f352a3aa"},
{file = "contourpy-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:53cc3a40635abedbec7f1bde60f8c189c49e84ac180c665f2cd7c162cc454baa"},
{file = "contourpy-1.1.0-cp310-cp310-win32.whl", hash = "sha256:9b2dd2ca3ac561aceef4c7c13ba654aaa404cf885b187427760d7f7d4c57cff8"},
{file = "contourpy-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:1f795597073b09d631782e7245016a4323cf1cf0b4e06eef7ea6627e06a37ff2"},
{file = "contourpy-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0b7b04ed0961647691cfe5d82115dd072af7ce8846d31a5fac6c142dcce8b882"},
{file = "contourpy-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27bc79200c742f9746d7dd51a734ee326a292d77e7d94c8af6e08d1e6c15d545"},
{file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:052cc634bf903c604ef1a00a5aa093c54f81a2612faedaa43295809ffdde885e"},
{file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9382a1c0bc46230fb881c36229bfa23d8c303b889b788b939365578d762b5c18"},
{file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5cec36c5090e75a9ac9dbd0ff4a8cf7cecd60f1b6dc23a374c7d980a1cd710e"},
{file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f0cbd657e9bde94cd0e33aa7df94fb73c1ab7799378d3b3f902eb8eb2e04a3a"},
{file = "contourpy-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:181cbace49874f4358e2929aaf7ba84006acb76694102e88dd15af861996c16e"},
{file = "contourpy-1.1.0-cp311-cp311-win32.whl", hash = "sha256:edb989d31065b1acef3828a3688f88b2abb799a7db891c9e282df5ec7e46221b"},
{file = "contourpy-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb3b7d9e6243bfa1efb93ccfe64ec610d85cfe5aec2c25f97fbbd2e58b531256"},
{file = "contourpy-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bcb41692aa09aeb19c7c213411854402f29f6613845ad2453d30bf421fe68fed"},
{file = "contourpy-1.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5d123a5bc63cd34c27ff9c7ac1cd978909e9c71da12e05be0231c608048bb2ae"},
{file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62013a2cf68abc80dadfd2307299bfa8f5aa0dcaec5b2954caeb5fa094171103"},
{file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b6616375d7de55797d7a66ee7d087efe27f03d336c27cf1f32c02b8c1a5ac70"},
{file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:317267d915490d1e84577924bd61ba71bf8681a30e0d6c545f577363157e5e94"},
{file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d551f3a442655f3dcc1285723f9acd646ca5858834efeab4598d706206b09c9f"},
{file = "contourpy-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7a117ce7df5a938fe035cad481b0189049e8d92433b4b33aa7fc609344aafa1"},
{file = "contourpy-1.1.0-cp38-cp38-win32.whl", hash = "sha256:108dfb5b3e731046a96c60bdc46a1a0ebee0760418951abecbe0fc07b5b93b27"},
{file = "contourpy-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4f26b25b4f86087e7d75e63212756c38546e70f2a92d2be44f80114826e1cd4"},
{file = "contourpy-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc00bb4225d57bff7ebb634646c0ee2a1298402ec10a5fe7af79df9a51c1bfd9"},
{file = "contourpy-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:189ceb1525eb0655ab8487a9a9c41f42a73ba52d6789754788d1883fb06b2d8a"},
{file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f2931ed4741f98f74b410b16e5213f71dcccee67518970c42f64153ea9313b9"},
{file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f511c05fab7f12e0b1b7730ebdc2ec8deedcfb505bc27eb570ff47c51a8f15"},
{file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:143dde50520a9f90e4a2703f367cf8ec96a73042b72e68fcd184e1279962eb6f"},
{file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e94bef2580e25b5fdb183bf98a2faa2adc5b638736b2c0a4da98691da641316a"},
{file = "contourpy-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ed614aea8462735e7d70141374bd7650afd1c3f3cb0c2dbbcbe44e14331bf002"},
{file = "contourpy-1.1.0-cp39-cp39-win32.whl", hash = "sha256:71551f9520f008b2950bef5f16b0e3587506ef4f23c734b71ffb7b89f8721999"},
{file = "contourpy-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:438ba416d02f82b692e371858143970ed2eb6337d9cdbbede0d8ad9f3d7dd17d"},
{file = "contourpy-1.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a698c6a7a432789e587168573a864a7ea374c6be8d4f31f9d87c001d5a843493"},
{file = "contourpy-1.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b0ac8a12880412da3551a8cb5a187d3298a72802b45a3bd1805e204ad8439"},
{file = "contourpy-1.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:a67259c2b493b00e5a4d0f7bfae51fb4b3371395e47d079a4446e9b0f4d70e76"},
{file = "contourpy-1.1.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2b836d22bd2c7bb2700348e4521b25e077255ebb6ab68e351ab5aa91ca27e027"},
{file = "contourpy-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084eaa568400cfaf7179b847ac871582199b1b44d5699198e9602ecbbb5f6104"},
{file = "contourpy-1.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:911ff4fd53e26b019f898f32db0d4956c9d227d51338fb3b03ec72ff0084ee5f"},
{file = "contourpy-1.1.0.tar.gz", hash = "sha256:e53046c3863828d21d531cc3b53786e6580eb1ba02477e8681009b6aa0870b21"},
]
[package.dependencies]
numpy = ">=1.16"
[package.extras]
bokeh = ["bokeh", "selenium"]
docs = ["furo", "sphinx-copybutton"]
mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.2.0)", "types-Pillow"]
test = ["Pillow", "contourpy[test-no-images]", "matplotlib"]
test-no-images = ["pytest", "pytest-cov", "wurlitzer"]
[[package]] [[package]]
name = "contourpy" name = "contourpy"
version = "1.1.1" version = "1.1.1"
@ -2346,35 +2408,35 @@ reference = ["Pillow", "google-re2"]
[[package]] [[package]]
name = "onnxruntime" name = "onnxruntime"
version = "1.16.2" version = "1.16.1"
description = "ONNX Runtime is a runtime accelerator for Machine Learning models" description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
{file = "onnxruntime-1.16.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:e19316bb15c29ca0397e78861ee7cdb4db763ac5c53eaa83169bcdcb1149878c"}, {file = "onnxruntime-1.16.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:28b2c7f444b4119950b69370801cd66067f403d19cbaf2a444735d7c269cce4a"},
{file = "onnxruntime-1.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:773f6d99d1e6a58936a55a4933c66674241dace9ec4bab71664cdfa170a7cd87"}, {file = "onnxruntime-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c24e04f33e7899f6aebb03ed51e51d346c1f906b05c5569d58ac9a12d38a2f58"},
{file = "onnxruntime-1.16.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b8df9583a6e874f1983b85a361d22c205c96e926626eb486d3e69d72642f79"}, {file = "onnxruntime-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fa93b166f2d97063dc9f33c5118c5729a4a5dd5617296b6dbef42f9047b3e81"},
{file = "onnxruntime-1.16.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceef600de846997e3ef5f9af956ae87c88d84d6e925c3e9d435ce17ea223568f"}, {file = "onnxruntime-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:042dd9201b3016ee18f8f8bc4609baf11ff34ca1ff489c0a46bcd30919bf883d"},
{file = "onnxruntime-1.16.2-cp310-cp310-win32.whl", hash = "sha256:4fed41edb766c6adea6c34f1eb63a344d697fd4625133e5e48f23950bce60803"}, {file = "onnxruntime-1.16.1-cp310-cp310-win32.whl", hash = "sha256:c20aa0591f305012f1b21aad607ed96917c86ae7aede4a4dd95824b3d124ceb7"},
{file = "onnxruntime-1.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:9fc410ec220804fb384e7cb4fd68c474d89da11a1b68184db2001d64ba1477a9"}, {file = "onnxruntime-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:5581873e578917bea76d6434ee7337e28195d03488dcf72d161d08e9398c6249"},
{file = "onnxruntime-1.16.2-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:aa09d8d9d9a4dc2f6647b5135bb540da36e2d78206aaf14140ba73e05928c4f8"}, {file = "onnxruntime-1.16.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:ef8c0c8abf5f309aa1caf35941380839dc5f7a2fa53da533be4a3f254993f120"},
{file = "onnxruntime-1.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:68f8d3347f11fcc6256266c562e4314b8c6da3e30fc275052a2ab693540b17fd"}, {file = "onnxruntime-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e680380bea35a137cbc3efd67a17486e96972901192ad3026ee79c8d8fe264f7"},
{file = "onnxruntime-1.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16217fa87d3482300a91036f9b499c85215a3b495de1ef9a68cbcf3df1a7c548"}, {file = "onnxruntime-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e62cc38ce1a669013d0a596d984762dc9c67c56f60ecfeee0d5ad36da5863f6"},
{file = "onnxruntime-1.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6b7046005442fcd09b86647bdc9a85d60c1367cb36ce7f16b942744cf27fe4"}, {file = "onnxruntime-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:025c7a4d57bd2e63b8a0f84ad3df53e419e3df1cc72d63184f2aae807b17c13c"},
{file = "onnxruntime-1.16.2-cp311-cp311-win32.whl", hash = "sha256:773c231e526f815b8a3f3549d216cd8fed4c9e226e9e16e86af1b69a4bd29b58"}, {file = "onnxruntime-1.16.1-cp311-cp311-win32.whl", hash = "sha256:9ad074057fa8d028df248b5668514088cb0937b6ac5954073b7fb9b2891ffc8c"},
{file = "onnxruntime-1.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:90e83a93b3d946c4a1d9dcbae286350accb0d80512d7c1b85953a444d19c0058"}, {file = "onnxruntime-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:d5e43a3478bffc01f817ecf826de7b25a2ca1bca8547d70888594ab80a77ad24"},
{file = "onnxruntime-1.16.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:8616f56905775dd8beeae11cf145542fff06c38cd97bfe9afe0c4a66142fc6d5"}, {file = "onnxruntime-1.16.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3aef4d70b0930e29a8943eab248cd1565664458d3a62b2276bd11181f28fd0a3"},
{file = "onnxruntime-1.16.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9f5e1d5ca5560044896edb2ad79113f863dc7daa804a26787c7b21c2a96d41e7"}, {file = "onnxruntime-1.16.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:55a7b843a57c8ca0c8ff169428137958146081d5d76f1a6dd444c4ffcd37c3c2"},
{file = "onnxruntime-1.16.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97ce538ffb668c4897e7500a586c150a045869876e0234e0611c4e4f428be63"}, {file = "onnxruntime-1.16.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c631af1941bf3b5f7d063d24c04aacce8cff0794e157c497e315e89ac5ad7b"},
{file = "onnxruntime-1.16.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cadf175baa782599f36586c23f84fe12b02702ceb59be57dbd8eefc6cc13cc4"}, {file = "onnxruntime-1.16.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5671f296c3d5c233f601e97a10ab5a1dd8e65ba35c7b7b0c253332aba9dff330"},
{file = "onnxruntime-1.16.2-cp38-cp38-win32.whl", hash = "sha256:0ffd3b8a3039be713476b8783d254564976664c9b51ec70e7fb5d3e2832bf0f0"}, {file = "onnxruntime-1.16.1-cp38-cp38-win32.whl", hash = "sha256:eb3802305023dd05e16848d4e22b41f8147247894309c0c27122aaa08793b3d2"},
{file = "onnxruntime-1.16.2-cp38-cp38-win_amd64.whl", hash = "sha256:e2211f336e83819edbf174dcf56de35b0dcbfc6c92d3b685c8d85fba19bdf97d"}, {file = "onnxruntime-1.16.1-cp38-cp38-win_amd64.whl", hash = "sha256:fecfb07443d09d271b1487f401fbdf1ba0c829af6fd4fe8f6af25f71190e7eb9"},
{file = "onnxruntime-1.16.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:98a49bda980bcf819f8d9be880e3e7ba8a1df66aa5ce4fc7bb68ba9acf1fc7ad"}, {file = "onnxruntime-1.16.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:de3e12094234db6545c67adbf801874b4eb91e9f299bda34c62967ef0050960f"},
{file = "onnxruntime-1.16.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1f1e90fa0f43e988cd043e5a4b1eb77eda6cbd7523f316d93d36b33ff1ceb91f"}, {file = "onnxruntime-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ff723c2a5621b5e7103f3be84d5aae1e03a20621e72219dddceae81f65f240af"},
{file = "onnxruntime-1.16.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0cbdb7df8078b2e8d9804de948963961eb8c6f417ef35ed243455162a9a065c"}, {file = "onnxruntime-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14a7fb3073aaf6b462e3d7fb433320f7700558a8892e5021780522dc4574292a"},
{file = "onnxruntime-1.16.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93c1cbd885c5fe0018b982c9dabe3cc3531416a3b50d0958a291605b32fe3ce"}, {file = "onnxruntime-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:963159f1f699b0454cd72fcef3276c8a1aab9389a7b301bcd8e320fb9d9e8597"},
{file = "onnxruntime-1.16.2-cp39-cp39-win32.whl", hash = "sha256:713101b65d74438f380f5ea2475ce4f6026171e6229100e5be2baa92519fca17"}, {file = "onnxruntime-1.16.1-cp39-cp39-win32.whl", hash = "sha256:85771adb75190db9364b25ddec353ebf07635b83eb94b64ed014f1f6d57a3857"},
{file = "onnxruntime-1.16.2-cp39-cp39-win_amd64.whl", hash = "sha256:3382934f9d86060b6bacd3eb4633c5ff904be2c99d3a7fb7faf2828381b15928"}, {file = "onnxruntime-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:d32d2b30799c1f950123c60ae8390818381fd5f88bdf3627eeca10071c155dc5"},
] ]
[package.dependencies] [package.dependencies]
@ -2516,6 +2578,64 @@ files = [
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
] ]
[[package]]
name = "pandas"
version = "2.1.0"
description = "Powerful data structures for data analysis, time series, and statistics"
optional = false
python-versions = ">=3.9"
files = [
{file = "pandas-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:40dd20439ff94f1b2ed55b393ecee9cb6f3b08104c2c40b0cb7186a2f0046242"},
{file = "pandas-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d4f38e4fedeba580285eaac7ede4f686c6701a9e618d8a857b138a126d067f2f"},
{file = "pandas-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e6a0fe052cf27ceb29be9429428b4918f3740e37ff185658f40d8702f0b3e09"},
{file = "pandas-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d81e1813191070440d4c7a413cb673052b3b4a984ffd86b8dd468c45742d3cc"},
{file = "pandas-2.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb20252720b1cc1b7d0b2879ffc7e0542dd568f24d7c4b2347cb035206936421"},
{file = "pandas-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:38f74ef7ebc0ffb43b3d633e23d74882bce7e27bfa09607f3c5d3e03ffd9a4a5"},
{file = "pandas-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cda72cc8c4761c8f1d97b169661f23a86b16fdb240bdc341173aee17e4d6cedd"},
{file = "pandas-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d97daeac0db8c993420b10da4f5f5b39b01fc9ca689a17844e07c0a35ac96b4b"},
{file = "pandas-2.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8c58b1113892e0c8078f006a167cc210a92bdae23322bb4614f2f0b7a4b510f"},
{file = "pandas-2.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:629124923bcf798965b054a540f9ccdfd60f71361255c81fa1ecd94a904b9dd3"},
{file = "pandas-2.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:70cf866af3ab346a10debba8ea78077cf3a8cd14bd5e4bed3d41555a3280041c"},
{file = "pandas-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:d53c8c1001f6a192ff1de1efe03b31a423d0eee2e9e855e69d004308e046e694"},
{file = "pandas-2.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:86f100b3876b8c6d1a2c66207288ead435dc71041ee4aea789e55ef0e06408cb"},
{file = "pandas-2.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28f330845ad21c11db51e02d8d69acc9035edfd1116926ff7245c7215db57957"},
{file = "pandas-2.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9a6ccf0963db88f9b12df6720e55f337447aea217f426a22d71f4213a3099a6"},
{file = "pandas-2.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d99e678180bc59b0c9443314297bddce4ad35727a1a2656dbe585fd78710b3b9"},
{file = "pandas-2.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b31da36d376d50a1a492efb18097b9101bdbd8b3fbb3f49006e02d4495d4c644"},
{file = "pandas-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0164b85937707ec7f70b34a6c3a578dbf0f50787f910f21ca3b26a7fd3363437"},
{file = "pandas-2.1.0.tar.gz", hash = "sha256:62c24c7fc59e42b775ce0679cfa7b14a5f9bfb7643cfbe708c960699e05fb918"},
]
[package.dependencies]
numpy = {version = ">=1.23.2", markers = "python_version >= \"3.11\""}
python-dateutil = ">=2.8.2"
pytz = ">=2020.1"
tzdata = ">=2022.1"
[package.extras]
all = ["PyQt5 (>=5.15.6)", "SQLAlchemy (>=1.4.36)", "beautifulsoup4 (>=4.11.1)", "bottleneck (>=1.3.4)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=0.8.1)", "fsspec (>=2022.05.0)", "gcsfs (>=2022.05.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.8.0)", "matplotlib (>=3.6.1)", "numba (>=0.55.2)", "numexpr (>=2.8.0)", "odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pandas-gbq (>=0.17.5)", "psycopg2 (>=2.9.3)", "pyarrow (>=7.0.0)", "pymysql (>=1.0.2)", "pyreadstat (>=1.1.5)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)", "pyxlsb (>=1.0.9)", "qtpy (>=2.2.0)", "s3fs (>=2022.05.0)", "scipy (>=1.8.1)", "tables (>=3.7.0)", "tabulate (>=0.8.10)", "xarray (>=2022.03.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)", "zstandard (>=0.17.0)"]
aws = ["s3fs (>=2022.05.0)"]
clipboard = ["PyQt5 (>=5.15.6)", "qtpy (>=2.2.0)"]
compression = ["zstandard (>=0.17.0)"]
computation = ["scipy (>=1.8.1)", "xarray (>=2022.03.0)"]
consortium-standard = ["dataframe-api-compat (>=0.1.7)"]
excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.0.10)", "pyxlsb (>=1.0.9)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.3)"]
feather = ["pyarrow (>=7.0.0)"]
fss = ["fsspec (>=2022.05.0)"]
gcp = ["gcsfs (>=2022.05.0)", "pandas-gbq (>=0.17.5)"]
hdf5 = ["tables (>=3.7.0)"]
html = ["beautifulsoup4 (>=4.11.1)", "html5lib (>=1.1)", "lxml (>=4.8.0)"]
mysql = ["SQLAlchemy (>=1.4.36)", "pymysql (>=1.0.2)"]
output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.8.10)"]
parquet = ["pyarrow (>=7.0.0)"]
performance = ["bottleneck (>=1.3.4)", "numba (>=0.55.2)", "numexpr (>=2.8.0)"]
plot = ["matplotlib (>=3.6.1)"]
postgresql = ["SQLAlchemy (>=1.4.36)", "psycopg2 (>=2.9.3)"]
spss = ["pyreadstat (>=1.1.5)"]
sql-other = ["SQLAlchemy (>=1.4.36)"]
test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-asyncio (>=0.17.0)", "pytest-xdist (>=2.2.0)"]
xml = ["lxml (>=4.8.0)"]
[[package]] [[package]]
name = "pandas" name = "pandas"
version = "2.1.2" version = "2.1.2"
@ -4651,5 +4771,5 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "~3.11" python-versions = "^3.11"
content-hash = "a4c9b3550bb2a67a54b9ab70e700b24fb9eb0b652e90d7dd8ec92abd121ca6e3" content-hash = "bba5f87aa67bc1d2283a9f4b471ef78e572337f22413870d324e908014410d53"

View file

@ -1,13 +1,13 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.89.0" version = "1.85.0"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
packages = [{include = "app"}] packages = [{include = "app"}]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "~3.11" python = "^3.11"
torch = [ torch = [
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.1.0", source = "pypi"}, {markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.1.0", source = "pypi"},
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.1.0", source = "pytorch-cpu"} {markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.1.0", source = "pytorch-cpu"}

View file

@ -36,57 +36,3 @@ analyzer:
- openapi/ - openapi/
- openapi/test/ - openapi/test/
- lib/generated_plugin_registrant.dart - lib/generated_plugin_registrant.dart
plugins:
- custom_lint
dart_code_metrics:
metrics:
cyclomatic-complexity: 20
number-of-parameters: 4
maximum-nesting-level: 5
rules:
# Common
- avoid-accessing-collections-by-constant-index
- avoid-accessing-other-classes-private-members
- avoid-cascade-after-if-null
- avoid-collapsible-if
- avoid-collection-methods-with-unrelated-types
- avoid-declaring-call-method
- avoid-double-slash-imports
- avoid-duplicate-cascades
- avoid-duplicate-patterns
- avoid-generics-shadowing
- avoid-global-state
# Flutter
- add-copy-with:
file-name-pattern: '.model.dart'
- always-remove-listener
- avoid-border-all
- avoid-empty-setstate
- avoid-expanded-as-spacer
- avoid-incomplete-copy-with
- avoid-inherited-widget-in-initstate
- avoid-late-context
- avoid-recursive-widget-calls
- avoid-returning-widgets
- avoid-shrink-wrap-in-lists
- avoid-single-child-column-or-row
- avoid-state-constructors
- avoid-stateless-widget-initialized-fields
- avoid-unnecessary-overrides-in-state
- avoid-unnecessary-stateful-widgets
- avoid-wrapping-in-padding
- dispose-fields
- prefer-const-border-radius
- prefer-correct-edge-insets-constructor
- prefer-dedicated-media-query-methods
- prefer-define-hero-tag
- prefer-extracting-callbacks
- prefer-single-widget-per-file:
ignore-private-widgets: true
- prefer-sliver-prefix
- prefer-text-rich
- prefer-using-list-view
- proper-super-calls
- use-setstate-synchronously

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 113, "android.injected.version.code" => 109,
"android.injected.version.name" => "1.89.0", "android.injected.version.name" => "1.85.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

@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000263"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000244">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="80.37488"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.0562">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="25.830358"> <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="33.087498">
</testcase> </testcase>

View file

@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80 distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2

View file

@ -27,7 +27,7 @@
"album_viewer_page_share_add_users": "Afegeix usuaris", "album_viewer_page_share_add_users": "Afegeix usuaris",
"all_people_page_title": "Persones", "all_people_page_title": "Persones",
"all_videos_page_title": "Vídeos", "all_videos_page_title": "Vídeos",
"app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_content": "Are you sure you wanna sign out?",
"app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_ok": "Yes",
"app_bar_signout_dialog_title": "Sign out", "app_bar_signout_dialog_title": "Sign out",
"archive_page_no_archived_assets": "No s'ha trobat res arxivat", "archive_page_no_archived_assets": "No s'ha trobat res arxivat",
@ -119,7 +119,7 @@
"cache_settings_tile_title": "Local Storage", "cache_settings_tile_title": "Local Storage",
"cache_settings_title": "Configuració de la memòria cau", "cache_settings_title": "Configuració de la memòria cau",
"change_password_form_confirm_password": "Confirma la contrasenya", "change_password_form_confirm_password": "Confirma la contrasenya",
"change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_description": "Hi {firstName} {lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_new_password": "New Password", "change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match", "change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password", "change_password_form_reenter_new_password": "Re-enter New Password",
@ -170,15 +170,10 @@
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_success": "Added {added} assets to album {album}.", "home_page_add_to_album_success": "Added {added} assets to album {album}.",
"home_page_album_err_partner": "Can not add partner assets to an album yet, skipping",
"home_page_archive_err_local": "Can not archive local assets yet, skipping", "home_page_archive_err_local": "Can not archive local assets yet, skipping",
"home_page_archive_err_partner": "Can not archive partner assets, skipping",
"home_page_building_timeline": "Building the timeline", "home_page_building_timeline": "Building the timeline",
"home_page_delete_err_partner": "Can not delete partner assets, skipping",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"home_page_share_err_local": "Can not share local assets via link, skipping",
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
"image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_success": "Download Success", "image_viewer_page_state_provider_download_success": "Download Success",
@ -250,7 +245,6 @@
"partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.",
"partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_stop_sharing_title": "Stop sharing your photos?",
"partner_page_title": "Company", "partner_page_title": "Company",
"permission_onboarding_back": "Back",
"permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_continue_anyway": "Continue anyway",
"permission_onboarding_get_started": "Get started", "permission_onboarding_get_started": "Get started",
"permission_onboarding_go_to_settings": "Go to settings", "permission_onboarding_go_to_settings": "Go to settings",

View file

@ -119,7 +119,7 @@
"cache_settings_tile_title": "Místní úložiště", "cache_settings_tile_title": "Místní úložiště",
"cache_settings_title": "Nastavení vyrovnávací paměti", "cache_settings_title": "Nastavení vyrovnávací paměti",
"change_password_form_confirm_password": "Potvrďte heslo", "change_password_form_confirm_password": "Potvrďte heslo",
"change_password_form_description": "Dobrý den, {name},\n\nje to buď poprvé, co se přihlašujete do systému, nebo byl vytvořen požadavek na změnu hesla. Níže zadejte nové heslo.", "change_password_form_description": "Dobrý den, {firstName} {lastName},\n\nje to buď poprvé, co se přihlašujete do systému, nebo byl vytvořen požadavek na změnu hesla. Níže zadejte nové heslo.",
"change_password_form_new_password": "Nové heslo", "change_password_form_new_password": "Nové heslo",
"change_password_form_password_mismatch": "Hesla se neshodují", "change_password_form_password_mismatch": "Hesla se neshodují",
"change_password_form_reenter_new_password": "Znovu zadejte nové heslo", "change_password_form_reenter_new_password": "Znovu zadejte nové heslo",
@ -170,15 +170,10 @@
"home_page_add_to_album_conflicts": "Přidáno {added} položek do alba {album}. {failed} položek již je v albu.", "home_page_add_to_album_conflicts": "Přidáno {added} položek do alba {album}. {failed} položek již je v albu.",
"home_page_add_to_album_err_local": "Zatím není možné přidat lokální média do alb, přeskakuji", "home_page_add_to_album_err_local": "Zatím není možné přidat lokální média do alb, přeskakuji",
"home_page_add_to_album_success": "Přidány položky {added} do alba {album}.", "home_page_add_to_album_success": "Přidány položky {added} do alba {album}.",
"home_page_album_err_partner": "Položky partnera nelze zatím přidat do alba, přeskakuji",
"home_page_archive_err_local": "Zatím nemohu archivovat lokální média, přeskakuji", "home_page_archive_err_local": "Zatím nemohu archivovat lokální média, přeskakuji",
"home_page_archive_err_partner": "Položky partnera nelze archivovat, přeskakuji",
"home_page_building_timeline": "Vytváření časové osy", "home_page_building_timeline": "Vytváření časové osy",
"home_page_delete_err_partner": "Položky partnera nelze odstranit, přeskakuji",
"home_page_favorite_err_local": "Zatím není možné zařadit lokální média mezi oblíbená, přeskakuji", "home_page_favorite_err_local": "Zatím není možné zařadit lokální média mezi oblíbená, přeskakuji",
"home_page_favorite_err_partner": "Položky partnera nelze označit jako oblíbené, přeskakuji",
"home_page_first_time_notice": "Pokud aplikaci používáte poprvé, nezapomeňte si vybrat zálohovaná alba, aby se na časové ose mohly nacházet fotografie a videa z vybraných alb.", "home_page_first_time_notice": "Pokud aplikaci používáte poprvé, nezapomeňte si vybrat zálohovaná alba, aby se na časové ose mohly nacházet fotografie a videa z vybraných alb.",
"home_page_share_err_local": "Can not share local assets via link, skipping",
"home_page_upload_err_limit": "Lze nahrát nejvýše 30 položek najednou, přeskakuji", "home_page_upload_err_limit": "Lze nahrát nejvýše 30 položek najednou, přeskakuji",
"image_viewer_page_state_provider_download_error": "Chyba stahování", "image_viewer_page_state_provider_download_error": "Chyba stahování",
"image_viewer_page_state_provider_download_success": "Stahování bylo úspěšné", "image_viewer_page_state_provider_download_success": "Stahování bylo úspěšné",
@ -250,7 +245,6 @@
"partner_page_stop_sharing_content": "{} již nebude mít přístup k vašim fotografiím.", "partner_page_stop_sharing_content": "{} již nebude mít přístup k vašim fotografiím.",
"partner_page_stop_sharing_title": "Přestat sdílet vaše fotografie?", "partner_page_stop_sharing_title": "Přestat sdílet vaše fotografie?",
"partner_page_title": "Partner", "partner_page_title": "Partner",
"permission_onboarding_back": "Back",
"permission_onboarding_continue_anyway": "Přesto pokračovat", "permission_onboarding_continue_anyway": "Přesto pokračovat",
"permission_onboarding_get_started": "Začít", "permission_onboarding_get_started": "Začít",
"permission_onboarding_go_to_settings": "Přejít do nastavení", "permission_onboarding_go_to_settings": "Přejít do nastavení",

View file

@ -27,7 +27,7 @@
"album_viewer_page_share_add_users": "Tilføj brugere", "album_viewer_page_share_add_users": "Tilføj brugere",
"all_people_page_title": "Personer", "all_people_page_title": "Personer",
"all_videos_page_title": "Videoer", "all_videos_page_title": "Videoer",
"app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_content": "Are you sure you wanna sign out?",
"app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_ok": "Yes",
"app_bar_signout_dialog_title": "Sign out", "app_bar_signout_dialog_title": "Sign out",
"archive_page_no_archived_assets": "Ingen arkiverede elementer blev fundet", "archive_page_no_archived_assets": "Ingen arkiverede elementer blev fundet",
@ -119,7 +119,7 @@
"cache_settings_tile_title": "Local Storage", "cache_settings_tile_title": "Local Storage",
"cache_settings_title": "Cache-indstillinger", "cache_settings_title": "Cache-indstillinger",
"change_password_form_confirm_password": "Bekræft kodeord", "change_password_form_confirm_password": "Bekræft kodeord",
"change_password_form_description": "Hej {name},\n\nDette er enten første gang du logger ind eller også er der lavet en anmodning om at ændre dit kodeord. Indtast venligst et nyt kodeord nedenfor.", "change_password_form_description": "Hej {firstName} {lastName},\n\nDette er enten første gang du logger ind eller også er der lavet en anmodning om at ændre dit kodeord. Indtast venligst et nyt kodeord nedenfor.",
"change_password_form_new_password": "Nyt kodeord", "change_password_form_new_password": "Nyt kodeord",
"change_password_form_password_mismatch": "Kodeord er ikke ens", "change_password_form_password_mismatch": "Kodeord er ikke ens",
"change_password_form_reenter_new_password": "Gentag nyt kodeord", "change_password_form_reenter_new_password": "Gentag nyt kodeord",
@ -170,15 +170,10 @@
"home_page_add_to_album_conflicts": "Tilføjede {added} elementer til album {album}. {failed} elementer er allerede i albummet.", "home_page_add_to_album_conflicts": "Tilføjede {added} elementer til album {album}. {failed} elementer er allerede i albummet.",
"home_page_add_to_album_err_local": "Kan endnu ikke tilføje lokale elementer til album. Springer over..", "home_page_add_to_album_err_local": "Kan endnu ikke tilføje lokale elementer til album. Springer over..",
"home_page_add_to_album_success": "Tilføjede {added} elementer til album {album}.", "home_page_add_to_album_success": "Tilføjede {added} elementer til album {album}.",
"home_page_album_err_partner": "Can not add partner assets to an album yet, skipping",
"home_page_archive_err_local": "Kan ikke arkivere lokalt element endnu.. Springer over", "home_page_archive_err_local": "Kan ikke arkivere lokalt element endnu.. Springer over",
"home_page_archive_err_partner": "Can not archive partner assets, skipping",
"home_page_building_timeline": "Bygger tidslinjen", "home_page_building_timeline": "Bygger tidslinjen",
"home_page_delete_err_partner": "Can not delete partner assets, skipping",
"home_page_favorite_err_local": "Kan endnu ikke gøre lokale elementer til favoritter. Springer over..", "home_page_favorite_err_local": "Kan endnu ikke gøre lokale elementer til favoritter. Springer over..",
"home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping",
"home_page_first_time_notice": "Hvis det er din første gang i appen, bedes du vælge en sikkerhedskopi af albummer så tidlinjen kan blive fyldt med billeder og videoer fra albummerne.", "home_page_first_time_notice": "Hvis det er din første gang i appen, bedes du vælge en sikkerhedskopi af albummer så tidlinjen kan blive fyldt med billeder og videoer fra albummerne.",
"home_page_share_err_local": "Can not share local assets via link, skipping",
"home_page_upload_err_limit": "Det er kun muligt at lave sikkerhedskopi af 30 elementer ad gangen. Springer over", "home_page_upload_err_limit": "Det er kun muligt at lave sikkerhedskopi af 30 elementer ad gangen. Springer over",
"image_viewer_page_state_provider_download_error": "Fejl ved download", "image_viewer_page_state_provider_download_error": "Fejl ved download",
"image_viewer_page_state_provider_download_success": "Download succesfuld", "image_viewer_page_state_provider_download_success": "Download succesfuld",
@ -250,7 +245,6 @@
"partner_page_stop_sharing_content": "{} vil ikke længere have adgang til dine billeder.", "partner_page_stop_sharing_content": "{} vil ikke længere have adgang til dine billeder.",
"partner_page_stop_sharing_title": "Stop med at dele dine billeder?", "partner_page_stop_sharing_title": "Stop med at dele dine billeder?",
"partner_page_title": "Partner", "partner_page_title": "Partner",
"permission_onboarding_back": "Back",
"permission_onboarding_continue_anyway": "Fortsæt alligevel", "permission_onboarding_continue_anyway": "Fortsæt alligevel",
"permission_onboarding_get_started": "Kom i gang", "permission_onboarding_get_started": "Kom i gang",
"permission_onboarding_go_to_settings": "Gå til indstillinger", "permission_onboarding_go_to_settings": "Gå til indstillinger",

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