Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df59b2099f | ||
|
|
7cc0904273 | ||
|
|
319ddfda53 | ||
|
|
d095382b14 | ||
|
|
5a66314ead | ||
|
|
e262298090 | ||
|
|
6835d4519a | ||
|
|
84e60ea155 | ||
|
|
41a32b4e6b | ||
|
|
0750e13d3f | ||
|
|
78d8783a54 | ||
|
|
2cc5149d0b | ||
|
|
8c784defa0 | ||
|
|
8921278447 | ||
|
|
a233e176e5 | ||
|
|
56cde0438c | ||
|
|
ad09896f58 | ||
|
|
d3af2b1f69 | ||
|
|
ac0cb4a96e | ||
|
|
af32183728 | ||
|
|
d1e16025cf | ||
|
|
4cf1e553d2 | ||
|
|
2aaf941dda | ||
|
|
13ba83dce6 | ||
|
|
4f6f79a392 | ||
|
|
ba4a20a181 | ||
|
|
aefd93e43a | ||
|
|
18f59f78e3 | ||
|
|
f8d64be13c | ||
|
|
52e92e9bb4 | ||
|
|
7b40c20ea5 | ||
|
|
317adc5c28 | ||
|
|
1e503c3212 | ||
|
|
282f7803bd | ||
|
|
2950d84820 | ||
|
|
ee82327d2a | ||
|
|
13fb32513c | ||
|
|
103c3ee2f1 | ||
|
|
40fbe81c7b | ||
|
|
093e61eee9 | ||
|
|
23b38a0474 | ||
|
|
27be076958 | ||
|
|
c24d0e82bb | ||
|
|
9a04014f98 | ||
|
|
a7d9e25fb0 | ||
|
|
014adf175a | ||
|
|
cc7ba3c21a | ||
|
|
2688e05033 | ||
|
|
4a5b8c3770 | ||
|
|
7dd88c4114 | ||
|
|
523e7d4742 | ||
|
|
e01c96c637 | ||
|
|
13390d0c87 | ||
|
|
a5a71e6b5c | ||
|
|
a53b2de3c4 | ||
|
|
cb3f18bb9f | ||
|
|
c80e37aded | ||
|
|
03eb5ffc5c | ||
|
|
5f6d09d3da | ||
|
|
9de557916b | ||
|
|
0ed89e61ec | ||
|
|
ca9cad20bc | ||
|
|
2e38fa73bf | ||
|
|
5f6bd4ae7e | ||
|
|
4fff2c75aa | ||
|
|
a1e1f11399 | ||
|
|
fd3a1a4da8 | ||
|
|
27bc777581 | ||
|
|
8119d4bb26 | ||
|
|
1af27fcc47 | ||
|
|
59cf17e5e1 | ||
|
|
5596b3a6a5 | ||
|
|
f7a78618e5 | ||
|
|
733fa28aa2 | ||
|
|
c0ebc943d2 | ||
|
|
1d0dbdff67 | ||
|
|
3edc87f684 | ||
|
|
a7889e5e11 | ||
|
|
dea1063b17 | ||
|
|
17ef411b0a | ||
|
|
83da1c74fc | ||
|
|
8fdd3aaed1 | ||
|
|
aaa7a613b2 | ||
|
|
45cf3291a2 | ||
|
|
612590feda | ||
|
|
19ea0ead85 | ||
|
|
e47e25e671 | ||
|
|
4dd7412a86 | ||
|
|
cc2dc12f6c | ||
|
|
2790a46703 | ||
|
|
f602295bf9 | ||
|
|
092a23fd7f | ||
|
|
154292242f | ||
|
|
8295542941 | ||
|
|
4505ebc315 | ||
|
|
19d66296fe | ||
|
|
64176d2ff4 | ||
|
|
cabc2d57dd | ||
|
|
5a3a2c7293 | ||
|
|
7e216809f3 | ||
|
|
81af48af7b | ||
|
|
4e06ccd052 | ||
|
|
234449f3c6 | ||
|
|
95a7bf7fac | ||
|
|
1c69dff967 | ||
|
|
f4c5bdfa1c | ||
|
|
b40859551b | ||
|
|
4e9b96ff1a | ||
|
|
baed16dab6 | ||
|
|
a7b4727c20 | ||
|
|
9834693fab | ||
|
|
085dc6cd93 | ||
|
|
de1514a441 | ||
|
|
fade8b627f | ||
|
|
d3e1572229 | ||
|
|
ffc31f034c | ||
|
|
3beeffaaf0 | ||
|
|
b68800d45c | ||
|
|
b520955d0e | ||
|
|
6e7b3d6f24 | ||
|
|
c45e8cc170 | ||
|
|
c6f56d9591 | ||
|
|
691e20521d | ||
|
|
27f8dd6040 | ||
|
|
e3fa32ad23 | ||
|
|
08f66c2ae5 | ||
|
|
4f38a283b4 | ||
|
|
00771899da | ||
|
|
09402eb6d0 | ||
|
|
d9b5adf0f7 | ||
|
|
a15c799ba3 | ||
|
|
bda9fd9dfe | ||
|
|
19754d4b21 | ||
|
|
62347edf43 | ||
|
|
67f020380f | ||
|
|
0aae9696f6 | ||
|
|
2f95cb89c1 | ||
|
|
cb1201e690 | ||
|
|
a2deba4734 | ||
|
|
ae2608e31d | ||
|
|
d8756f3897 | ||
|
|
7839be3b49 | ||
|
|
94e11d52dc | ||
|
|
05a1283500 | ||
|
|
f8519d60c7 | ||
|
|
899c71f297 | ||
|
|
2aa5f55cbf |
@@ -1,5 +1,5 @@
|
||||
.vscode/
|
||||
cli/
|
||||
|
||||
design/
|
||||
docker/
|
||||
docs/
|
||||
@@ -18,3 +18,8 @@ web/node_modules/
|
||||
web/coverage/
|
||||
web/.svelte-kit
|
||||
web/build/
|
||||
|
||||
cli/node_modules
|
||||
cli/.reverse-geocoding-dump/
|
||||
cli/upload/
|
||||
cli/dist/
|
||||
1
.github/release.yml
vendored
@@ -23,6 +23,7 @@ changelog:
|
||||
- documentation
|
||||
- title: Dependency updates
|
||||
labels:
|
||||
- dependencies
|
||||
- renovate
|
||||
- title: Other changes
|
||||
labels:
|
||||
|
||||
44
.github/workflows/test.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Run e2e tests
|
||||
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: make test-server-e2e
|
||||
|
||||
doc-tests:
|
||||
name: Docs
|
||||
@@ -90,9 +90,13 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
- name: Run npm install in cli
|
||||
run: npm ci
|
||||
|
||||
- name: Run npm install in server
|
||||
run: npm ci
|
||||
working-directory: ./server
|
||||
|
||||
- name: Run linter
|
||||
run: npm run lint
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -109,6 +113,29 @@ jobs:
|
||||
run: npm run test:cov
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
cli-e2e-tests:
|
||||
name: CLI (e2e)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Run npm install in cli
|
||||
run: npm ci
|
||||
|
||||
- name: Run npm install in server
|
||||
run: npm ci
|
||||
working-directory: ./server
|
||||
|
||||
- name: Run e2e tests
|
||||
run: npm run test:e2e
|
||||
|
||||
web-unit-tests:
|
||||
name: Web
|
||||
runs-on: ubuntu-latest
|
||||
@@ -139,9 +166,9 @@ jobs:
|
||||
run: npm run check:typescript
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
# - name: Run unit tests & coverage
|
||||
# run: npm run test:cov
|
||||
# if: ${{ !cancelled() }}
|
||||
- name: Run unit tests & coverage
|
||||
run: npm run test:cov
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
mobile-unit-tests:
|
||||
name: Mobile
|
||||
@@ -176,13 +203,13 @@ jobs:
|
||||
poetry install --with dev
|
||||
- name: Lint with ruff
|
||||
run: |
|
||||
poetry run ruff check --format=github app export
|
||||
poetry run ruff check --output-format=github app export
|
||||
- name: Check black formatting
|
||||
run: |
|
||||
poetry run black --check app export
|
||||
- name: Run mypy type checking
|
||||
run: |
|
||||
poetry run mypy --install-types --non-interactive --strict app/ export/
|
||||
poetry run mypy --install-types --non-interactive --strict app/
|
||||
- name: Run tests and coverage
|
||||
run: |
|
||||
poetry run pytest --cov app
|
||||
@@ -242,6 +269,9 @@ jobs:
|
||||
- name: Run existing migrations
|
||||
run: npm run typeorm:migrations:run
|
||||
|
||||
- name: Test npm run schema:reset command works
|
||||
run: npm run typeorm:schema:reset
|
||||
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
|
||||
|
||||
4
Makefile
@@ -16,8 +16,8 @@ stage:
|
||||
pull-stage:
|
||||
docker compose -f ./docker/docker-compose.staging.yml pull
|
||||
|
||||
test-e2e:
|
||||
docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||
test-server-e2e:
|
||||
docker compose -f ./server/test/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
|
||||
|
||||
prod:
|
||||
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<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>
|
||||
<a href="README_ru_RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
## Disclaimer
|
||||
@@ -111,7 +112,7 @@ If you feel like this is the right cause and the app is something you are seeing
|
||||
|
||||
- [Monthly donation](https://github.com/sponsors/alextran1502) via GitHub Sponsors
|
||||
- [One-time donation](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
|
||||
- [Librepay](https://liberapay.com/alex.tran1502/)
|
||||
- [Liberapay](https://liberapay.com/alex.tran1502/)
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
|
||||
|
||||
@@ -102,7 +102,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
|
||||
私はこのプロジェクトにコミットしてきました。ドキュメントを更新し、新しい機能を追加し、バグを修正し続けるつもりですが、私ひとりではできません。だから、続けるためのモチベーションをさらに高めてくれる皆さんの助けが必要なのです。
|
||||
|
||||
[selfhosted.show - In the episode 'The-organization-must-いいえt-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
|
||||
[selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
|
||||
|
||||
もし、あなたがこのプロジェクトに賛同し、このアプリを長く使い続けたいと思われるのであれば、以下のオプションから支援をご検討ください。
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
|
||||
Ik ben trouw aan dit project en ik zal niet stoppen. Ik zal de documenten blijven bijwerken, nieuwe functies toevoegen en bugs oplossen. Maar ik kan het niet alleen. Ik heb dus jouw hulp nodig om mij extra motivatie te geven om door te gaan.
|
||||
|
||||
Als onze gastheren in de [selfhosted.show - In de aflevering 'The-organization-must-Neet-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) zeiden, dit is een eNeerme onderneming van wat het team en ik doen. En ik zou dit graag fulltime willen doen, ik vraag jouw hulp om dat mogelijk te maken.
|
||||
Als onze gastheren in de [selfhosted.show - In de aflevering 'The-organization-must-Neet-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) zeiden, dit is een enorme onderneming van wat het team en ik doen. En ik zou dit graag fulltime willen doen, ik vraag jouw hulp om dat mogelijk te maken.
|
||||
|
||||
Als je denkt dat dit het juiste doel is en de app iets is dat je jezelf al heel lang ziet gebruiken, overweeg dan om het project te steunen met de onderstaande optie.
|
||||
|
||||
|
||||
124
README_ru_RU.md
Normal file
@@ -0,0 +1,124 @@
|
||||
<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_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_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>
|
||||
<a href="README_ru_RU.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
|
||||
|
||||
Для мобильного приложения вы можете использовать адрес `https://demo.immich.app/api` в поле `Server Endpoint URL`
|
||||
|
||||
```bash title="Демо доступ"
|
||||
Реквизиты доступа
|
||||
логин/почта: demo@immich.app
|
||||
пароль: demo
|
||||
```
|
||||
|
||||
```
|
||||
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
```
|
||||
|
||||
## Возможности
|
||||
|
||||
| Возможности | Приложение | Веб |
|
||||
| --------------------------------------------------- | ---------- | --- |
|
||||
| Выгрузка на сервер и просмотр видео и фото | Да | Да |
|
||||
| Авто бекап когда приложение открыто | Да | Н/Д |
|
||||
| Выбор альбома(ов) для бекапа | Да | Н/Д |
|
||||
| загрузка с сервера фото и видео на устройство | Да | Да |
|
||||
| Поддержка нескольких пользователей | Да | Да |
|
||||
| Альбомы и общие альбомы | Да | Да |
|
||||
| Прокручиваемая/перетаскиваемая полоса прокрутки | Да | Да |
|
||||
| Поддержка формата RAW | Да | Да |
|
||||
| Просмотр метаданных (EXIF, map) | Да | Да |
|
||||
| Поиск до метаданным, объектам, лицам и CLIP | Да | Да |
|
||||
| Административные функци (управление пользователями) | Нет | Да |
|
||||
| Фоновый бекпа | Да | Н/Д |
|
||||
| Виртуальная прокрутка | Да | Да |
|
||||
| Поддержка OAuth | Да | Да |
|
||||
| Ключи API | Н/Д | Да |
|
||||
| LivePhoto/MotionPhoto бекап и воспроизведение | Да | Да |
|
||||
| Настраиваемая структура хранилища | Да | Да |
|
||||
| Публичные альбомы | Нет | Да |
|
||||
| Архив и Избранное | Да | Да |
|
||||
| Мировая карта | Да | Да |
|
||||
| Совместное использование | Да | Да |
|
||||
| Распознавание лиц и группировка по лицам | Да | Да |
|
||||
| В этот день (x лет назад) | Да | Да |
|
||||
| Работа без интернета | Да | Нет |
|
||||
| Галлереи только для просмотра | Да | Да |
|
||||
| Колллажи | Да | Да |
|
||||
|
||||
## Поддержка проекта
|
||||
|
||||
Я посвятил себя этому проекту и не остановлюсь. Я буду продолжать обновлять документацию, добавлять новые функции и исправлять ошибки. Но я не могу сделать это один. Поэтому мне нужна ваша помощь, чтобы дать мне дополнительную мотивацию продолжать идти дальше.
|
||||
|
||||
Как сказали наши покровители [selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418), это масштабная работа, которую мы с командой делаем. И мне бы очень хотелось когда-нибудь иметь возможность заниматься этим на постоянной основе, и я прошу вашей помощи, чтобы это произошло.
|
||||
|
||||
|
||||
Если вы считаете, что это правильная причина и вы уже давно используете это приложение, рассмотрите возможность финансовой поддержки проекта, выбрав вариант ниже.
|
||||
|
||||
### Пожертвование
|
||||
|
||||
- [Ежемесячное пожертвование](https://github.com/sponsors/alextran1502) via GitHub Sponsors
|
||||
- [Одноразовое пожертвование](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
|
||||
- [Librepay](https://liberapay.com/alex.tran1502/)
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
|
||||
|
||||
## Авторы
|
||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||
</a>
|
||||
4
cli/.gitignore
vendored
@@ -10,4 +10,6 @@ oclif.manifest.json
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
/coverage/
|
||||
/coverage/
|
||||
.reverse-geocoding-dump/
|
||||
upload/
|
||||
@@ -1,4 +1,6 @@
|
||||
**/*.spec.js
|
||||
test/**
|
||||
upload/**
|
||||
.editorconfig
|
||||
.eslintignore
|
||||
.eslintrc.js
|
||||
|
||||
19
cli/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:20231228@sha256:e631113b47c7e16a06ca47d3a99bdf269e831dfa4b94f6f4cc923781fa82c4e3 as test
|
||||
|
||||
WORKDIR /usr/src/app/server
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ./server/ .
|
||||
|
||||
WORKDIR /usr/src/app/cli
|
||||
COPY cli/package.json cli/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY ./cli/ .
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:20231228@sha256:e51e418d904124f368eca84b504414e40c5b55f9990be043d1749fdf5d1a045c
|
||||
|
||||
VOLUME /usr/src/app/upload
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
||||
3466
cli/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.6",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@@ -18,25 +18,24 @@
|
||||
"commander": "^11.0.0",
|
||||
"form-data": "^4.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testcontainers/postgresql": "^10.4.0",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/chai": "^4.3.5",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^20.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"chai": "^4.3.7",
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.2.2",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^49.0.0",
|
||||
"eslint-plugin-unicorn": "^50.0.0",
|
||||
"immich": "file:../server",
|
||||
"jest": "^29.5.0",
|
||||
"jest-extended": "^4.0.0",
|
||||
"jest-message-util": "^29.5.0",
|
||||
@@ -50,13 +49,15 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"prepack": "npm run build",
|
||||
"test": "jest",
|
||||
"test:cov": "jest --coverage",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit",
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config test/e2e/jest-e2e.json --runInBand"
|
||||
},
|
||||
"jest": {
|
||||
"clearMocks": true,
|
||||
@@ -71,10 +72,15 @@
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/src/**/*.(t|j)s"
|
||||
"<rootDir>/src/**/*.(t|j)s",
|
||||
"!**/open-api/**"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@api(|/.*)$": "<rootDir>/src/api/$1"
|
||||
"^@api(|/.*)$": "<rootDir>/src/api/$1",
|
||||
"^@test(|/.*)$": "<rootDir>../server/test/$1",
|
||||
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
|
||||
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
|
||||
"^@app/domain(|/.*)$": "<rootDir>../server/src/domain/$1"
|
||||
},
|
||||
"coverageDirectory": "./coverage",
|
||||
"testEnvironment": "node"
|
||||
|
||||
197
cli/src/api/open-api/api.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.91.0
|
||||
* The version of the OpenAPI document: 1.92.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
@@ -355,12 +355,6 @@ export interface AllJobStatusResponseDto {
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'backgroundTask': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'clipEncoding': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
@@ -379,12 +373,6 @@ export interface AllJobStatusResponseDto {
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'migration': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'objectTagging': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
@@ -403,6 +391,12 @@ export interface AllJobStatusResponseDto {
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'sidecar': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'smartSearch': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
@@ -1318,39 +1312,6 @@ export interface CheckExistingAssetsResponseDto {
|
||||
*/
|
||||
'existingIds': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ClassificationConfig
|
||||
*/
|
||||
export interface ClassificationConfig {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'enabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'minScore': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'modelName': string;
|
||||
/**
|
||||
*
|
||||
* @type {ModelType}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'modelType'?: ModelType;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -2015,9 +1976,8 @@ export const JobName = {
|
||||
ThumbnailGeneration: 'thumbnailGeneration',
|
||||
MetadataExtraction: 'metadataExtraction',
|
||||
VideoConversion: 'videoConversion',
|
||||
ObjectTagging: 'objectTagging',
|
||||
RecognizeFaces: 'recognizeFaces',
|
||||
ClipEncoding: 'clipEncoding',
|
||||
SmartSearch: 'smartSearch',
|
||||
BackgroundTask: 'backgroundTask',
|
||||
StorageTemplateMigration: 'storageTemplateMigration',
|
||||
Migration: 'migration',
|
||||
@@ -2358,7 +2318,6 @@ export interface MergePersonDto {
|
||||
*/
|
||||
|
||||
export const ModelType = {
|
||||
ImageClassification: 'image-classification',
|
||||
FacialRecognition: 'facial-recognition',
|
||||
Clip: 'clip'
|
||||
} as const;
|
||||
@@ -3048,12 +3007,24 @@ export interface SearchResponseDto {
|
||||
* @interface ServerConfigDto
|
||||
*/
|
||||
export interface ServerConfigDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ServerConfigDto
|
||||
*/
|
||||
'externalDomain': string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ServerConfigDto
|
||||
*/
|
||||
'isInitialized': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ServerConfigDto
|
||||
*/
|
||||
'isOnboarded': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -3139,12 +3110,6 @@ export interface ServerFeaturesDto {
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'sidecar': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'tagImage': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@@ -3637,6 +3602,12 @@ export interface SystemConfigDto {
|
||||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'reverseGeocoding': SystemConfigReverseGeocodingDto;
|
||||
/**
|
||||
*
|
||||
* @type {SystemConfigServerDto}
|
||||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'server': SystemConfigServerDto;
|
||||
/**
|
||||
*
|
||||
* @type {SystemConfigStorageTemplateDto}
|
||||
@@ -3785,12 +3756,6 @@ export interface SystemConfigJobDto {
|
||||
* @memberof SystemConfigJobDto
|
||||
*/
|
||||
'backgroundTask': JobSettingsDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobSettingsDto}
|
||||
* @memberof SystemConfigJobDto
|
||||
*/
|
||||
'clipEncoding': JobSettingsDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobSettingsDto}
|
||||
@@ -3809,12 +3774,6 @@ export interface SystemConfigJobDto {
|
||||
* @memberof SystemConfigJobDto
|
||||
*/
|
||||
'migration': JobSettingsDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobSettingsDto}
|
||||
* @memberof SystemConfigJobDto
|
||||
*/
|
||||
'objectTagging': JobSettingsDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobSettingsDto}
|
||||
@@ -3838,7 +3797,7 @@ export interface SystemConfigJobDto {
|
||||
* @type {JobSettingsDto}
|
||||
* @memberof SystemConfigJobDto
|
||||
*/
|
||||
'storageTemplateMigration': JobSettingsDto;
|
||||
'smartSearch': JobSettingsDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobSettingsDto}
|
||||
@@ -3911,12 +3870,6 @@ export interface SystemConfigLoggingDto {
|
||||
* @interface SystemConfigMachineLearningDto
|
||||
*/
|
||||
export interface SystemConfigMachineLearningDto {
|
||||
/**
|
||||
*
|
||||
* @type {ClassificationConfig}
|
||||
* @memberof SystemConfigMachineLearningDto
|
||||
*/
|
||||
'classification': ClassificationConfig;
|
||||
/**
|
||||
*
|
||||
* @type {CLIPConfig}
|
||||
@@ -4079,12 +4032,43 @@ export interface SystemConfigReverseGeocodingDto {
|
||||
*/
|
||||
'enabled': boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface SystemConfigServerDto
|
||||
*/
|
||||
export interface SystemConfigServerDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemConfigServerDto
|
||||
*/
|
||||
'externalDomain': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemConfigServerDto
|
||||
*/
|
||||
'loginPageMessage': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface SystemConfigStorageTemplateDto
|
||||
*/
|
||||
export interface SystemConfigStorageTemplateDto {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof SystemConfigStorageTemplateDto
|
||||
*/
|
||||
'enabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof SystemConfigStorageTemplateDto
|
||||
*/
|
||||
'hashVerificationEnabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@@ -15164,6 +15148,44 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
setAdminOnboarding: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/server-info/admin-onboarding`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
@@ -15255,6 +15277,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.pingServer(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async setAdminOnboarding(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.setAdminOnboarding(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15329,6 +15360,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
|
||||
pingServer(options?: AxiosRequestConfig): AxiosPromise<ServerPingResponse> {
|
||||
return localVarFp.pingServer(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
setAdminOnboarding(options?: AxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.setAdminOnboarding(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15418,6 +15457,16 @@ export class ServerInfoApi extends BaseAPI {
|
||||
public pingServer(options?: AxiosRequestConfig) {
|
||||
return ServerInfoApiFp(this.configuration).pingServer(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof ServerInfoApi
|
||||
*/
|
||||
public setAdminOnboarding(options?: AxiosRequestConfig) {
|
||||
return ServerInfoApiFp(this.configuration).setAdminOnboarding(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
2
cli/src/api/open-api/base.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.91.0
|
||||
* The version of the OpenAPI document: 1.92.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/common.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.91.0
|
||||
* The version of the OpenAPI document: 1.92.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/configuration.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.91.0
|
||||
* The version of the OpenAPI document: 1.92.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/index.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.91.0
|
||||
* The version of the OpenAPI document: 1.92.1
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ImmichApi } from '../api/client';
|
||||
import path from 'node:path';
|
||||
import { SessionService } from '../services/session.service';
|
||||
import { LoginError } from '../cores/errors/login-error';
|
||||
import { exit } from 'node:process';
|
||||
import os from 'os';
|
||||
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
|
||||
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
|
||||
|
||||
export abstract class BaseCommand {
|
||||
protected sessionService!: SessionService;
|
||||
@@ -12,14 +11,11 @@ export abstract class BaseCommand {
|
||||
protected user!: UserResponseDto;
|
||||
protected serverVersion!: ServerVersionResponseDto;
|
||||
|
||||
protected configDir;
|
||||
protected authPath;
|
||||
|
||||
constructor() {
|
||||
const userHomeDir = os.homedir();
|
||||
this.configDir = path.join(userHomeDir, '.config/immich/');
|
||||
this.sessionService = new SessionService(this.configDir);
|
||||
this.authPath = path.join(this.configDir, 'auth.yml');
|
||||
constructor(options: BaseOptionsDto) {
|
||||
if (!options.config) {
|
||||
throw new Error('Config directory is required');
|
||||
}
|
||||
this.sessionService = new SessionService(options.config);
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Asset } from '../cores/models/asset';
|
||||
import { CrawlService } from '../services';
|
||||
import { UploadOptionsDto } from '../cores/dto/upload-options-dto';
|
||||
import { CrawlOptionsDto } from '../cores/dto/crawl-options-dto';
|
||||
|
||||
import fs from 'node:fs';
|
||||
import cliProgress from 'cli-progress';
|
||||
import byteSize from 'byte-size';
|
||||
import { BaseCommand } from '../cli/base-command';
|
||||
@@ -15,8 +15,6 @@ export default class Upload extends BaseCommand {
|
||||
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
|
||||
await this.connect();
|
||||
|
||||
const deviceId = 'CLI';
|
||||
|
||||
const formatResponse = await this.immichApi.serverInfoApi.getSupportedMediaTypes();
|
||||
const crawlService = new CrawlService(formatResponse.data.image, formatResponse.data.video);
|
||||
|
||||
@@ -24,15 +22,28 @@ export default class Upload extends BaseCommand {
|
||||
crawlOptions.pathsToCrawl = paths;
|
||||
crawlOptions.recursive = options.recursive;
|
||||
crawlOptions.exclusionPatterns = options.exclusionPatterns;
|
||||
crawlOptions.includeHidden = options.includeHidden;
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
for (const pathArgument of paths) {
|
||||
const fileStat = await fs.promises.lstat(pathArgument);
|
||||
|
||||
if (fileStat.isFile()) {
|
||||
files.push(pathArgument);
|
||||
}
|
||||
}
|
||||
|
||||
const crawledFiles: string[] = await crawlService.crawl(crawlOptions);
|
||||
|
||||
crawledFiles.push(...files);
|
||||
|
||||
if (crawledFiles.length === 0) {
|
||||
console.log('No assets found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsToUpload = crawledFiles.map((path) => new Asset(path, deviceId));
|
||||
const assetsToUpload = crawledFiles.map((path) => new Asset(path));
|
||||
|
||||
const uploadProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
@@ -49,8 +60,12 @@ export default class Upload extends BaseCommand {
|
||||
|
||||
for (const asset of assetsToUpload) {
|
||||
// Compute total size first
|
||||
await asset.process();
|
||||
await asset.prepare();
|
||||
totalSize += asset.fileSize;
|
||||
|
||||
if (options.albumName) {
|
||||
asset.albumName = options.albumName;
|
||||
}
|
||||
}
|
||||
|
||||
const existingAlbums = (await this.immichApi.albumApi.getAllAlbums()).data;
|
||||
@@ -65,6 +80,10 @@ export default class Upload extends BaseCommand {
|
||||
});
|
||||
|
||||
let skipUpload = false;
|
||||
|
||||
let skipAsset = false;
|
||||
let existingAssetId: string | undefined = undefined;
|
||||
|
||||
if (!options.skipHash) {
|
||||
const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] };
|
||||
|
||||
@@ -73,14 +92,24 @@ export default class Upload extends BaseCommand {
|
||||
});
|
||||
|
||||
skipUpload = checkResponse.data.results[0].action === 'reject';
|
||||
|
||||
const isDuplicate = checkResponse.data.results[0].reason === 'duplicate';
|
||||
if (isDuplicate) {
|
||||
existingAssetId = checkResponse.data.results[0].assetId;
|
||||
}
|
||||
|
||||
skipAsset = skipUpload && !isDuplicate;
|
||||
}
|
||||
|
||||
if (!skipUpload) {
|
||||
if (!skipAsset) {
|
||||
if (!options.dryRun) {
|
||||
const formData = asset.getUploadFormData();
|
||||
const res = await this.uploadAsset(formData);
|
||||
if (!skipUpload) {
|
||||
const formData = asset.getUploadFormData();
|
||||
const res = await this.uploadAsset(formData);
|
||||
existingAssetId = res.data.id;
|
||||
}
|
||||
|
||||
if (options.album && asset.albumName) {
|
||||
if ((options.album || options.albumName) && asset.albumName !== undefined) {
|
||||
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
|
||||
if (!album) {
|
||||
const res = await this.immichApi.albumApi.createAlbum({
|
||||
@@ -90,7 +119,12 @@ export default class Upload extends BaseCommand {
|
||||
existingAlbums.push(album);
|
||||
}
|
||||
|
||||
await this.immichApi.albumApi.addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: [res.data.id] } });
|
||||
if (existingAssetId) {
|
||||
await this.immichApi.albumApi.addAssetsToAlbum({
|
||||
id: album.id,
|
||||
bulkIdsDto: { ids: [existingAssetId] },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
cli/src/constants.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import pkg from '../package.json';
|
||||
|
||||
export interface ICLIVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
export class CLIVersion implements ICLIVersion {
|
||||
constructor(
|
||||
public readonly major: number,
|
||||
public readonly minor: number,
|
||||
public readonly patch: number,
|
||||
) {}
|
||||
|
||||
toString() {
|
||||
return `${this.major}.${this.minor}.${this.patch}`;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const { major, minor, patch } = this;
|
||||
return { major, minor, patch };
|
||||
}
|
||||
|
||||
static fromString(version: string): CLIVersion {
|
||||
const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
|
||||
const matchResult = version.match(regex);
|
||||
if (matchResult) {
|
||||
const [, major, minor, patch] = matchResult.map(Number);
|
||||
return new CLIVersion(major, minor, patch);
|
||||
} else {
|
||||
throw new Error(`Invalid version format: ${version}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cliVersion = CLIVersion.fromString(pkg.version);
|
||||
3
cli/src/cores/dto/base-options-dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class BaseOptionsDto {
|
||||
config?: string;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
export class UploadOptionsDto {
|
||||
recursive = false;
|
||||
exclusionPatterns!: string[];
|
||||
dryRun = false;
|
||||
skipHash = false;
|
||||
delete = false;
|
||||
readOnly = true;
|
||||
album = false;
|
||||
recursive? = false;
|
||||
exclusionPatterns?: string[] = [];
|
||||
dryRun? = false;
|
||||
skipHash? = false;
|
||||
delete? = false;
|
||||
album? = false;
|
||||
albumName? = '';
|
||||
includeHidden? = false;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ export class LoginError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
|
||||
// assign the error class name in your custom error (as a shortcut)
|
||||
this.name = this.constructor.name;
|
||||
|
||||
// capturing the stack trace keeps the reference to your error class
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,51 @@
|
||||
import * as fs from 'fs';
|
||||
import { basename } from 'node:path';
|
||||
import crypto from 'crypto';
|
||||
import Os from 'os';
|
||||
import FormData from 'form-data';
|
||||
import * as fs from 'graceful-fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import Os from 'os';
|
||||
|
||||
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) {
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
this.deviceId = deviceId;
|
||||
}
|
||||
|
||||
async process() {
|
||||
async prepare() {
|
||||
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');
|
||||
|
||||
// TODO: doesn't xmp replace the file extension? Will need investigation
|
||||
const sideCarPath = `${this.path}.xmp`;
|
||||
let sidecarData: fs.ReadStream | undefined = undefined;
|
||||
try {
|
||||
fs.accessSync(sideCarPath, fs.constants.R_OK);
|
||||
sidecarData = createReadStream(sideCarPath);
|
||||
} catch (error) {}
|
||||
|
||||
const data: any = {
|
||||
assetData: this.assetData as any,
|
||||
assetData: createReadStream(this.path),
|
||||
deviceAssetId: this.deviceAssetId,
|
||||
deviceId: this.deviceId,
|
||||
deviceId: 'CLI',
|
||||
fileCreatedAt: this.fileCreatedAt,
|
||||
fileModifiedAt: this.fileModifiedAt,
|
||||
isFavorite: String(false),
|
||||
@@ -61,17 +56,13 @@ export class Asset {
|
||||
formData.append(prop, data[prop]);
|
||||
}
|
||||
|
||||
if (this.sidecarData) {
|
||||
formData.append('sidecarData', this.sidecarData);
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
private getReadStream(path: string): fs.ReadStream {
|
||||
return fs.createReadStream(path);
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
return fs.promises.unlink(this.path);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
#! /usr/bin/env node
|
||||
|
||||
import { program, Option } from 'commander';
|
||||
import { Option, Command } from 'commander';
|
||||
import Upload from './commands/upload';
|
||||
import ServerInfo from './commands/server-info';
|
||||
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);
|
||||
import path from 'node:path';
|
||||
import os from 'os';
|
||||
|
||||
const userHomeDir = os.homedir();
|
||||
const configDir = path.join(userHomeDir, '.config/immich/');
|
||||
|
||||
const program = new Command()
|
||||
.name('immich')
|
||||
.version(version)
|
||||
.description('Command line interface for Immich')
|
||||
.addOption(new Option('-d, --config', 'Configuration directory').env('IMMICH_CONFIG_DIR').default(configDir));
|
||||
|
||||
program
|
||||
.command('upload')
|
||||
@@ -16,11 +26,17 @@ program
|
||||
.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('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
|
||||
.addOption(new Option('-i, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
|
||||
.addOption(
|
||||
new Option('-a, --album', 'Automatically create albums based on folder name')
|
||||
.env('IMMICH_AUTO_CREATE_ALBUM')
|
||||
.default(false),
|
||||
)
|
||||
.addOption(
|
||||
new Option('-A, --album-name <name>', 'Add all assets to specified album')
|
||||
.env('IMMICH_ALBUM_NAME')
|
||||
.conflicts('album'),
|
||||
)
|
||||
.addOption(
|
||||
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
|
||||
.env('IMMICH_DRY_RUN')
|
||||
@@ -30,14 +46,14 @@ program
|
||||
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
||||
.action(async (paths, options) => {
|
||||
options.exclusionPatterns = options.ignore;
|
||||
await new Upload().run(paths, options);
|
||||
await new Upload(program.opts()).run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('server-info')
|
||||
.description('Display server information')
|
||||
.action(async () => {
|
||||
await new ServerInfo().run();
|
||||
await new ServerInfo(program.opts()).run();
|
||||
});
|
||||
|
||||
program
|
||||
@@ -46,14 +62,14 @@ program
|
||||
.argument('[instanceUrl]')
|
||||
.argument('[apiKey]')
|
||||
.action(async (paths, options) => {
|
||||
await new LoginKey().run(paths, options);
|
||||
await new LoginKey(program.opts()).run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('logout')
|
||||
.description('Remove stored credentials')
|
||||
.action(async () => {
|
||||
await new Logout().run();
|
||||
await new Logout(program.opts()).run();
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -19,7 +19,7 @@ const tests: Test[] = [
|
||||
files: {},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
test: 'should crawl a single folder',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
@@ -27,6 +27,25 @@ const tests: Test[] = [
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single file',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/image.jpg'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single file and a folder',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/image.jpg', '/images/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/images/image2.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension',
|
||||
options: {
|
||||
@@ -54,6 +73,7 @@ const tests: Test[] = [
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/raw/**'],
|
||||
recursive: true,
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
@@ -98,6 +118,7 @@ const tests: Test[] = [
|
||||
test: 'should crawl a single path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
recursive: true,
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
@@ -177,6 +198,58 @@ const tests: Test[] = [
|
||||
[`/photos/3.jpg`]: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support ignoring full filename',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos'],
|
||||
exclusionPatterns: ['**/image2.jpg'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/photos/image2.jpg': false,
|
||||
'/photos/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support ignoring file extensions',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos'],
|
||||
exclusionPatterns: ['**/*.png'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/photos/image2.png': false,
|
||||
'/photos/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support ignoring folder names',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos'],
|
||||
recursive: true,
|
||||
exclusionPatterns: ['**/raw/**'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/photos/image/image1.jpg': true,
|
||||
'/photos/raw/image2.dng': false,
|
||||
'/photos/raw/image3.dng': false,
|
||||
'/photos/notraw/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should support ignoring absolute paths',
|
||||
options: {
|
||||
pathsToCrawl: ['/'],
|
||||
recursive: true,
|
||||
exclusionPatterns: ['/images/**'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image1.jpg': true,
|
||||
'/images/image2.jpg': false,
|
||||
'/assets/image3.jpg': true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe(CrawlService.name, () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto';
|
||||
import { glob } from 'glob';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export class CrawlService {
|
||||
private readonly extensions!: string[];
|
||||
@@ -8,21 +9,57 @@ export class CrawlService {
|
||||
this.extensions = image.concat(video).map((extension) => extension.replace('.', ''));
|
||||
}
|
||||
|
||||
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
|
||||
async crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
|
||||
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
|
||||
if (!pathsToCrawl) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const base = pathsToCrawl.length === 1 ? pathsToCrawl[0] : `{${pathsToCrawl.join(',')}}`;
|
||||
const extensions = `*{${this.extensions}}`;
|
||||
const patterns: string[] = [];
|
||||
const crawledFiles: string[] = [];
|
||||
|
||||
return glob(`${base}/**/${extensions}`, {
|
||||
for await (const currentPath of pathsToCrawl) {
|
||||
try {
|
||||
const stats = await fs.promises.stat(currentPath);
|
||||
if (stats.isFile() || stats.isSymbolicLink()) {
|
||||
crawledFiles.push(currentPath);
|
||||
} else {
|
||||
patterns.push(currentPath);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
patterns.push(currentPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let searchPattern: string;
|
||||
if (patterns.length === 1) {
|
||||
searchPattern = patterns[0];
|
||||
} else if (patterns.length === 0) {
|
||||
return crawledFiles;
|
||||
} else {
|
||||
searchPattern = '{' + patterns.join(',') + '}';
|
||||
}
|
||||
|
||||
if (crawlOptions.recursive) {
|
||||
searchPattern = searchPattern + '/**/';
|
||||
}
|
||||
|
||||
searchPattern = `${searchPattern}/*.{${this.extensions.join(',')}}`;
|
||||
|
||||
const globbedFiles = await glob(searchPattern, {
|
||||
absolute: true,
|
||||
nocase: true,
|
||||
nodir: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
});
|
||||
|
||||
const returnedFiles = crawledFiles.concat(globbedFiles);
|
||||
returnedFiles.sort();
|
||||
return returnedFiles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { SessionService } from './session.service';
|
||||
import mockfs from 'mock-fs';
|
||||
import fs from 'node:fs';
|
||||
import yaml from 'yaml';
|
||||
import { LoginError } from '../cores/errors/login-error';
|
||||
import {
|
||||
TEST_AUTH_FILE,
|
||||
TEST_CONFIG_DIR,
|
||||
TEST_IMMICH_API_KEY,
|
||||
TEST_IMMICH_INSTANCE_URL,
|
||||
createTestAuthFile,
|
||||
deleteAuthFile,
|
||||
readTestAuthFile,
|
||||
spyOnConsole,
|
||||
} from '../../test/cli-test-utils';
|
||||
|
||||
const mockPingServer = jest.fn(() => Promise.resolve({ data: { res: 'pong' } }));
|
||||
const mockUserInfo = jest.fn(() => Promise.resolve({ data: { email: 'admin@example.com' } }));
|
||||
@@ -22,74 +31,85 @@ jest.mock('../api/open-api', () => {
|
||||
|
||||
describe('SessionService', () => {
|
||||
let sessionService: SessionService;
|
||||
let consoleSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
// Write a dummy output before mock-fs to prevent some annoying errors
|
||||
console.log();
|
||||
consoleSpy = spyOnConsole();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const configDir = '/config';
|
||||
sessionService = new SessionService(configDir);
|
||||
deleteAuthFile();
|
||||
sessionService = new SessionService(TEST_CONFIG_DIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
deleteAuthFile();
|
||||
});
|
||||
|
||||
it('should connect to immich', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
apiKey: TEST_IMMICH_API_KEY,
|
||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||
}),
|
||||
);
|
||||
|
||||
await sessionService.connect();
|
||||
expect(mockPingServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should error if no auth file exists', async () => {
|
||||
mockfs();
|
||||
await sessionService.connect().catch((error) => {
|
||||
expect(error.message).toEqual('No auth file exist. Please login first');
|
||||
});
|
||||
});
|
||||
|
||||
it('should error if auth file is missing instance URl', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'foo: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\napiKey: https://test/api',
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
apiKey: TEST_IMMICH_API_KEY,
|
||||
}),
|
||||
);
|
||||
await sessionService.connect().catch((error) => {
|
||||
expect(error).toBeInstanceOf(LoginError);
|
||||
expect(error.message).toEqual('Instance URL missing in auth config file /config/auth.yml');
|
||||
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should error if auth file is missing api key', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'instanceUrl: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\nbar: https://test/api',
|
||||
});
|
||||
await sessionService.connect().catch((error) => {
|
||||
expect(error).toBeInstanceOf(LoginError);
|
||||
expect(error.message).toEqual('API key missing in auth config file /config/auth.yml');
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(sessionService.connect()).rejects.toThrow(
|
||||
new LoginError(`API key missing in auth config file ${TEST_AUTH_FILE}`),
|
||||
);
|
||||
});
|
||||
|
||||
it.skip('should create auth file when logged in', async () => {
|
||||
mockfs();
|
||||
it('should create auth file when logged in', async () => {
|
||||
await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
|
||||
|
||||
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
||||
|
||||
const data: string = await fs.promises.readFile('/config/auth.yml', 'utf8');
|
||||
const data: string = await readTestAuthFile();
|
||||
const authConfig = yaml.parse(data);
|
||||
expect(authConfig.instanceUrl).toBe('https://test/api');
|
||||
expect(authConfig.apiKey).toBe('pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
||||
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
|
||||
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
|
||||
});
|
||||
|
||||
it('should delete auth file when logging out', async () => {
|
||||
mockfs({
|
||||
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
|
||||
});
|
||||
await createTestAuthFile(
|
||||
JSON.stringify({
|
||||
apiKey: TEST_IMMICH_API_KEY,
|
||||
instanceUrl: TEST_IMMICH_INSTANCE_URL,
|
||||
}),
|
||||
);
|
||||
await sessionService.logout();
|
||||
|
||||
await fs.promises.access('/auth.yml', fs.constants.F_OK).catch((error) => {
|
||||
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
|
||||
expect(error.message).toContain('ENOENT');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,33 +5,39 @@ import { ImmichApi } from '../api/client';
|
||||
import { LoginError } from '../cores/errors/login-error';
|
||||
|
||||
export class SessionService {
|
||||
readonly configDir: string;
|
||||
readonly configDir!: string;
|
||||
readonly authPath!: string;
|
||||
private api!: ImmichApi;
|
||||
|
||||
constructor(configDir: string) {
|
||||
this.configDir = configDir;
|
||||
this.authPath = path.join(this.configDir, 'auth.yml');
|
||||
this.authPath = path.join(configDir, '/auth.yml');
|
||||
}
|
||||
|
||||
public async connect(): Promise<ImmichApi> {
|
||||
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new LoginError('No auth file exist. Please login first');
|
||||
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||
let apiKey = process.env.IMMICH_API_KEY;
|
||||
|
||||
if (!instanceUrl || !apiKey) {
|
||||
await fs.promises.access(this.authPath, fs.constants.F_OK).catch((error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new LoginError('No auth file exist. Please login first');
|
||||
}
|
||||
});
|
||||
|
||||
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
|
||||
const parsedConfig = yaml.parse(data);
|
||||
|
||||
instanceUrl = parsedConfig.instanceUrl;
|
||||
apiKey = parsedConfig.apiKey;
|
||||
|
||||
if (!instanceUrl) {
|
||||
throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
const data: string = await fs.promises.readFile(this.authPath, 'utf8');
|
||||
const parsedConfig = yaml.parse(data);
|
||||
const instanceUrl: string = parsedConfig.instanceUrl;
|
||||
const apiKey: string = parsedConfig.apiKey;
|
||||
|
||||
if (!instanceUrl) {
|
||||
throw new LoginError('Instance URL missing in auth config file ' + this.authPath);
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new LoginError('API key missing in auth config file ' + this.authPath);
|
||||
if (!apiKey) {
|
||||
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.api = new ImmichApi(instanceUrl, apiKey);
|
||||
@@ -59,10 +65,6 @@ export class SessionService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
console.error('waah');
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
|
||||
|
||||
console.log('Wrote auth info to ' + this.authPath);
|
||||
@@ -82,7 +84,7 @@ export class SessionService {
|
||||
});
|
||||
|
||||
if (pingResponse.res !== 'pong') {
|
||||
throw new Error('Unexpected ping reply');
|
||||
throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
cli/test/cli-test-utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const TEST_CONFIG_DIR = '/tmp/immich/';
|
||||
export const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
|
||||
export const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
|
||||
export const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
|
||||
|
||||
export const CLI_BASE_OPTIONS: BaseOptionsDto = { config: TEST_CONFIG_DIR };
|
||||
|
||||
export const spyOnConsole = () => jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
export const createTestAuthFile = async (contents: string) => {
|
||||
if (!fs.existsSync(TEST_CONFIG_DIR)) {
|
||||
// Create config folder if it doesn't exist
|
||||
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
|
||||
if (!created) {
|
||||
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(TEST_AUTH_FILE, contents);
|
||||
};
|
||||
|
||||
export const readTestAuthFile = async (): Promise<string> => {
|
||||
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
|
||||
};
|
||||
|
||||
export const deleteAuthFile = () => {
|
||||
try {
|
||||
fs.unlinkSync(TEST_AUTH_FILE);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
24
cli/test/e2e/jest-e2e.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"modulePaths": ["<rootDir>"],
|
||||
"rootDir": "../..",
|
||||
"globalSetup": "<rootDir>/test/e2e/setup.ts",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"testTimeout": 6000000,
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/src/**/*.(t|j)s",
|
||||
"!<rootDir>/src/**/*.spec.(t|s)s",
|
||||
"!<rootDir>/src/infra/migrations/**"
|
||||
],
|
||||
"coverageDirectory": "./coverage",
|
||||
"moduleNameMapper": {
|
||||
"^@test(|/.*)$": "<rootDir>../server/test/$1",
|
||||
"^@app/immich(|/.*)$": "<rootDir>../server/src/immich/$1",
|
||||
"^@app/infra(|/.*)$": "<rootDir>../server/src/infra/$1",
|
||||
"^@app/domain(|/.*)$": "<rootDir>/../server/src/domain/$1"
|
||||
}
|
||||
}
|
||||
48
cli/test/e2e/login-key.e2e-spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { api } from '@test/api';
|
||||
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||
import { LoginResponseDto } from 'src/api/open-api';
|
||||
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||
import LoginKey from 'src/commands/login/key';
|
||||
import { LoginError } from 'src/cores/errors/login-error';
|
||||
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||
|
||||
describe(`login-key (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let apiKey: APIKeyCreateResponseDto;
|
||||
let instanceUrl: string;
|
||||
spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
if (!process.env.IMMICH_INSTANCE_URL) {
|
||||
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
|
||||
} else {
|
||||
instanceUrl = process.env.IMMICH_INSTANCE_URL;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||
});
|
||||
|
||||
it('should error when providing an invalid API key', async () => {
|
||||
await expect(async () => await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
|
||||
new LoginError(`Failed to connect to server ${instanceUrl}: Request failed with status code 401`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log in when providing the correct API key', async () => {
|
||||
await new LoginKey(CLI_BASE_OPTIONS).run(instanceUrl, apiKey.secret);
|
||||
});
|
||||
});
|
||||
42
cli/test/e2e/server-info.e2e-spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { api } from '@test/api';
|
||||
import { restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||
import { LoginResponseDto } from 'src/api/open-api';
|
||||
import ServerInfo from 'src/commands/server-info';
|
||||
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||
|
||||
describe(`server-info (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let apiKey: APIKeyCreateResponseDto;
|
||||
const consoleSpy = spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||
});
|
||||
|
||||
it('should show server version', async () => {
|
||||
await new ServerInfo(CLI_BASE_OPTIONS).run();
|
||||
|
||||
expect(consoleSpy.mock.calls).toEqual([
|
||||
[expect.stringMatching(new RegExp('Server is running version \\d+.\\d+.\\d+'))],
|
||||
[expect.stringMatching('Supported image types: .*')],
|
||||
[expect.stringMatching('Supported video types: .*')],
|
||||
['Images: 0, Videos: 0, Total: 0'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
43
cli/test/e2e/setup.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import path from 'path';
|
||||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
import { access } from 'fs/promises';
|
||||
|
||||
export default async () => {
|
||||
let IMMICH_TEST_ASSET_PATH: string = '';
|
||||
|
||||
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
|
||||
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../../server/test/assets/`);
|
||||
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
|
||||
} else {
|
||||
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
|
||||
}
|
||||
|
||||
const directoryExists = async (dirPath: string) =>
|
||||
await access(dirPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!(await directoryExists(`${IMMICH_TEST_ASSET_PATH}/albums`))) {
|
||||
throw new Error(
|
||||
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${IMMICH_TEST_ASSET_PATH} before testing`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.DB_HOSTNAME === undefined) {
|
||||
// DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container.
|
||||
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.11')
|
||||
.withExposedPorts(5432)
|
||||
.withDatabase('immich')
|
||||
.withUsername('postgres')
|
||||
.withPassword('postgres')
|
||||
.withReuse()
|
||||
.start();
|
||||
|
||||
process.env.DB_URL = pg.getConnectionUri();
|
||||
}
|
||||
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.IMMICH_TEST_ENV = 'true';
|
||||
process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/test/e2e/immich-e2e-config.json`);
|
||||
process.env.TZ = 'Z';
|
||||
};
|
||||
84
cli/test/e2e/upload.e2e-spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { api } from '@test/api';
|
||||
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from 'immich/test/test-utils';
|
||||
import { LoginResponseDto } from 'src/api/open-api';
|
||||
import Upload from 'src/commands/upload';
|
||||
import { APIKeyCreateResponseDto } from '@app/domain';
|
||||
import { CLI_BASE_OPTIONS, spyOnConsole } from 'test/cli-test-utils';
|
||||
|
||||
describe(`upload (e2e)`, () => {
|
||||
let server: any;
|
||||
let admin: LoginResponseDto;
|
||||
let apiKey: APIKeyCreateResponseDto;
|
||||
spyOnConsole();
|
||||
|
||||
beforeAll(async () => {
|
||||
server = (await testApp.create()).getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.teardown();
|
||||
await restoreTempFolder();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testApp.reset();
|
||||
await restoreTempFolder();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
apiKey = await api.apiKeyApi.createApiKey(server, admin.accessToken);
|
||||
process.env.IMMICH_API_KEY = apiKey.secret;
|
||||
});
|
||||
|
||||
it('should upload a folder recursively', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
|
||||
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
|
||||
expect(assets.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('should not create a new album', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should create album from folder name', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
album: true,
|
||||
});
|
||||
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(1);
|
||||
const natureAlbum = albums[0];
|
||||
expect(natureAlbum.albumName).toEqual('nature');
|
||||
});
|
||||
|
||||
it('should add existing assets to album', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Upload again, but this time add to album
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
album: true,
|
||||
});
|
||||
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(1);
|
||||
const natureAlbum = albums[0];
|
||||
expect(natureAlbum.albumName).toEqual('nature');
|
||||
});
|
||||
|
||||
it('should upload to the specified album name', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
albumName: 'testAlbum',
|
||||
});
|
||||
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(1);
|
||||
const testAlbum = albums[0];
|
||||
expect(testAlbum.albumName).toEqual('testAlbum');
|
||||
});
|
||||
});
|
||||
3
cli/test/global-setup.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
};
|
||||
@@ -8,17 +8,24 @@
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "es2022",
|
||||
"target": "es2021",
|
||||
"moduleResolution": "node16",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"rootDirs": ["src", "../server/src"],
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@test": ["test"],
|
||||
"@test/*": ["test/*"]
|
||||
"@test": ["../server/test"],
|
||||
"@test/*": ["../server/test/*"],
|
||||
"@app/immich": ["../server/src/immich"],
|
||||
"@app/immich/*": ["../server/src/immich/*"],
|
||||
"@app/infra": ["../server/src/infra"],
|
||||
"@app/infra/*": ["../server/src/infra/*"],
|
||||
"@app/domain": ["../server/src/domain"],
|
||||
"@app/domain/*": ["../server/src/domain/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["dist", "node_modules", "upload"]
|
||||
|
||||
@@ -12,15 +12,15 @@ x-server-build: &server-common
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
target: dev
|
||||
restart: always
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
|
||||
- /usr/src/app/node_modules
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
@@ -87,15 +87,13 @@ services:
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
|
||||
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
|
||||
@@ -51,7 +51,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
|
||||
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
|
||||
restart: always
|
||||
|
||||
database:
|
||||
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
|
||||
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
|
||||
restart: always
|
||||
|
||||
database:
|
||||
|
||||
@@ -11,7 +11,6 @@ services:
|
||||
# volumes:
|
||||
# - /usr/lib/wsl:/usr/lib/wsl # If using VAAPI in WSL2
|
||||
# environment:
|
||||
# - NVIDIA_DRIVER_CAPABILITIES=all # If using NVIDIA GPU
|
||||
# - LD_LIBRARY_PATH=/usr/lib/wsl/lib # If using VAAPI in WSL2
|
||||
# - LIBVA_DRIVER_NAME=d3d12 # If using VAAPI in WSL2
|
||||
# deploy: # Uncomment this section if using NVIDIA GPU
|
||||
|
||||
@@ -26,7 +26,7 @@ Immich optionally uses machine learning for several features. However, it can be
|
||||
|
||||
### How can I lower Immich's CPU usage?
|
||||
|
||||
The initial backup is the most intensive due to the number of jobs running. The most CPU-intensive ones are transcoding and machine learning jobs (Tag Images, Encode CLIP, Recognize Faces), and to a lesser extent thumbnail generation. Here are some ways to lower their CPU usage:
|
||||
The initial backup is the most intensive due to the number of jobs running. The most CPU-intensive ones are transcoding and machine learning jobs (Tag Images, Smart Search, Recognize Faces), and to a lesser extent thumbnail generation. Here are some ways to lower their CPU usage:
|
||||
|
||||
- Lower the job concurrency for these jobs to 1.
|
||||
- Under Settings > Transcoding Settings > Threads, set the number of threads to a low number like 1 or 2.
|
||||
@@ -56,10 +56,6 @@ Template changes will only apply to new assets. To retroactively apply the templ
|
||||
|
||||
This is fixed by running the storage migration job.
|
||||
|
||||
### Why is object detection not very good?
|
||||
|
||||
The default image tagging model is relatively small. You can change this for a larger model like `google/vit-base-patch16-224` by setting the model name under Settings > Machine Learning Settings > Image Tagging. You can then re-run the Image Tagging job to get improved tags.
|
||||
|
||||
### Why are there so many thumbnail generation jobs?
|
||||
|
||||
Immich generates three thumbnails for each asset (blurred, small, and large), as well as a thumbnail for each recognized face.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Backup and Restore
|
||||
|
||||
A [3-2-1 backup strategy](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) is recommended to protect your data. You should keep copies of your uploaded photos/videos as well as the Immich database for a comprehensive backup solution. This page provides an overview on how to backup the database and the location of user-uploaded pictures and videos. A template bash script that can be run as a cron job is provided [here](../guides/template-backup-script)
|
||||
|
||||
## Database
|
||||
|
||||
:::caution
|
||||
|
||||
@@ -28,3 +28,13 @@ server {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caddy example config
|
||||
|
||||
As an alternative to nginx, you can also use [Caddy](https://caddyserver.com/) as a reverse proxy (with automatic HTTPS configuration). Below is an example config.
|
||||
|
||||
```
|
||||
immich.example.org {
|
||||
reverse_proxy http://<snip>:2283
|
||||
}
|
||||
```
|
||||
|
||||
@@ -73,7 +73,7 @@ The Immich Microservices image uses the same `Dockerfile` as the Immich Server,
|
||||
- Thumbnail Generation
|
||||
- Metadata Extraction
|
||||
- Video Transcoding
|
||||
- Object Tagging
|
||||
- Smart Search
|
||||
- Facial Recognition
|
||||
- Storage Template Migration
|
||||
- Sidecar (see [XMP Sidecars](/docs/features/xmp-sidecars.md))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Database Migrations
|
||||
|
||||
After making any changes in the `server/src/infra/database/entities`, a database migration need to run in order to register the changes in the database. Follow the steps below to create a new migration.
|
||||
After making any changes in the `server/src/infra/entities`, a database migration need to run in order to register the changes in the database. Follow the steps below to create a new migration.
|
||||
|
||||
1. Run the command
|
||||
|
||||
|
||||
@@ -25,6 +25,6 @@ Additional actions you can do with a detected person are:
|
||||
- Merge two or more detected faces into one person
|
||||
- Hide face
|
||||
|
||||
It can be found from the app bar when you access the detial view of a person
|
||||
It can be found from the app bar when you access the detail view of a person.
|
||||
|
||||
<img src={require('./img/facial-recognition-4.png').default} title='Facial Recognition 4' width="70%"/>
|
||||
|
||||
@@ -43,12 +43,28 @@ As this is a new feature, it is still experimental and may not work on all syste
|
||||
|
||||
## Setup
|
||||
|
||||
#### Initial Setup
|
||||
|
||||
1. If you do not already have it, download the latest [`hwaccel.yml`][hw-file] file and ensure it's in the same folder as the `docker-compose.yml`.
|
||||
2. Uncomment the lines that apply to your system and desired usage.
|
||||
3. In the `docker-compose.yml` under `immich-microservices`, uncomment the lines relating to the `hwaccel.yml` file.
|
||||
4. Redeploy the `immich-microservices` container with these updated settings.
|
||||
5. In the Admin page under `FFmpeg settings`, change the hardware acceleration setting to the appropriate option and save.
|
||||
|
||||
#### All-In-One - Unraid Setup
|
||||
|
||||
##### NVENC - NVIDIA GPUs
|
||||
|
||||
- If you are using other backends. You will still need to implement [`hwaccel.yml`][hw-file] file into the `immich-microservices` service directly, please see the "Initial Setup" section above on how to do that.
|
||||
- As of v1.92.0, steps 1 and 2 are no longer necessary. If your version of Immich is below that or missing the environment variables, please follow these steps. Otherwise, skip to step 3.
|
||||
- Please note that`NVIDIA_DRIVER_CAPABILITIES` is no longer required to enter as a variable.
|
||||
|
||||
1. Assuming you already have the Nvidia Driver Plugin installed on your Unraid Server. Please confirm that your Nvida GPU is showing up with its GPU ID in the Nvidia Driver Plugin. The ID will be `GPU-LONG_STRING_OF_CHARACTERS`. Copy the GPU ID.
|
||||
2. In the Imagegenius/Immich Docker Container app, add two new variables: Key=`NVIDIA_VISIBLE_DEVICES` Value=`GPU-LONG_STRING_OF_CHARACTERS` and Key=`NVIDIA_DRIVER_CAPABILITIES` Value=`all`
|
||||
3. While you are in the docker container app, change the Container from Basic Mode to Advanced Mode and add the following parameter to the Extra Parameters field: `--runtime=nvidia`
|
||||
4. Restart the Imagegenius/Immich Docker Container app.
|
||||
5. In the Admin page under FFmpeg settings, change the hardware acceleration setting to the appropriate option and save.
|
||||
|
||||
## Tips
|
||||
|
||||
- You may want to choose a slower preset than for software transcoding to maintain quality and efficiency
|
||||
|
||||
100
docs/docs/guides/external-library.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# External Library
|
||||
|
||||
This guide walks you through adding an [External Library](../features/libraries#external-libraries).
|
||||
This guide assumes you are running Immich in Docker and that the files you wish to access are stored
|
||||
in a directory on the same machine.
|
||||
|
||||
# Mount the directory into the containers.
|
||||
|
||||
Edit `docker-compose.yml` to add two new mount points under `volumes:`
|
||||
|
||||
```
|
||||
immich-server:
|
||||
volumes:
|
||||
- ${EXTERNAL_PATH}:/usr/src/app/external
|
||||
```
|
||||
|
||||
Be sure to add exactly the same line to both `immich-server:` and `immich-microservices:`.
|
||||
|
||||
Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer:
|
||||
|
||||
```
|
||||
EXTERNAL_PATH=<your-path-here>
|
||||
```
|
||||
|
||||
On my computer, for example, I use this path:
|
||||
|
||||
```
|
||||
EXTERNAL_PATH=/home/tenino/photos
|
||||
```
|
||||
|
||||
Restart Immich.
|
||||
|
||||
```
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
# Set the External Path
|
||||
|
||||
In the Immich web UI:
|
||||
|
||||
- click the **Administration** link in the upper right corner.
|
||||
<img src={require('./img/administration-link.png').default} width="50%" title="Administration link" />
|
||||
|
||||
- Select the **Users** tab
|
||||
<img src={require('./img/users-tab.png').default} width="50%" title="Users tab" />
|
||||
|
||||
- Select the **pencil** next to your user ID
|
||||
<img src={require('./img/pencil.png').default} width="50%" title="Pencil" />
|
||||
|
||||
- Fill in the **External Path** field with `/usr/src/app/external`
|
||||
<img src={require('./img/external-path.png').default} width="50%" title="External Path field" />
|
||||
|
||||
Notice this matches the path _inside the container_ where we mounted your photos.
|
||||
The purpose of the external path field is for administrators who have multiple users
|
||||
on their Immich instance. It lets you prevent other authorized users from
|
||||
navigating to your external library.
|
||||
|
||||
# Import the library
|
||||
|
||||
In the Immich web UI:
|
||||
|
||||
- Click your user avatar in the upper-right corner (circle with your initials)
|
||||
<img src={require('./img/user-avatar.png').default} width="50%" title="User avatar" />
|
||||
|
||||
- Click **Account Settings**
|
||||
<img src={require('./img/account-settings.png').default} width="50%" title="Account Settings button" />
|
||||
|
||||
- Click to expand **Libraries**
|
||||
<img src={require('./img/libraries-dropdown.png').default} width="50%" title="Libraries dropdown" />
|
||||
|
||||
- Click the **Create External Library** button
|
||||
<img src={require('./img/create-external-library-button.png').default} width="50%" title="Create External Library button" />
|
||||
|
||||
- Click the three-dots menu and select **Edit Import Paths**
|
||||
<img src={require('./img/edit-import-paths.png').default} width="50%" title="Edit Import Paths menu option" />
|
||||
|
||||
- Click \*_Add path_
|
||||
<img src={require('./img/add-path-button.png').default} width="50%" title="Add Path button" />
|
||||
|
||||
- Enter **.** as the path and click Add
|
||||
<img src={require('./img/add-path-field.png').default} width="50%" title="Add Path field" />
|
||||
|
||||
- Save the new path
|
||||
<img src={require('./img/path-save.png').default} width="50%" title="Path Save button" />
|
||||
|
||||
- Click the three-dots menu and select **Scan New Library Files**
|
||||
<img src={require('./img/scan-new-library-files.png').default} width="50%" title="Scan New Library Files menu option" />
|
||||
|
||||
# Confirm stuff is happening
|
||||
|
||||
- Click **Administration**
|
||||
<img src={require('./img/administration-link.png').default} width="50%" title="Administration link" />
|
||||
|
||||
- Select the **Jobs** tab
|
||||
<img src={require('./img/jobs-tab.png').default} width="50%" title="Jobs tab" />
|
||||
|
||||
- You should see non-zero Active jobs for
|
||||
Library, Generate Thumbnails, and Extract Metadata.
|
||||
<img src={require('./img/job-status.png').default} width="50%" title="Job Status display" />
|
||||
BIN
docs/docs/guides/img/account-settings.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/docs/guides/img/add-path-button.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
docs/docs/guides/img/add-path-field.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/docs/guides/img/administration-link.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/docs/guides/img/create-external-library-button.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/docs/guides/img/edit-import-paths.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/docs/guides/img/external-path.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/docs/guides/img/job-status.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
docs/docs/guides/img/jobs-tab.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/docs/guides/img/libraries-dropdown.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
docs/docs/guides/img/path-save.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/docs/guides/img/pencil.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
docs/docs/guides/img/scan-new-library-files.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/docs/guides/img/user-avatar.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/docs/guides/img/users-tab.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -41,7 +41,7 @@ If you are unable to open a port on your router for Wireguard or OpenVPN to your
|
||||
|
||||
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).
|
||||
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](/docs/administration/reverse-proxy.md).
|
||||
|
||||
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.
|
||||
|
||||
|
||||
82
docs/docs/guides/template-backup-script.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Backup Script
|
||||
|
||||
[Borg](https://www.borgbackup.org/) is a feature-rich deduplicating archiving software with built-in versioning. We provide a template bash script that can be run daily/weekly as a [cron](https://wiki.archlinux.org/title/cron) job to back up your files and database. We encourage you to read the quick-start guide for Borg before running this script.
|
||||
|
||||
This script assumes you have a second hard drive connected to your server for on-site backup and ssh access to a remote machine for your third off-site copy. [BorgBase](https://www.borgbase.com/) is an alternative option for off-site backups with a competitive pricing structure. You may choose to skip off-site backups entirely by removing the relevant lines from the template script.
|
||||
|
||||
The database is saved to your Immich upload folder in the `database-backup` subdirectory. The database is then backed up and versioned with your assets by Borg. This ensures that the database backup is in sync with your assets in every snapshot.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Borg needs to be installed on your server as well as the remote machine. You can find instructions to install Borg [here](https://borgbackup.readthedocs.io/en/latest/installation.html).
|
||||
- To run this sript as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).
|
||||
- To run this script non-interactively, set up [passwordless ssh](https://www.redhat.com/sysadmin/passwordless-ssh) to your remote machine from your server.
|
||||
|
||||
To initialize the borg repository, run the following commands once.
|
||||
|
||||
```bash title='Borg set-up'
|
||||
UPLOAD_LOCATION="/path/to/immich/directory" # Immich database location, as set in your .env file
|
||||
BACKUP_PATH="/path/to/local/backup/directory"
|
||||
|
||||
mkdir "$UPLOAD_LOCATION/database-backup"
|
||||
mkdir "$BACKUP_PATH/immich-borg"
|
||||
|
||||
borg init --encryption=none "$BACKUP_PATH/immich-borg"
|
||||
|
||||
## Remote set up
|
||||
REMOTE_HOST="remote_host@IP"
|
||||
REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
|
||||
|
||||
ssh "$REMOTE_HOST" "mkdir $REMOTE_BACKUP_PATH/immich-borg"
|
||||
ssh "$REMOTE_HOST" "borg init --encryption=none $REMOTE_BACKUP_PATH/immich-borg"
|
||||
```
|
||||
|
||||
Edit the following script as necessary and add it to your crontab. Note that this script assumes there are no spaces in your paths. If there are spaces, enclose the paths in double quotes.
|
||||
|
||||
```bash title='Borg backup template'
|
||||
#!/bin/sh
|
||||
|
||||
# Paths
|
||||
UPLOAD_LOCATION="/path/to/immich/directory"
|
||||
BACKUP_PATH="/path/to/local/backup/directory"
|
||||
REMOTE_HOST="remote_host@IP"
|
||||
REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
|
||||
|
||||
|
||||
### Local
|
||||
|
||||
# Backup Immich database
|
||||
docker exec -t immich_postgres pg_dumpall -c -U postgres | /usr/bin/gzip > $UPLOAD_LOCATION/database-backup/immich-database.sql.gz
|
||||
|
||||
### Append to local Borg repository
|
||||
borg create $BACKUP_PATH/immich-borg::{now} $UPLOAD_LOCATION --exclude $UPLOAD_LOCATION/thumbs/ --exclude $UPLOAD_LOCATION/encoded-video/
|
||||
borg prune --keep-weekly=4 --keep-monthly=3 $BACKUP_PATH/immich-borg
|
||||
borg compact $BACKUP_PATH/immich-borg
|
||||
|
||||
|
||||
### Append to remote Borg repository
|
||||
borg create $REMOTE_HOST:$REMOTE_BACKUP_PATH/immich-borg::{now} $UPLOAD_LOCATION --exclude $UPLOAD_LOCATION/thumbs/ --exclude $UPLOAD_LOCATION/encoded-video/
|
||||
borg prune --keep-weekly=4 --keep-monthly=3 $REMOTE_HOST:$REMOTE_BACKUP_PATH/immich-borg
|
||||
borg compact $REMOTE_HOST:$REMOTE_BACKUP_PATH/immich-borg
|
||||
```
|
||||
|
||||
### Restoring
|
||||
|
||||
To restore from a backup, use the `borg mount` command.
|
||||
|
||||
```bash title='Restore from local backup'
|
||||
BACKUP_PATH="/path/to/local/backup/directory"
|
||||
mkdir /tmp/immich-mountpoint
|
||||
borg mount $BACKUP_PATH/immich-borg /tmp/immich-mountpoint
|
||||
cd /tmp/immich-mountpoint
|
||||
```
|
||||
|
||||
```bash title='Restore from remote backup'
|
||||
REMOTE_HOST="remote_host@IP"
|
||||
REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
|
||||
mkdir /tmp/immich-mountpoint
|
||||
borg mount $REMOTE_HOST:$REMOTE_BACKUP_PATH/immich-borg /tmp/immich-mountpoint
|
||||
cd /tmp/immich-mountpoint
|
||||
```
|
||||
|
||||
You can find available snapshots in seperate sub-directories at `/tmp/immich-mountpoint`. Restore the files you need, and unmount the Borg repository using `borg umount /tmp/immich-mountpoint`
|
||||
@@ -32,15 +32,12 @@ The default configuration looks like this:
|
||||
"backgroundTask": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"clipEncoding": {
|
||||
"smartSearch": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"metadataExtraction": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"objectTagging": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"recognizeFaces": {
|
||||
"concurrency": 2
|
||||
},
|
||||
@@ -66,14 +63,13 @@ The default configuration looks like this:
|
||||
"concurrency": 1
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"enabled": true,
|
||||
"level": "log"
|
||||
},
|
||||
"machineLearning": {
|
||||
"enabled": true,
|
||||
"url": "http://immich-machine-learning:3003",
|
||||
"classification": {
|
||||
"enabled": true,
|
||||
"modelName": "microsoft/resnet-50",
|
||||
"minScore": 0.9
|
||||
},
|
||||
"clip": {
|
||||
"enabled": true,
|
||||
"modelName": "ViT-B-32__openai"
|
||||
@@ -88,7 +84,8 @@ The default configuration looks like this:
|
||||
},
|
||||
"map": {
|
||||
"enabled": true,
|
||||
"tileUrl": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
"lightStyle": "",
|
||||
"darkStyle": ""
|
||||
},
|
||||
"reverseGeocoding": {
|
||||
"enabled": true
|
||||
@@ -133,9 +130,6 @@ The default configuration looks like this:
|
||||
"enabled": true,
|
||||
"cronExpression": "0 0 * * *"
|
||||
}
|
||||
},
|
||||
"stylesheets": {
|
||||
"css": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -148,4 +142,4 @@ So you can just grab it from there, paste it into a file and you're pretty much
|
||||
### Step 2 - Specify the file location
|
||||
|
||||
In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
|
||||
For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section.
|
||||
For more information, refer to the [Environment Variables](/docs/install/environment-variables.md) section.
|
||||
|
||||
@@ -55,7 +55,7 @@ Optionally, you can use the [`hwaccel.yml`][hw-file] file to enable hardware acc
|
||||
|
||||
### Step 3 - Start the containers
|
||||
|
||||
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"
|
||||
docker compose up -d
|
||||
|
||||
@@ -30,14 +30,15 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## General
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :-------------------------- | :------------------------------------------- | :----------: | :------------------------------------------- |
|
||||
| `TZ` | Timezone | | microservices |
|
||||
| `NODE_ENV` | Environment (production, development) | `production` | server, microservices, machine learning, web |
|
||||
| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices |
|
||||
| `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message | | web |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server |
|
||||
| Variable | Description | Default | Services |
|
||||
| :------------------------------ | :------------------------------------------- | :------------------: | :------------------------------------------- |
|
||||
| `TZ` | Timezone | | microservices |
|
||||
| `NODE_ENV` | Environment (production, development) | `production` | server, microservices, machine learning, web |
|
||||
| `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices |
|
||||
| `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices |
|
||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server |
|
||||
| `IMMICH_WEB_ROOT` | Path of root index.html | `/usr/src/app/www` | server |
|
||||
| `IMMICH_REVERSE_GEOCODING_ROOT` | Path of reverse geocoding dump directory | `/usr/src/resources` | microservices |
|
||||
|
||||
:::tip
|
||||
|
||||
@@ -47,12 +48,6 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
:::
|
||||
|
||||
## Geocoding
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :--------------------------------- | :------------------------------- | :--------------------------: | :------------ |
|
||||
| `REVERSE_GEOCODING_DUMP_DIRECTORY` | Reverse Geocoding Dump Directory | `./.reverse-geocoding-dump/` | microservices |
|
||||
|
||||
## Ports
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Help Me!
|
||||
|
||||
BIN
docs/docs/overview/img/upload-button.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Logo
|
||||
|
||||
87
docs/docs/overview/quick-start.mdx
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Quick Start
|
||||
|
||||
Here is a quick, no-choices path to install Immich and take it for a test drive.
|
||||
Once you've tried it, perhaps you'll use one of the many other ways
|
||||
to install and use it.
|
||||
|
||||
## Requirements
|
||||
|
||||
Check the [requirements page](../install/requirements) to get started.
|
||||
|
||||
## Install and launch via Docker Compose
|
||||
|
||||
Follow the [Docker Compose (Recommended)](../install/docker-compose) instructions
|
||||
to install the server.
|
||||
|
||||
- Where random passwords are required, `pwgen` is a handy utility.
|
||||
- `UPLOAD_LOCATION` should be set to some new directory on the server
|
||||
with free space.
|
||||
- You may ignore "Step 4 - Upgrading".
|
||||
|
||||
## Try the Web UI
|
||||
|
||||
import RegisterAdminUser from '../partials/_register-admin.md';
|
||||
|
||||
<RegisterAdminUser />
|
||||
|
||||
Try uploading a picture from your browser.
|
||||
|
||||
<img src={require('./img/upload-button.png').default} title="Upload button" />
|
||||
|
||||
## Try the Mobile UI
|
||||
|
||||
### Download the Mobile App
|
||||
|
||||
import MobileAppDownload from '../partials/_mobile-app-download.md';
|
||||
|
||||
<MobileAppDownload />
|
||||
|
||||
### Login to the Mobile App
|
||||
|
||||
import MobileAppLogin from '../partials/_mobile-app-login.md';
|
||||
|
||||
<MobileAppLogin />
|
||||
|
||||
In the mobile app, you should see the photo you uploaded from the web UI.
|
||||
|
||||
### Transfer Photos from your Mobile Device
|
||||
|
||||
import MobileAppBackup from '../partials/_mobile-app-backup.md';
|
||||
|
||||
<MobileAppBackup />
|
||||
|
||||
Depending on how many photos are on your mobile device, this backup may
|
||||
take quite a while.
|
||||
|
||||
You can select the Jobs tab to see Immich processing your photos.
|
||||
|
||||
<img src={require('../guides/img/jobs-tab.png').default} title="Jobs tab" />
|
||||
|
||||
## Set up your backups
|
||||
|
||||
You may want to back up the content of your Immich instance
|
||||
along with other parts of your server; be sure to read about
|
||||
[database backup](../administration/backup-and-restore).
|
||||
|
||||
## Where to go from here?
|
||||
|
||||
You may decide you'd like to install the server a different way;
|
||||
the Install category on the left menu provides many options.
|
||||
|
||||
You may decide you'd like to add the _rest_ of your photos from Google Photos,
|
||||
even those not on your mobile device, via Google Takeout.
|
||||
You can use [immich-go](https://github.com/simulot/immich-go) for this.
|
||||
|
||||
You may want to
|
||||
[upload photos from your own archive](../features/command-line-interface).
|
||||
|
||||
You may want to incorporate an immutable archive of photos from an
|
||||
[External Library](../features/libraries#external-libraries);
|
||||
there's a [Guide](../guides/external-library) for that.
|
||||
|
||||
You may want your mobile device to
|
||||
[back photos up to your server automatically](../features/automatic-backup).
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Support The Project
|
||||
|
||||
204
docs/package-lock.json
generated
@@ -221,11 +221,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
|
||||
"integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"@babel/highlight": "^7.23.4",
|
||||
"chalk": "^2.4.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -342,11 +342,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
|
||||
"integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
|
||||
"integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.23.0",
|
||||
"@babel/types": "^7.23.6",
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"@jridgewell/trace-mapping": "^0.3.17",
|
||||
"jsesc": "^2.5.1"
|
||||
@@ -625,9 +625,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
|
||||
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -675,9 +675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
|
||||
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
|
||||
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
@@ -752,9 +752,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
|
||||
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
|
||||
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
@@ -2098,19 +2098,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz",
|
||||
"integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz",
|
||||
"integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/generator": "^7.23.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
"@babel/generator": "^7.23.6",
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-function-name": "^7.23.0",
|
||||
"@babel/helper-hoist-variables": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/parser": "^7.23.0",
|
||||
"@babel/types": "^7.23.0",
|
||||
"debug": "^4.1.0",
|
||||
"@babel/parser": "^7.23.6",
|
||||
"@babel/types": "^7.23.6",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2118,11 +2118,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
|
||||
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
|
||||
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-string-parser": "^7.23.4",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
},
|
||||
@@ -2890,9 +2890,9 @@
|
||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
|
||||
},
|
||||
"node_modules/@mdi/js": {
|
||||
"version": "7.3.67",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.3.67.tgz",
|
||||
"integrity": "sha512-MnRjknFqpTC6FifhGHjZ0+QYq2bAkZFQqIj8JA2AdPZbBxUvr8QSgB2yPAJ8/ob/XkR41xlg5majDR3c1JP1hw=="
|
||||
"version": "7.4.47",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
|
||||
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ=="
|
||||
},
|
||||
"node_modules/@mdi/react": {
|
||||
"version": "1.6.1",
|
||||
@@ -4830,9 +4830,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
|
||||
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||
},
|
||||
"node_modules/clean-css": {
|
||||
"version": "5.3.2",
|
||||
@@ -9194,9 +9194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10024,9 +10024,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"version": "8.4.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
|
||||
"integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -10042,7 +10042,7 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
@@ -10850,9 +10850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz",
|
||||
"integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz",
|
||||
"integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
@@ -13332,9 +13332,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
|
||||
"integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz",
|
||||
"integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -13666,9 +13666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
|
||||
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
|
||||
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15205,11 +15205,11 @@
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
|
||||
"integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
|
||||
"requires": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"@babel/highlight": "^7.23.4",
|
||||
"chalk": "^2.4.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15299,11 +15299,11 @@
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
|
||||
"integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
|
||||
"integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
|
||||
"requires": {
|
||||
"@babel/types": "^7.23.0",
|
||||
"@babel/types": "^7.23.6",
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"@jridgewell/trace-mapping": "^0.3.17",
|
||||
"jsesc": "^2.5.1"
|
||||
@@ -15504,9 +15504,9 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
|
||||
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ=="
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.22.20",
|
||||
@@ -15539,9 +15539,9 @@
|
||||
}
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
|
||||
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
|
||||
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
@@ -15600,9 +15600,9 @@
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
|
||||
"integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw=="
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
|
||||
"integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ=="
|
||||
},
|
||||
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
|
||||
"version": "7.22.15",
|
||||
@@ -16473,28 +16473,28 @@
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz",
|
||||
"integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz",
|
||||
"integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.22.13",
|
||||
"@babel/generator": "^7.23.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
"@babel/generator": "^7.23.6",
|
||||
"@babel/helper-environment-visitor": "^7.22.20",
|
||||
"@babel/helper-function-name": "^7.23.0",
|
||||
"@babel/helper-hoist-variables": "^7.22.5",
|
||||
"@babel/helper-split-export-declaration": "^7.22.6",
|
||||
"@babel/parser": "^7.23.0",
|
||||
"@babel/types": "^7.23.0",
|
||||
"debug": "^4.1.0",
|
||||
"@babel/parser": "^7.23.6",
|
||||
"@babel/types": "^7.23.6",
|
||||
"debug": "^4.3.1",
|
||||
"globals": "^11.1.0"
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
|
||||
"integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
|
||||
"version": "7.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
|
||||
"integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
|
||||
"requires": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-string-parser": "^7.23.4",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
@@ -17069,9 +17069,9 @@
|
||||
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A=="
|
||||
},
|
||||
"@mdi/js": {
|
||||
"version": "7.3.67",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.3.67.tgz",
|
||||
"integrity": "sha512-MnRjknFqpTC6FifhGHjZ0+QYq2bAkZFQqIj8JA2AdPZbBxUvr8QSgB2yPAJ8/ob/XkR41xlg5majDR3c1JP1hw=="
|
||||
"version": "7.4.47",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
|
||||
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ=="
|
||||
},
|
||||
"@mdi/react": {
|
||||
"version": "1.6.1",
|
||||
@@ -18522,9 +18522,9 @@
|
||||
"integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw=="
|
||||
},
|
||||
"classnames": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
|
||||
"integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||
},
|
||||
"clean-css": {
|
||||
"version": "5.3.2",
|
||||
@@ -21697,9 +21697,9 @@
|
||||
}
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
|
||||
},
|
||||
"native-promise-only": {
|
||||
"version": "0.8.1",
|
||||
@@ -22304,11 +22304,11 @@
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"version": "8.4.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
|
||||
"integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
|
||||
"requires": {
|
||||
"nanoid": "^3.3.6",
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
@@ -22803,9 +22803,9 @@
|
||||
"integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA=="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz",
|
||||
"integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz",
|
||||
"integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-error": {
|
||||
@@ -24689,9 +24689,9 @@
|
||||
}
|
||||
},
|
||||
"tailwindcss": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
|
||||
"integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==",
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz",
|
||||
"integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==",
|
||||
"requires": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -24929,9 +24929,9 @@
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
|
||||
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ=="
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
|
||||
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw=="
|
||||
},
|
||||
"ua-parser-js": {
|
||||
"version": "1.0.36",
|
||||
|
||||
@@ -26,12 +26,14 @@ import {
|
||||
mdiMagnify,
|
||||
mdiMap,
|
||||
mdiMaterialDesign,
|
||||
mdiMatrix,
|
||||
mdiMerge,
|
||||
mdiMonitor,
|
||||
mdiMotionPlayOutline,
|
||||
mdiPalette,
|
||||
mdiPanVertical,
|
||||
mdiPartyPopper,
|
||||
mdiPencil,
|
||||
mdiRaw,
|
||||
mdiRotate360,
|
||||
mdiSecurity,
|
||||
@@ -52,6 +54,24 @@ import React from 'react';
|
||||
import Timeline, { DateType, Item } from '../components/timeline';
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
icon: mdiMatrix,
|
||||
description: 'Moved the search from typesense to pgvecto.rs',
|
||||
title: 'Search improvement with pgvecto.rs',
|
||||
release: 'v1.91.0',
|
||||
tag: 'v1.91.0',
|
||||
date: new Date(2023, 11, 15),
|
||||
dateType: DateType.RELEASE,
|
||||
},
|
||||
{
|
||||
icon: mdiPencil,
|
||||
description: "Edit a photo or video's date, time, hours, timezone, and GPS information",
|
||||
title: 'Edit metadata',
|
||||
release: 'v1.90.0',
|
||||
tag: 'v1.90.0',
|
||||
date: new Date(2023, 11, 7),
|
||||
dateType: DateType.RELEASE,
|
||||
},
|
||||
{
|
||||
icon: mdiVectorCombine,
|
||||
description:
|
||||
|
||||
1
docs/_redirects → docs/static/_redirects
vendored
@@ -23,4 +23,3 @@
|
||||
/docs/features/storage-template /docs/administration/storage-template 301
|
||||
/docs/features/user-management /docs/administration/user-management 301
|
||||
/docs/developer/contributing /docs/developer/pr-checklist 301
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"redirects": [
|
||||
{ "source": "/docs", "destination": "/docs/overview/introduction" },
|
||||
{ "source": "/docs/mobile-app-beta-program", "destination": "/docs/features/mobile-app" },
|
||||
{ "source": "/docs/contribution-guidelines", "destination": "/docs/overview/support-the-project#contributing" },
|
||||
{ "source": "/docs/install", "destination": "/docs/install/docker-compose" },
|
||||
{ "source": "/docs/installation/one-step-installation", "destination": "/docs/install/script" },
|
||||
{ "source": "/docs/installation/portainer-installation", "destination": "/docs/install/portainer" },
|
||||
{ "source": "/docs/installation/recommended-installation", "destination": "/docs/install/docker-compose" },
|
||||
{ "source": "/docs/installation/unraid", "destination": "/docs/install/unraid" },
|
||||
{ "source": "/docs/installation/requirements", "destination": "/docs/install/requirements" },
|
||||
{ "source": "/docs/overview/logo-meaning", "destination": "/docs/overview/logo" },
|
||||
{ "source": "/docs/overview/technology-stack", "destination": "/docs/developer/architecture" },
|
||||
{ "source": "/docs/usage/automatic-backup", "destination": "/docs/features/automatic-backup" },
|
||||
{ "source": "/docs/usage/bulk-upload", "destination": "/docs/features/command-line-interface" },
|
||||
{ "source": "/docs/features/bulk-upload", "destination": "/docs/features/command-line-interface" },
|
||||
{ "source": "/docs/usage/oauth", "destination": "/docs/administration/oauth" },
|
||||
{ "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/server-commands", "destination": "/docs/administration/server-commands" },
|
||||
{ "source": "/docs/features/jobs", "destination": "/docs/administration/jobs" },
|
||||
{ "source": "/docs/features/oauth", "destination": "/docs/administration/oauth" },
|
||||
{ "source": "/docs/features/password-login", "destination": "/docs/administration/password-login" },
|
||||
{ "source": "/docs/features/server-commands", "destination": "/docs/administration/server-commands" },
|
||||
{ "source": "/docs/features/storage-template", "destination": "/docs/administration/storage-template" },
|
||||
{ "source": "/docs/features/user-management", "destination": "/docs/administration/user-management" },
|
||||
{ "source": "/docs/developer/contributing", "destination": "/docs/developer/pr-checklist" }
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,8 @@ download:
|
||||
locale_code: pl-PL
|
||||
- file: mobile/assets/i18n/fi-FI.json
|
||||
locale_code: fi-FI
|
||||
- file: mobile/assets/i18n/pt-PT.json
|
||||
locale_code: pt-PT
|
||||
- file: mobile/assets/i18n/pt-BR.json
|
||||
locale_code: pt-BR
|
||||
- file: mobile/assets/i18n/cs-CZ.json
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-bookworm@sha256:ba7a7ac30c38e119c4304f98ef0e188f90f4f67a958bb6899da9defb99bfb471 as builder
|
||||
FROM python:3.11-bookworm@sha256:291405e32318285d8913b7b03293777c255fb1e89305c82aa495ac747b0049fe as builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
@@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:cfd7ed5c11a88ce533d69a1da2fd932d647f9eb6791c5b4ddce081aedf7f7876
|
||||
FROM python:3.11-slim-bookworm@sha256:8f64a67710f3d981cf3008d6f9f1dbe61accd7927f165f4e37ea3f8b883ccc3f
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -25,6 +25,11 @@ ENV NODE_ENV=production \
|
||||
PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONPATH=/usr/src
|
||||
|
||||
# prevent core dumps
|
||||
RUN echo "hard core 0" >> /etc/security/limits.conf && \
|
||||
echo "fs.suid_dumpable 0" >> /etc/sysctl.conf && \
|
||||
echo 'ulimit -S -c 0 > /dev/null 2>&1' >> /etc/profile
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY start.sh log_conf.json ./
|
||||
COPY app .
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Immich Machine Learning
|
||||
|
||||
- Image classification
|
||||
- CLIP embeddings
|
||||
- Facial recognition
|
||||
|
||||
|
||||
@@ -59,3 +59,37 @@ def clip_preprocess_cfg() -> dict[str, Any]:
|
||||
"resize_mode": "shortest",
|
||||
"fill_color": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def clip_tokenizer_cfg() -> dict[str, Any]:
|
||||
return {
|
||||
"add_prefix_space": False,
|
||||
"added_tokens_decoder": {
|
||||
"49406": {
|
||||
"content": "<|startoftext|>",
|
||||
"lstrip": False,
|
||||
"normalized": True,
|
||||
"rstrip": False,
|
||||
"single_word": False,
|
||||
"special": True,
|
||||
},
|
||||
"49407": {
|
||||
"content": "<|endoftext|>",
|
||||
"lstrip": False,
|
||||
"normalized": True,
|
||||
"rstrip": False,
|
||||
"single_word": False,
|
||||
"special": True,
|
||||
},
|
||||
},
|
||||
"bos_token": "<|startoftext|>",
|
||||
"clean_up_tokenization_spaces": True,
|
||||
"do_lower_case": True,
|
||||
"eos_token": "<|endoftext|>",
|
||||
"errors": "replace",
|
||||
"model_max_length": 77,
|
||||
"pad_token": "<|endoftext|>",
|
||||
"tokenizer_class": "CLIPTokenizer",
|
||||
"unk_token": "<|endoftext|>",
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ from .base import InferenceModel
|
||||
from .clip import MCLIPEncoder, OpenCLIPEncoder
|
||||
from .constants import is_insightface, is_mclip, is_openclip
|
||||
from .facial_recognition import FaceRecognizer
|
||||
from .image_classification import ImageClassifier
|
||||
|
||||
|
||||
def from_model_type(model_type: ModelType, model_name: str, **model_kwargs: Any) -> InferenceModel:
|
||||
@@ -19,8 +18,6 @@ def from_model_type(model_type: ModelType, model_name: str, **model_kwargs: Any)
|
||||
case ModelType.FACIAL_RECOGNITION:
|
||||
if is_insightface(model_name):
|
||||
return FaceRecognizer(model_name, **model_kwargs)
|
||||
case ModelType.IMAGE_CLASSIFICATION:
|
||||
return ImageClassifier(model_name, **model_kwargs)
|
||||
case _:
|
||||
raise ValueError(f"Unknown model type {model_type}")
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class InferenceModel(ABC):
|
||||
)
|
||||
log.debug(
|
||||
(
|
||||
f"Setting '{self.model_name}' execution providers to {self.providers}"
|
||||
f"Setting '{self.model_name}' execution providers to {self.providers} "
|
||||
"in descending order of preference"
|
||||
),
|
||||
)
|
||||
@@ -55,7 +55,7 @@ class InferenceModel(ABC):
|
||||
def download(self) -> None:
|
||||
if not self.cached:
|
||||
log.info(
|
||||
(f"Downloading {self.model_type.replace('-', ' ')} model '{self.model_name}'." "This may take a while.")
|
||||
f"Downloading {self.model_type.replace('-', ' ')} model '{self.model_name}'. This may take a while."
|
||||
)
|
||||
self._download()
|
||||
|
||||
@@ -63,7 +63,7 @@ class InferenceModel(ABC):
|
||||
if self.loaded:
|
||||
return
|
||||
self.download()
|
||||
log.info(f"Loading {self.model_type.replace('-', ' ')} model '{self.model_name}'")
|
||||
log.info(f"Loading {self.model_type.replace('-', ' ')} model '{self.model_name}' to memory")
|
||||
self._load()
|
||||
self.loaded = True
|
||||
|
||||
@@ -119,11 +119,11 @@ class InferenceModel(ABC):
|
||||
def clear_cache(self) -> None:
|
||||
if not self.cache_dir.exists():
|
||||
log.warn(
|
||||
f"Attempted to clear cache for model '{self.model_name}' but cache directory does not exist.",
|
||||
f"Attempted to clear cache for model '{self.model_name}', but cache directory does not exist",
|
||||
)
|
||||
return
|
||||
if not rmtree.avoids_symlink_attacks:
|
||||
raise RuntimeError("Attempted to clear cache, but rmtree is not safe on this platform.")
|
||||
raise RuntimeError("Attempted to clear cache, but rmtree is not safe on this platform")
|
||||
|
||||
if self.cache_dir.is_dir():
|
||||
log.info(f"Cleared cache directory for model '{self.model_name}'.")
|
||||
|
||||
@@ -8,11 +8,11 @@ from typing import Any, Literal
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
from transformers import AutoTokenizer
|
||||
from tokenizers import Encoding, Tokenizer
|
||||
|
||||
from app.config import clean_name, log
|
||||
from app.models.transforms import crop, get_pil_resampling, normalize, resize, to_numpy
|
||||
from app.schemas import ModelType, ndarray_f32, ndarray_i32, ndarray_i64
|
||||
from app.schemas import ModelType, ndarray_f32, ndarray_i32
|
||||
|
||||
from .base import InferenceModel
|
||||
|
||||
@@ -40,6 +40,7 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
log.debug(f"Loaded clip text model '{self.model_name}'")
|
||||
|
||||
if self.mode == "vision" or self.mode is None:
|
||||
log.debug(f"Loading clip vision model '{self.model_name}'")
|
||||
@@ -50,6 +51,7 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
log.debug(f"Loaded clip vision model '{self.model_name}'")
|
||||
|
||||
def _predict(self, image_or_text: Image.Image | str) -> ndarray_f32:
|
||||
if isinstance(image_or_text, bytes):
|
||||
@@ -99,6 +101,14 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
def visual_path(self) -> Path:
|
||||
return self.visual_dir / "model.onnx"
|
||||
|
||||
@property
|
||||
def tokenizer_file_path(self) -> Path:
|
||||
return self.textual_dir / "tokenizer.json"
|
||||
|
||||
@property
|
||||
def tokenizer_cfg_path(self) -> Path:
|
||||
return self.textual_dir / "tokenizer_config.json"
|
||||
|
||||
@property
|
||||
def preprocess_cfg_path(self) -> Path:
|
||||
return self.visual_dir / "preprocess_cfg.json"
|
||||
@@ -107,6 +117,34 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
def cached(self) -> bool:
|
||||
return self.textual_path.is_file() and self.visual_path.is_file()
|
||||
|
||||
@cached_property
|
||||
def model_cfg(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading model config for CLIP model '{self.model_name}'")
|
||||
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
|
||||
log.debug(f"Loaded model config for CLIP model '{self.model_name}'")
|
||||
return model_cfg
|
||||
|
||||
@cached_property
|
||||
def tokenizer_file(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading tokenizer file for CLIP model '{self.model_name}'")
|
||||
tokenizer_file: dict[str, Any] = json.load(self.tokenizer_file_path.open())
|
||||
log.debug(f"Loaded tokenizer file for CLIP model '{self.model_name}'")
|
||||
return tokenizer_file
|
||||
|
||||
@cached_property
|
||||
def tokenizer_cfg(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading tokenizer config for CLIP model '{self.model_name}'")
|
||||
tokenizer_cfg: dict[str, Any] = json.load(self.tokenizer_cfg_path.open())
|
||||
log.debug(f"Loaded tokenizer config for CLIP model '{self.model_name}'")
|
||||
return tokenizer_cfg
|
||||
|
||||
@cached_property
|
||||
def preprocess_cfg(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading visual preprocessing config for CLIP model '{self.model_name}'")
|
||||
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open())
|
||||
log.debug(f"Loaded visual preprocessing config for CLIP model '{self.model_name}'")
|
||||
return preprocess_cfg
|
||||
|
||||
|
||||
class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
def __init__(
|
||||
@@ -121,26 +159,26 @@ class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
def _load(self) -> None:
|
||||
super()._load()
|
||||
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(self.textual_dir)
|
||||
self.sequence_length = self.model_cfg["text_cfg"]["context_length"]
|
||||
context_length = self.model_cfg["text_cfg"]["context_length"]
|
||||
pad_token = self.tokenizer_cfg["pad_token"]
|
||||
|
||||
size = self.preprocess_cfg["size"]
|
||||
self.size = size[0] if isinstance(size, list) else size
|
||||
|
||||
self.size = (
|
||||
self.preprocess_cfg["size"][0] if type(self.preprocess_cfg["size"]) == list else self.preprocess_cfg["size"]
|
||||
)
|
||||
self.resampling = get_pil_resampling(self.preprocess_cfg["interpolation"])
|
||||
self.mean = np.array(self.preprocess_cfg["mean"], dtype=np.float32)
|
||||
self.std = np.array(self.preprocess_cfg["std"], dtype=np.float32)
|
||||
|
||||
log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'")
|
||||
self.tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
|
||||
pad_id = self.tokenizer.token_to_id(pad_token)
|
||||
self.tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
|
||||
self.tokenizer.enable_truncation(max_length=context_length)
|
||||
log.debug(f"Loaded tokenizer for CLIP model '{self.model_name}'")
|
||||
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
input_ids: ndarray_i64 = self.tokenizer(
|
||||
text,
|
||||
max_length=self.sequence_length,
|
||||
return_tensors="np",
|
||||
return_attention_mask=False,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
).input_ids
|
||||
return {"text": input_ids.astype(np.int32)}
|
||||
tokens: Encoding = self.tokenizer.encode(text)
|
||||
return {"text": np.array([tokens.ids], dtype=np.int32)}
|
||||
|
||||
def transform(self, image: Image.Image) -> dict[str, ndarray_f32]:
|
||||
image = resize(image, self.size)
|
||||
@@ -149,18 +187,11 @@ class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
image_np = normalize(image_np, self.mean, self.std)
|
||||
return {"image": np.expand_dims(image_np.transpose(2, 0, 1), 0)}
|
||||
|
||||
@cached_property
|
||||
def model_cfg(self) -> dict[str, Any]:
|
||||
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
|
||||
return model_cfg
|
||||
|
||||
@cached_property
|
||||
def preprocess_cfg(self) -> dict[str, Any]:
|
||||
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open())
|
||||
return preprocess_cfg
|
||||
|
||||
|
||||
class MCLIPEncoder(OpenCLIPEncoder):
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
tokens: dict[str, ndarray_i64] = self.tokenizer(text, return_tensors="np")
|
||||
return {k: v.astype(np.int32) for k, v in tokens.items()}
|
||||
tokens: Encoding = self.tokenizer.encode(text)
|
||||
return {
|
||||
"input_ids": np.array([tokens.ids], dtype=np.int32),
|
||||
"attention_mask": np.array([tokens.attention_mask], dtype=np.int32),
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ _OPENCLIP_MODELS = {
|
||||
"ViT-L-14-336__openai",
|
||||
"ViT-H-14__laion2b-s32b-b79k",
|
||||
"ViT-g-14__laion2b-s12b-b42k",
|
||||
"ViT-L-14-quickgelu__dfn2b",
|
||||
"ViT-H-14-quickgelu__dfn5b",
|
||||
"ViT-H-14-378-quickgelu__dfn5b",
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +37,9 @@ _MCLIP_MODELS = {
|
||||
"XLM-Roberta-Large-Vit-B-32",
|
||||
"XLM-Roberta-Large-Vit-B-16Plus",
|
||||
"XLM-Roberta-Large-Vit-L-14",
|
||||
"XLM-Roberta-Large-ViT-H-14__frozen_laion5b_s13b_b90k",
|
||||
"nllb-clip-base-siglip__v1",
|
||||
"nllb-clip-large-siglip__v1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
from optimum.onnxruntime import ORTModelForImageClassification
|
||||
from optimum.pipelines import pipeline
|
||||
from PIL import Image
|
||||
from transformers import AutoImageProcessor
|
||||
|
||||
from ..config import log
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
|
||||
class ImageClassifier(InferenceModel):
|
||||
_model_type = ModelType.IMAGE_CLASSIFICATION
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
min_score: float = 0.9,
|
||||
cache_dir: Path | str | None = None,
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
self.min_score = model_kwargs.pop("minScore", min_score)
|
||||
super().__init__(model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self) -> None:
|
||||
snapshot_download(
|
||||
cache_dir=self.cache_dir,
|
||||
repo_id=self.model_name,
|
||||
allow_patterns=["*.bin", "*.json", "*.txt"],
|
||||
local_dir=self.cache_dir,
|
||||
local_dir_use_symlinks=True,
|
||||
)
|
||||
|
||||
def _load(self) -> None:
|
||||
processor = AutoImageProcessor.from_pretrained(self.cache_dir, cache_dir=self.cache_dir)
|
||||
model_path = self.cache_dir / "model.onnx"
|
||||
model_kwargs = {
|
||||
"cache_dir": self.cache_dir,
|
||||
"provider": self.providers[0],
|
||||
"provider_options": self.provider_options[0],
|
||||
"session_options": self.sess_options,
|
||||
}
|
||||
|
||||
if model_path.exists():
|
||||
model = ORTModelForImageClassification.from_pretrained(self.cache_dir, **model_kwargs)
|
||||
self.model = pipeline(self.model_type.value, model, feature_extractor=processor)
|
||||
else:
|
||||
log.info(
|
||||
(
|
||||
f"ONNX model not found in cache directory for '{self.model_name}'."
|
||||
"Exporting optimized model for future use."
|
||||
),
|
||||
)
|
||||
self.sess_options.optimized_model_filepath = model_path.as_posix()
|
||||
self.model = pipeline(
|
||||
self.model_type.value,
|
||||
self.model_name,
|
||||
model_kwargs=model_kwargs,
|
||||
feature_extractor=processor,
|
||||
)
|
||||
|
||||
def _predict(self, image: Image.Image | bytes) -> list[str]:
|
||||
if isinstance(image, bytes):
|
||||
image = Image.open(BytesIO(image))
|
||||
predictions: list[dict[str, Any]] = self.model(image)
|
||||
tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score]
|
||||
|
||||
return tags
|
||||
|
||||
def configure(self, **model_kwargs: Any) -> None:
|
||||
self.min_score = model_kwargs.pop("minScore", self.min_score)
|
||||
@@ -25,7 +25,6 @@ class BoundingBox(TypedDict):
|
||||
|
||||
|
||||
class ModelType(StrEnum):
|
||||
IMAGE_CLASSIFICATION = "image-classification"
|
||||
CLIP = "clip"
|
||||
FACIAL_RECOGNITION = "facial-recognition"
|
||||
|
||||
@@ -43,4 +42,4 @@ class Face(TypedDict):
|
||||
|
||||
|
||||
def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
|
||||
return hasattr(obj, "profiling") and type(obj.profiling) == dict
|
||||
return hasattr(obj, "profiling") and isinstance(obj.profiling, dict)
|
||||
|
||||
@@ -17,42 +17,9 @@ from .models.base import PicklableSessionOptions
|
||||
from .models.cache import ModelCache
|
||||
from .models.clip import OpenCLIPEncoder
|
||||
from .models.facial_recognition import FaceRecognizer
|
||||
from .models.image_classification import ImageClassifier
|
||||
from .schemas import ModelType
|
||||
|
||||
|
||||
class TestImageClassifier:
|
||||
classifier_preds = [
|
||||
{"label": "that's an image alright", "score": 0.8},
|
||||
{"label": "well it ends with .jpg", "score": 0.1},
|
||||
{"label": "idk, im just seeing bytes", "score": 0.05},
|
||||
{"label": "not sure", "score": 0.04},
|
||||
{"label": "probably a virus", "score": 0.01},
|
||||
]
|
||||
|
||||
def test_min_score(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(ImageClassifier, "load")
|
||||
classifier = ImageClassifier("test_model_name", min_score=0.0)
|
||||
assert classifier.min_score == 0.0
|
||||
|
||||
classifier.model = mock.Mock()
|
||||
classifier.model.return_value = self.classifier_preds
|
||||
|
||||
all_labels = classifier.predict(pil_image)
|
||||
classifier.min_score = 0.5
|
||||
filtered_labels = classifier.predict(pil_image)
|
||||
|
||||
assert all_labels == [
|
||||
"that's an image alright",
|
||||
"well it ends with .jpg",
|
||||
"idk",
|
||||
"im just seeing bytes",
|
||||
"not sure",
|
||||
"probably a virus",
|
||||
]
|
||||
assert filtered_labels == ["that's an image alright"]
|
||||
|
||||
|
||||
class TestCLIP:
|
||||
embedding = np.random.rand(512).astype(np.float32)
|
||||
cache_dir = Path("test_cache")
|
||||
@@ -63,11 +30,13 @@ class TestCLIP:
|
||||
mocker: MockerFixture,
|
||||
clip_model_cfg: dict[str, Any],
|
||||
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
|
||||
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
||||
) -> None:
|
||||
mocker.patch.object(OpenCLIPEncoder, "download")
|
||||
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
|
||||
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
|
||||
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
|
||||
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
||||
mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True)
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
|
||||
@@ -85,11 +54,13 @@ class TestCLIP:
|
||||
mocker: MockerFixture,
|
||||
clip_model_cfg: dict[str, Any],
|
||||
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
|
||||
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
||||
) -> None:
|
||||
mocker.patch.object(OpenCLIPEncoder, "download")
|
||||
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
|
||||
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
|
||||
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
|
||||
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
||||
mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True)
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
|
||||
@@ -145,17 +116,15 @@ class TestFaceRecognition:
|
||||
class TestCache:
|
||||
async def test_caches(self, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache()
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
assert len(model_cache.cache._cache) == 1
|
||||
mock_get_model.assert_called_once()
|
||||
|
||||
async def test_kwargs_used(self, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache()
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION, cache_dir="test_cache")
|
||||
mock_get_model.assert_called_once_with(
|
||||
ModelType.IMAGE_CLASSIFICATION, "test_model_name", cache_dir="test_cache"
|
||||
)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, cache_dir="test_cache")
|
||||
mock_get_model.assert_called_once_with(ModelType.FACIAL_RECOGNITION, "test_model_name", cache_dir="test_cache")
|
||||
|
||||
async def test_different_clip(self, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache()
|
||||
@@ -172,14 +141,14 @@ class TestCache:
|
||||
@mock.patch("app.models.cache.OptimisticLock", autospec=True)
|
||||
async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache(ttl=100)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
|
||||
|
||||
@mock.patch("app.models.cache.SimpleMemoryCache.expire")
|
||||
async def test_revalidate(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache(ttl=100, revalidate=True)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
mock_cache_expire.assert_called_once_with(mock.ANY, 100)
|
||||
|
||||
|
||||
@@ -188,23 +157,6 @@ class TestCache:
|
||||
reason="More time-consuming since it deploys the app and loads models.",
|
||||
)
|
||||
class TestEndpoints:
|
||||
def test_tagging_endpoint(
|
||||
self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient
|
||||
) -> None:
|
||||
byte_image = BytesIO()
|
||||
pil_image.save(byte_image, format="jpeg")
|
||||
response = deployed_app.post(
|
||||
"http://localhost:3003/predict",
|
||||
data={
|
||||
"modelName": "microsoft/resnet-50",
|
||||
"modelType": "image-classification",
|
||||
"options": json.dumps({"minScore": 0.0}),
|
||||
},
|
||||
files={"image": byte_image.getvalue()},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == responses["image-classification"]
|
||||
|
||||
def test_clip_image_endpoint(
|
||||
self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient
|
||||
) -> None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:5ea70d22075f7209d0410e28b7ce5b1703623099fa04d1154081156b180f739c as builder
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:a7033851ca71454ed8cfbc958fdd62a72e6fa90f71ccb3aa9b15e67a98f66a1e as builder
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
TRANSFORMERS_CACHE=/cache \
|
||||
|
||||
@@ -12,7 +12,6 @@ byte_image = BytesIO()
|
||||
|
||||
@events.init_command_line_parser.add_listener
|
||||
def _(parser: ArgumentParser) -> None:
|
||||
parser.add_argument("--tag-model", type=str, default="microsoft/resnet-50")
|
||||
parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai")
|
||||
parser.add_argument("--face-model", type=str, default="buffalo_l")
|
||||
parser.add_argument(
|
||||
@@ -54,18 +53,6 @@ class InferenceLoadTest(HttpUser):
|
||||
self.data = byte_image.getvalue()
|
||||
|
||||
|
||||
class ClassificationFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def classify(self) -> None:
|
||||
data = [
|
||||
("modelName", self.environment.parsed_options.clip_model),
|
||||
("modelType", "clip"),
|
||||
("options", json.dumps({"minScore": self.environment.parsed_options.tag_min_score})),
|
||||
]
|
||||
files = {"image": self.data}
|
||||
self.client.post("/predict", data=data, files=files)
|
||||
|
||||
|
||||
class CLIPTextFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def encode_text(self) -> None:
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "app.config.CustomRichHandler",
|
||||
"formatter": "rich",
|
||||
"level": "INFO"
|
||||
"formatter": "rich"
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
|
||||
3080
machine-learning/poetry.lock
generated
@@ -1,50 +1,40 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.91.0"
|
||||
version = "1.92.1"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
packages = [{include = "app"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "~3.11"
|
||||
torch = [
|
||||
{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"}
|
||||
]
|
||||
transformers = "^4.29.2"
|
||||
python = ">=3.10,<3.12"
|
||||
onnxruntime = "^1.15.0"
|
||||
insightface = "^0.7.3"
|
||||
opencv-python-headless = "^4.7.0.72"
|
||||
pillow = "^9.5.0"
|
||||
fastapi = "^0.95.2"
|
||||
uvicorn = {extras = ["standard"], version = "^0.22.0"}
|
||||
insightface = ">=0.7.3,<1.0"
|
||||
opencv-python-headless = ">=4.7.0.72,<5.0"
|
||||
pillow = ">=9.5.0,<11.0"
|
||||
fastapi = ">=0.95.2,<1.0"
|
||||
uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
|
||||
pydantic = "^1.10.8"
|
||||
aiocache = "^0.12.1"
|
||||
optimum = "^1.9.1"
|
||||
rich = "^13.4.2"
|
||||
ftfy = "^6.1.1"
|
||||
aiocache = ">=0.12.1,<1.0"
|
||||
rich = ">=13.4.2"
|
||||
ftfy = ">=6.1.1"
|
||||
setuptools = "^68.0.0"
|
||||
python-multipart = "^0.0.6"
|
||||
orjson = "^3.9.5"
|
||||
safetensors = "0.3.2"
|
||||
gunicorn = "^21.1.0"
|
||||
python-multipart = ">=0.0.6,<1.0"
|
||||
orjson = ">=3.9.5"
|
||||
gunicorn = ">=21.1.0"
|
||||
huggingface-hub = ">=0.20.1,<1.0"
|
||||
tokenizers = ">=0.15.0,<1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mypy = "^1.3.0"
|
||||
black = "^23.3.0"
|
||||
pytest = "^7.3.1"
|
||||
locust = "^2.15.1"
|
||||
httpx = "^0.24.1"
|
||||
pytest-asyncio = "^0.21.0"
|
||||
pytest-cov = "^4.1.0"
|
||||
ruff = "^0.0.272"
|
||||
pytest-mock = "^3.11.1"
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
priority = "explicit"
|
||||
mypy = ">=1.3.0"
|
||||
black = ">=23.3.0"
|
||||
pytest = ">=7.3.1"
|
||||
locust = ">=2.15.1"
|
||||
httpx = ">=0.24.1"
|
||||
pytest-asyncio = ">=0.21.0"
|
||||
pytest-cov = ">=4.1.0"
|
||||
ruff = ">=0.0.272"
|
||||
pytest-mock = ">=3.11.1"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
||||
@@ -1 +1,54 @@
|
||||
# Immich Mobile Application - Flutter
|
||||
|
||||
The Immich mobile app is a Flutter-based solution leveraging the Isar Database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance.
|
||||
|
||||
## Setup
|
||||
|
||||
You must set up Flutter toolchain in your machine before you can perform any of the development.
|
||||
|
||||
## Immich-Flutter Directory Structure
|
||||
|
||||
Below are the directory inside the `lib` directory:
|
||||
|
||||
- `constants`: Store essential constants utilized across the application, like colors and locale.
|
||||
|
||||
- `extensions`: Extensions enhancing various existing functionalities within the app, such as asset_extensions.dart, string_extensions.dart, and more.
|
||||
|
||||
- `module_template`: Provides a template structure for different modules within the app, including subdivisions like models, providers, services, UI, and views.
|
||||
- `models`: Placeholder for storing module-specific models.
|
||||
- `providers`: Section to define module-specific Riverpod providers.
|
||||
- `services`: Houses services tailored to the module's functionality.
|
||||
- `ui`: Contains UI components and widgets for the module.
|
||||
- `views`: Placeholder for module-specific views.
|
||||
|
||||
- `modules`: Organizes different functional modules of the app, each containing subdivisions for models, providers, services, UI, and views. This structure promotes modular development and scalability.
|
||||
|
||||
- `routing`: Includes guards like auth_guard.dart, backup_permission_guard.dart, and routers like router.dart and router.gr.dart for streamlined navigation and permission management.
|
||||
|
||||
- `shared`: cache, models, providers, services, ui, views: Encapsulates shared functionalities, such as caching mechanisms, common models, providers, services, UI components, and views accessible across the application.
|
||||
|
||||
- `utils`: A collection of utility classes and functions catering to different app functionalities, including async_mutex.dart, bytes_units.dart, debounce.dart, migration.dart, and more.
|
||||
|
||||
## Immich Architectural Pattern
|
||||
|
||||
The Immich Flutter app embraces a well-defined architectural pattern inspired by the Model-View-ViewModel (MVVM) approach. This layout organizes modules for models, providers, services, UI, and views, creating a modular development approach that strongly emphasizes a clean separation of concerns.
|
||||
|
||||
Please use the `module_template` provided to create a new module.
|
||||
|
||||
### Architecture Breakdown
|
||||
|
||||
Below is how your code needs to be structured:
|
||||
|
||||
- Models: In Immich, Models are like the app's blueprint—they're essential for organizing and using information. Imagine them as containers that hold data the app needs to function. They also handle basic rules and logic for managing and interacting with this data across the app.
|
||||
|
||||
- Providers (Riverpod): Providers in Immich are a bit like traffic managers. They help different parts of the app communicate and share information effectively. They ensure that the right data gets to the right places at the right time. These providers use Riverpod, a tool that helps with managing and organizing how the app's information flows. Everything related to the state goes here.
|
||||
|
||||
- Services: Services are the helpful behind-the-scenes workers in Immich. They handle important tasks like handling network requests or managing other essential functions. These services work independently and focus on supporting the app's main functionalities.
|
||||
|
||||
- UI: In Immich, the UI focuses solely on how things appear and feel without worrying about the app's complex inner workings. You can slot in your reusable widget here.
|
||||
|
||||
- Views: Views use Providers to get the needed information and handle actions without dealing with the technical complexities behind the scenes. Normally Flutter's screen & pages goes here.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please refer to the [architecture](https://immich.app/docs/developer/architecture/) for contributing to the mobile app!
|
||||
|
||||
@@ -52,7 +52,6 @@ dart_code_metrics:
|
||||
- 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
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 115,
|
||||
"android.injected.version.name" => "1.91.0",
|
||||
"android.injected.version.code" => 117,
|
||||
"android.injected.version.name" => "1.92.1",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000217">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="27.74518">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="66.694734">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="25.612783">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="27.6926">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"action_common_cancel": "Cancel",
|
||||
"action_common_update": "Update",
|
||||
"add_to_album_bottom_sheet_added": "Added to {album}",
|
||||
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
||||
"advanced_settings_log_level_title": "Log level: {}",
|
||||
"advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.",
|
||||
"advanced_settings_prefer_remote_title": "Prefereix imatges remotes",
|
||||
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
|
||||
@@ -32,6 +35,8 @@
|
||||
"app_bar_signout_dialog_title": "Sign out",
|
||||
"archive_page_no_archived_assets": "No s'ha trobat res arxivat",
|
||||
"archive_page_title": "Arxiu({})",
|
||||
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
||||
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
|
||||
"asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
|
||||
"asset_list_layout_settings_group_automatically": "Automàtic",
|
||||
"asset_list_layout_settings_group_by": "Group assets by",
|
||||
@@ -106,6 +111,9 @@
|
||||
"cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
|
||||
"cache_settings_clear_cache_button": "Clear cache",
|
||||
"cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
|
||||
"cache_settings_duplicated_assets_clear_button": "CLEAR",
|
||||
"cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app",
|
||||
"cache_settings_duplicated_assets_title": "Duplicated Assets ({})",
|
||||
"cache_settings_image_cache_size": "Image cache size ({} assets)",
|
||||
"cache_settings_statistics_album": "Library thumbnails",
|
||||
"cache_settings_statistics_assets": "{} elements ({})",
|
||||
@@ -134,11 +142,14 @@
|
||||
"control_bottom_app_bar_archive": "Arxiu",
|
||||
"control_bottom_app_bar_create_new_album": "Crea un àlbum nou",
|
||||
"control_bottom_app_bar_delete": "Esborra",
|
||||
"control_bottom_app_bar_edit_location": "Edit Location",
|
||||
"control_bottom_app_bar_edit_time": "Edit Date & Time",
|
||||
"control_bottom_app_bar_favorite": "Preferit",
|
||||
"control_bottom_app_bar_share": "Share",
|
||||
"control_bottom_app_bar_share_to": "Share To",
|
||||
"control_bottom_app_bar_stack": "Stack",
|
||||
"control_bottom_app_bar_unarchive": "Desarxiva",
|
||||
"control_bottom_app_bar_unfavorite": "Unfavorite",
|
||||
"control_bottom_app_bar_upload": "Upload",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"create_shared_album_page_create": "Create",
|
||||
@@ -158,9 +169,13 @@
|
||||
"delete_shared_link_dialog_title": "Delete Shared Link",
|
||||
"description_input_hint_text": "Afegeix descripció...",
|
||||
"description_input_submit_error": "Error updating description, check the log for more details",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"edit_location_dialog_title": "Location",
|
||||
"exif_bottom_sheet_description": "Afegeix descripció",
|
||||
"exif_bottom_sheet_details": "DETALLS",
|
||||
"exif_bottom_sheet_location": "UBICACIÓ",
|
||||
"exif_bottom_sheet_location_add": "Add a location",
|
||||
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
||||
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
||||
"experimental_settings_subtitle": "Use at your own risk!",
|
||||
@@ -189,12 +204,22 @@
|
||||
"library_page_favorites": "Favorites",
|
||||
"library_page_new_album": "New album",
|
||||
"library_page_sharing": "Sharing",
|
||||
"library_page_sort_created": "Most recently created",
|
||||
"library_page_sort_asset_count": "Number of assets",
|
||||
"library_page_sort_created": "Created date",
|
||||
"library_page_sort_last_modified": "Last modified",
|
||||
"library_page_sort_most_oldest_photo": "Oldest photo",
|
||||
"library_page_sort_most_recent_photo": "Most recent photo",
|
||||
"library_page_sort_title": "Album title",
|
||||
"location_picker_choose_on_map": "Choose on map",
|
||||
"location_picker_latitude": "Latitude",
|
||||
"location_picker_latitude_error": "Enter a valid latitude",
|
||||
"location_picker_latitude_hint": "Enter your latitude here",
|
||||
"location_picker_longitude": "Longitude",
|
||||
"location_picker_longitude_error": "Enter a valid longitude",
|
||||
"location_picker_longitude_hint": "Enter your longitude here",
|
||||
"login_disabled": "Login has been disabled",
|
||||
"login_form_api_exception": "API exception. Please check the server URL and try again.",
|
||||
"login_form_back_button_text": "Back",
|
||||
"login_form_button_text": "Entra",
|
||||
"login_form_email_hint": "elteu@correu.cat",
|
||||
"login_form_endpoint_hint": "http://ip-del-servidor:port/api",
|
||||
@@ -217,15 +242,23 @@
|
||||
"login_form_server_error": "Could not connect to server.",
|
||||
"login_password_changed_error": "There was an error updating your password",
|
||||
"login_password_changed_success": "Password updated successfully",
|
||||
"map_assets_in_bound": "{} photo",
|
||||
"map_assets_in_bounds": "{} photos",
|
||||
"map_cannot_get_user_location": "Cannot get user's location",
|
||||
"map_location_dialog_cancel": "Cancel",
|
||||
"map_location_dialog_yes": "Yes",
|
||||
"map_location_picker_page_use_location": "Use this location",
|
||||
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
|
||||
"map_location_service_disabled_title": "Location Service disabled",
|
||||
"map_no_assets_in_bounds": "No photos in this area",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
"map_settings_dark_mode": "Dark mode",
|
||||
"map_settings_date_range_option_all": "All",
|
||||
"map_settings_date_range_option_day": "Past 24 hours",
|
||||
"map_settings_date_range_option_days": "Past {} days",
|
||||
"map_settings_date_range_option_year": "Past year",
|
||||
"map_settings_date_range_option_years": "Past {} years",
|
||||
"map_settings_dialog_cancel": "Cancel",
|
||||
"map_settings_dialog_save": "Save",
|
||||
"map_settings_dialog_title": "Map Settings",
|
||||
@@ -235,6 +268,8 @@
|
||||
"map_zoom_to_see_photos": "Zoom out to see photos",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"motion_photos_page_title": "Motion Photos",
|
||||
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
|
||||
"multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping",
|
||||
"notification_permission_dialog_cancel": "Cancel·la",
|
||||
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
|
||||
"notification_permission_dialog_settings": "Configuració",
|
||||
@@ -261,13 +296,18 @@
|
||||
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
|
||||
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
|
||||
"profile_drawer_app_logs": "Logs",
|
||||
"profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.",
|
||||
"profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.",
|
||||
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
||||
"profile_drawer_documentation": "Documentation",
|
||||
"profile_drawer_github": "GitHub",
|
||||
"profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.",
|
||||
"profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.",
|
||||
"profile_drawer_settings": "Settings",
|
||||
"profile_drawer_sign_out": "Tanca la sessió",
|
||||
"profile_drawer_trash": "Trash",
|
||||
"recently_added_page_title": "Recently Added",
|
||||
"scaffold_body_error_occurred": "Error occurred",
|
||||
"search_bar_hint": "Search your photos",
|
||||
"search_page_categories": "Categories",
|
||||
"search_page_favorites": "Preferides",
|
||||
@@ -275,6 +315,13 @@
|
||||
"search_page_no_objects": "No Objects Info Available",
|
||||
"search_page_no_places": "No Places Info Available",
|
||||
"search_page_people": "Persones",
|
||||
"search_page_person_add_name_dialog_cancel": "Cancel",
|
||||
"search_page_person_add_name_dialog_hint": "Name",
|
||||
"search_page_person_add_name_dialog_save": "Save",
|
||||
"search_page_person_add_name_dialog_title": "Add a name",
|
||||
"search_page_person_add_name_subtitle": "Find them fast by name with search",
|
||||
"search_page_person_add_name_title": "Add a name",
|
||||
"search_page_person_edit_name": "Edit name",
|
||||
"search_page_places": "Llocs",
|
||||
"search_page_recently_added": "Afegit recentment",
|
||||
"search_page_screenshots": "Captures de pantalla",
|
||||
@@ -283,6 +330,7 @@
|
||||
"search_page_videos": "Videos",
|
||||
"search_page_view_all_button": "Veure tot",
|
||||
"search_page_your_activity": "Your activity",
|
||||
"search_page_your_map": "Your Map",
|
||||
"search_result_page_new_search_hint": "Cerca nova",
|
||||
"search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ",
|
||||
"search_suggestion_list_smart_search_hint_2": "m:your-search-term",
|
||||
@@ -290,6 +338,7 @@
|
||||
"select_user_for_sharing_page_err_album": "Error al crear l'àlbum",
|
||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||
"server_info_box_app_version": "Versió de l'aplicació",
|
||||
"server_info_box_latest_release": "Latest Version",
|
||||
"server_info_box_server_url": "Server URL",
|
||||
"server_info_box_server_version": "Versió del servidor",
|
||||
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
|
||||
@@ -321,9 +370,17 @@
|
||||
"shared_album_activity_remove_title": "Delete Activity",
|
||||
"shared_album_activity_setting_subtitle": "Let others respond",
|
||||
"shared_album_activity_setting_title": "Comments & likes",
|
||||
"shared_album_section_people_action_error": "Error leaving/removing from album",
|
||||
"shared_album_section_people_action_leave": "Remove user from album",
|
||||
"shared_album_section_people_action_remove_user": "Remove user from album",
|
||||
"shared_album_section_people_owner_label": "Owner",
|
||||
"shared_album_section_people_title": "PEOPLE",
|
||||
"share_dialog_preparing": "Preparing...",
|
||||
"shared_link_app_bar_title": "Shared Links",
|
||||
"shared_link_clipboard_copied_massage": "Copied to clipboard",
|
||||
"shared_link_clipboard_text": "Link: {}\nPassword: {}",
|
||||
"shared_link_create_app_bar_title": "Create link to share",
|
||||
"shared_link_create_error": "Error while creating shared link",
|
||||
"shared_link_create_info": "Let anyone with the link see the selected photo(s)",
|
||||
"shared_link_create_submit_button": "Create link",
|
||||
"shared_link_edit_allow_download": "Allow public user to download",
|
||||
@@ -333,11 +390,32 @@
|
||||
"shared_link_edit_description": "Description",
|
||||
"shared_link_edit_description_hint": "Enter the share description",
|
||||
"shared_link_edit_expire_after": "Expire after",
|
||||
"shared_link_edit_expire_after_option_day": "1 day",
|
||||
"shared_link_edit_expire_after_option_days": "{} days",
|
||||
"shared_link_edit_expire_after_option_hour": "1 hour",
|
||||
"shared_link_edit_expire_after_option_hours": "{} hours",
|
||||
"shared_link_edit_expire_after_option_minute": "1 minute",
|
||||
"shared_link_edit_expire_after_option_minutes": "{} minutes",
|
||||
"shared_link_edit_expire_after_option_never": "Never",
|
||||
"shared_link_edit_password": "Password",
|
||||
"shared_link_edit_password_hint": "Enter the share password",
|
||||
"shared_link_edit_show_meta": "Show metadata",
|
||||
"shared_link_edit_submit_button": "Update link",
|
||||
"shared_link_empty": "You don't have any shared links",
|
||||
"shared_link_error_server_url_fetch": "Cannot fetch the server url",
|
||||
"shared_link_expired": "Expired",
|
||||
"shared_link_expires_day": "Expires in {} day",
|
||||
"shared_link_expires_days": "Expires in {} days",
|
||||
"shared_link_expires_hour": "Expires in {} hour",
|
||||
"shared_link_expires_hours": "Expires in {} hours",
|
||||
"shared_link_expires_minute": "Expires in {} minute",
|
||||
"shared_link_expires_minutes": "Expires in {} minutes",
|
||||
"shared_link_expires_never": "Expires ∞",
|
||||
"shared_link_expires_second": "Expires in {} second",
|
||||
"shared_link_expires_seconds": "Expires in {} seconds",
|
||||
"shared_link_info_chip_download": "Download",
|
||||
"shared_link_info_chip_metadata": "EXIF",
|
||||
"shared_link_info_chip_upload": "Upload",
|
||||
"shared_link_manage_links": "Manage Shared links",
|
||||
"share_done": "Done",
|
||||
"share_invite": "Convida a l'àlbum",
|
||||
|
||||