Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Tran
b49d9660f6 reduce widget rebuild 2023-11-02 21:33:33 -05:00
1422 changed files with 80247 additions and 77542 deletions

View File

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

15
.gitattributes vendored
View File

@@ -5,12 +5,13 @@ mobile/openapi/**/*.dart linguist-generated=true
mobile/openapi/.openapi-generator/FILES -diff -merge
mobile/openapi/.openapi-generator/FILES linguist-generated=true
mobile/lib/**/*.g.dart -diff -merge
mobile/lib/**/*.g.dart linguist-generated=true
open-api/typescript-sdk/client/**/*.md -diff -merge
open-api/typescript-sdk/client/**/*.md linguist-generated=true
open-api/typescript-sdk/client/**/*.ts -diff -merge
open-api/typescript-sdk/client/**/*.ts linguist-generated=true
cli/src/api/open-api/**/*.md -diff -merge
cli/src/api/open-api/**/*.md linguist-generated=true
cli/src/api/open-api/**/*.ts -diff -merge
cli/src/api/open-api/**/*.ts linguist-generated=true
*.sh text eol=lf
web/src/api/open-api/**/*.md -diff -merge
web/src/api/open-api/**/*.md linguist-generated=true
web/src/api/open-api/**/*.ts -diff -merge
web/src/api/open-api/**/*.ts linguist-generated=true

2
.github/FUNDING.yml vendored
View File

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

View File

@@ -1,5 +1,7 @@
name: Report an issue with Immich
description: Report an issue with Immich
labels: ["bug", "need triage"]
title: "[BUG] <title>"
body:
- type: markdown
attributes:

42
.github/release.yml vendored
View File

@@ -1,42 +0,0 @@
changelog:
categories:
- title: ⚠️ Breaking Changes
labels:
- breaking-change
- title: 🗄️ Server
labels:
- 🗄server
- title: 📱 Mobile
labels:
- 📱mobile
- title: 🖥️ Web
labels:
- 🖥web
- title: 🧠 Machine Learning
labels:
- 🧠machine-learning
- title: ⚡ CLI
labels:
- cli
- title: 📓 Documentation
labels:
- documentation
- title: 🔨 Build
labels:
- deployment
- title: 🤖 Dependencies
labels:
- dependencies
- renovate
- title: Other changes
labels:
- "*"

View File

@@ -20,7 +20,7 @@ jobs:
name: Build and sign Android
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' }}
runs-on: macos-13
runs-on: macos-12
steps:
- name: Determine ref
@@ -35,7 +35,7 @@ jobs:
with:
ref: ${{ steps.get-ref.outputs.ref }}
- uses: actions/setup-java@v4
- uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "12.x"
@@ -69,7 +69,7 @@ jobs:
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
- name: Publish Android Artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -60,7 +60,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -73,6 +73,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
name: Docker
name: Build and Push Docker Images
on:
workflow_dispatch:
@@ -18,21 +18,21 @@ permissions:
jobs:
build_and_push:
name: Build and Push
runs-on: ubuntu-latest
strategy:
# Prevent a failure in one image from stopping the other builds
fail-fast: false
matrix:
include:
- context: "web"
image: "immich-web"
platforms: "linux/amd64,linux/arm64"
- context: "machine-learning"
file: "machine-learning/Dockerfile"
image: "immich-machine-learning"
platforms: "linux/amd64,linux/arm64"
- context: "."
file: "server/Dockerfile"
image: "immich-server"
platforms: "linux/arm64,linux/amd64"
- context: "nginx"
image: "immich-proxy"
platforms: "linux/amd64,linux/arm64"
steps:
- name: Checkout
@@ -97,10 +97,93 @@ jobs:
fi
- name: Build and push image
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v5.0.0
with:
context: ${{ matrix.context }}
platforms: ${{ matrix.platforms }}
# Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
build_and_push_server_arm_64:
runs-on: self-hosted
strategy:
# Prevent a failure in one image from stopping the other builds
fail-fast: false
matrix:
include:
- context: "server"
image: "immich-server"
platforms: "linux/arm64,linux/amd64"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
with:
driver-opts: |
image=moby/buildkit:v0.10.6
- name: Login to Docker Hub
# Only push to Docker Hub when making a release
if: ${{ github.event_name == 'release' }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v5
with:
flavor: |
# Disable latest tag
latest=false
images: |
name=ghcr.io/${{ github.repository_owner }}/${{matrix.image}}
name=altran1502/${{matrix.image}},enable=${{ github.event_name == 'release' }}
tags: |
# Tag with branch name
type=ref,event=branch
# Tag with pr-number
type=ref,event=pr
# Tag with git tag on release
type=ref,event=tag
type=raw,value=release,enable=${{ github.event_name == 'release' }}
- name: Determine build cache output
id: cache-target
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Essentially just ignore the cache output (PR can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else
echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT
fi
- name: Build and push image
uses: docker/build-push-action@v5.0.0
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}
platforms: ${{ matrix.platforms }}
# Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }}

View File

@@ -1,13 +0,0 @@
name: Enforce PR labels
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
jobs:
enforce-label:
name: Enforce label
runs-on: ubuntu-latest
steps:
- if: toJson(github.event.pull_request.labels) == '[]'
run: exit 1

View File

@@ -69,7 +69,7 @@ jobs:
token: ${{ secrets.ORG_RELEASE_TOKEN }}
- name: Download APK
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: release-apk-signed

View File

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

View File

@@ -10,26 +10,9 @@ concurrency:
cancel-in-progress: true
jobs:
server-e2e-api:
name: Server (e2e-api)
runs-on: mich
defaults:
run:
working-directory: ./server
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
- name: Run e2e tests
run: npm run e2e:api
server-e2e-jobs:
name: Server (e2e-jobs)
runs-on: mich
e2e-tests:
name: Run end-to-end test suites
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -38,10 +21,10 @@ jobs:
submodules: "recursive"
- name: Run e2e tests
run: make server-e2e-jobs
run: docker compose -f ./docker/docker-compose.test.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
doc-tests:
name: Docs
name: Run documentation checks
runs-on: ubuntu-latest
defaults:
run:
@@ -58,12 +41,12 @@ jobs:
run: npm run format
if: ${{ !cancelled() }}
- name: Run build
run: npm run build
- name: Run tsc
run: npm run check
if: ${{ !cancelled() }}
server-unit-tests:
name: Server
name: Run server unit test suites and checks
runs-on: ubuntu-latest
defaults:
run:
@@ -93,7 +76,7 @@ jobs:
if: ${{ !cancelled() }}
cli-unit-tests:
name: CLI
name: Run cli test suites
runs-on: ubuntu-latest
defaults:
run:
@@ -103,17 +86,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run npm install (cli)
- name: Run npm install
run: npm ci
- name: Run npm install (server)
run: npm ci
working-directory: ./server
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
@@ -122,43 +97,12 @@ jobs:
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
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 setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run npm install (cli)
run: npm ci
- name: Run npm install (server)
run: npm ci
working-directory: ./server
- name: Run e2e tests
run: npm run test:e2e
web-unit-tests:
name: Web
name: Run web unit test suites and checks
runs-on: ubuntu-latest
defaults:
run:
@@ -168,10 +112,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: npm ci
@@ -191,12 +131,12 @@ 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
name: Run mobile unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -210,7 +150,7 @@ jobs:
run: flutter test -j 1
ml-unit-tests:
name: Machine Learning
name: Run ML unit tests and checks
runs-on: ubuntu-latest
defaults:
run:
@@ -219,7 +159,7 @@ jobs:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v5
- uses: actions/setup-python@v4
with:
python-version: 3.11
cache: "poetry"
@@ -228,31 +168,31 @@ jobs:
poetry install --with dev
- name: Lint with ruff
run: |
poetry run ruff check --output-format=github app export
poetry run ruff check --format=github app
- name: Check black formatting
run: |
poetry run black --check app export
poetry run black --check app
- name: Run mypy type checking
run: |
poetry run mypy --install-types --non-interactive --strict app/
poetry run mypy --install-types --non-interactive app/
- name: Run tests and coverage
run: |
poetry run pytest --cov app
generated-api-up-to-date:
name: OpenAPI Clients
name: Check generated files are up-to-date
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run API generation
run: make open-api
run: npm --prefix server run api:generate
- name: Find file changes
uses: tj-actions/verify-changed-files@v13.1
id: verify-changed-files
with:
files: |
mobile/openapi
open-api/typescript-sdk
web/src/api/open-api
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |
@@ -261,11 +201,11 @@ jobs:
exit 1
generated-typeorm-migrations-up-to-date:
name: TypeORM Checks
name: Check generated TypeORM migrations are up-to-date
runs-on: ubuntu-latest
services:
postgres:
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
image: postgres
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
@@ -288,15 +228,12 @@ jobs:
- name: Install server dependencies
run: npm ci
- name: Build the app
- name: Build the
run: npm run build
- 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
@@ -307,32 +244,13 @@ jobs:
with:
files: |
server/src/infra/migrations/
- name: Verify migration files have not changed
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |
echo "ERROR: Generated migration files not up to date!"
echo "ERROR: Generated files not up to date!"
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
exit 1
- name: Run SQL generation
run: npm run sql:generate
env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@v13.1
id: verify-changed-sql-files
with:
files: |
server/src/infra/sql
- name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
run: |
echo "ERROR: Generated SQL files not up to date!"
echo "Changed files: ${{ steps.verify-changed-sql-files.outputs.changed_files }}"
exit 1
# mobile-integration-tests:
# name: Run mobile end-to-end integration tests
# runs-on: macos-latest

7
.gitignore vendored
View File

@@ -1,5 +1,3 @@
**/node_modules/**
.DS_Store
.vscode/*
!.vscode/launch.json
@@ -13,8 +11,3 @@ coverage
mobile/gradle.properties
mobile/openapi/pubspec.lock
mobile/*.jks
mobile/libisar.dylib
open-api/typescript-sdk/build
mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml

View File

@@ -1,42 +1,38 @@
dev:
docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
dev-new:
docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans || make dev-down
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-update:
dev-new-update:
docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-update:
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
docker-compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
stage:
docker compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
docker-compose -f ./docker/docker-compose.staging.yml up --build -V --remove-orphans
pull-stage:
docker compose -f ./docker/docker-compose.staging.yml pull
docker-compose -f ./docker/docker-compose.staging.yml pull
server-e2e-jobs:
docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
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
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-scale:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api
open-api:
cd ./open-api && bash ./bin/generate-open-api.sh
open-api-dart:
cd ./open-api && bash ./bin/generate-open-api.sh dart
open-api-typescript:
cd ./open-api && bash ./bin/generate-open-api.sh typescript
sql:
npm --prefix server run sql:generate
api:
cd ./server && npm run api:generate
attach-server:
docker exec -it docker_immich-server_1 sh

View File

@@ -2,7 +2,7 @@
<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"/>
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
</a>
<br/>
<br/>
@@ -18,17 +18,14 @@
</a>
<br/>
<p align="center">
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_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>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Disclaimer
@@ -88,7 +85,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Virtual scroll | Yes | Yes |
| OAuth support | Yes | Yes |
| API Keys | N/A | Yes |
| LivePhoto/MotionPhoto backup and playback | Yes | Yes |
| LivePhoto backup and playback | iOS | Yes |
| User-defined storage structure | Yes | Yes |
| Public Sharing | No | Yes |
| Archive and Favorites | Yes | Yes |
@@ -98,7 +95,6 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Memories (x years ago) | Yes | Yes |
| Offline support | Yes | No |
| Read-only gallery | Yes | Yes |
| Stacked Photos | Yes | Yes |
## Support the project
@@ -112,10 +108,9 @@ 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
- [Liberapay](https://liberapay.com/alex.tran1502/)
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
## Contributors
<a href="https://github.com/alextran1502/immich/graphs/contributors">

View File

@@ -19,15 +19,13 @@
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">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_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Avís legal
@@ -111,4 +109,4 @@ Si creieu que aquesta és una causa justa i l'aplicació és alguna cosa que us
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View File

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

View File

@@ -19,15 +19,12 @@
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_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_it_IT.md">Italiano</a>
</p>
## Descargo de responsabilidad
@@ -112,4 +109,3 @@ Si consideras que esta es una causa justa y la aplicación es algo que te gustar
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View File

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

View File

@@ -19,20 +19,18 @@
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_ja_JP.md">日本語</a>
</p>
## Declino di responsabilità
- ⚠️ Il progetto è in una fase **molto intensa** di sviluppo.
- ⚠️ Il progetto è in fase di sviluppo **molto avanzato**.
- ⚠️ Possibilità di bug e cambiamenti rilevanti.
- ⚠️ **Non utilizzare l'app come unico salvataggio delle tue foto e dei tuoi video.**
- ⚠️ Utilizza sempre una tecnica [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) di backup per le foto e i video a cui tieni!
@@ -73,8 +71,8 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Funzionalità | Mobile | Web |
| ---------------------------------------------- | ------ | --- |
| Caricamento e visualizzazione di foto e video | Sì | Sì |
| Backup automatico quando l'app è in esecuzione | Sì | N/D |
| Selezione degli album per backup | Sì | N/D |
| Backup automatico quando l'app è in esecuzione | Sì | N/A |
| Selezione degli album per backup | Sì | N/A |
| Download foto e video sul dispositivo | Sì | Sì |
| Supporto multi utente | Sì | Sì |
| Album e album condivisi | Sì | Sì |
@@ -83,10 +81,10 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Visualizzazione metadata (EXIF, map) | Sì | Sì |
| Ricerca per metadata, oggetti, volti e CLIP | Sì | Sì |
| Funzioni di amministrazione degli utenti | No | Sì |
| Backup in background | Sì | N/D |
| Backup in background | Sì | N/A |
| Scroll virtuale | Sì | Sì |
| Supporto OAuth | Sì | Sì |
| API Keys | N/D | Sì |
| API Keys | N/A | Sì |
| Backup e riproduzione di LivePhoto | iOS | Sì |
| Archiviazione impostata dall'utente | Sì | Sì |
| Condivisione pubblica | No | Sì |
@@ -97,7 +95,6 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Ricordi (x anni fa) | Sì | Sì |
| Supporto offline | Sì | No |
| Galleria sola lettura | Sì | Sì |
| Foto raggruppate | Sì | Sì |
# Supporta il progetto
@@ -114,4 +111,3 @@ Se pensi che Immich sia una buona causa e che l'app sia qualcosa che useresti ne
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View File

@@ -18,16 +18,13 @@
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_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_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_it_IT.md">Italiano</a>
</p>
## 免責事項
@@ -102,7 +99,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
私はこのプロジェクトにコミットしてきました。ドキュメントを更新し、新しい機能を追加し、バグを修正し続けるつもりですが、私ひとりではできません。だから、続けるためのモチベーションをさらに高めてくれる皆さんの助けが必要なのです。
[selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
[selfhosted.show - In the episode 'The-organization-must-いいえt-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
もし、あなたがこのプロジェクトに賛同し、このアプリを長く使い続けたいと思われるのであれば、以下のオプションから支援をご検討ください。

View File

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

View File

@@ -18,16 +18,14 @@
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_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_nl_NL.md">Nederlands</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_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## Disclaimer
@@ -102,7 +100,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 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 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 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.
@@ -113,4 +111,3 @@ Als je denkt dat dit het juiste doel is en de app iets is dat je jezelf al heel
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz

View File

@@ -1,124 +0,0 @@
<p align="center">
<br/>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
<a href="https://discord.gg/D8JsnBEuKb">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - Высокопроизводительное решение для автономоного создания фото и видео архивов</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README_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>

View File

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

View File

@@ -17,30 +17,28 @@
</p>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="界面截图">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="README.md">English</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_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_ru_RU.md">Русский</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_it_IT.md">Italiano</a>
</p>
## 免责声明
- ⚠️ 本项目正在 **非常活跃** 地开发中。
- ⚠️ 可能存在 bug 或者随时有重大变更。
- ⚠️ **不要把本软件作为您存储照片或视频的唯一方式。**
- ⚠️ 为了您宝贵的照片与视频,始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
- ⚠️ 为了您宝贵的照片与视频,始终遵守 [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) 备份方案!
## 目录
@@ -75,41 +73,40 @@
# 功能特性
| 功能特性 | 移动端 | 网页端 |
|---------------------------------------------|--------|--------|
| 上传并查看照片和视频 | 是 | 是 |
| 软件运行时自动备份 | 是 | N/A |
| 选择需要备份的相册 | 是 | N/A |
| 下载照片和视频到本地 | 是 | 是 |
| 多用户支持 | 是 | 是 |
| 相册与共享相册 | 是 | 是 |
| 可拖动的快速导航栏 | 是 | 是 |
| 支持RAW格式 | 是 | 是 |
| 元数据视图EXIF、地图 | 是 | 是 |
| 通过元数据、对象、人脸和标签进行搜索 | 是 | 是 |
| 管理功能(用户管理) | 否 | 是 |
| 后台备份 | 是 | N/A |
| 虚拟滚动 | 是 | 是 |
| OAuth 支持 | 是 | 是 |
| API Keys | N/A | 是 |
| 实况照片备份和查看 | 是 | 是 |
| 用户自定义存储结构 | 是 | 是 |
| 公共分享 | 否 | 是 |
| 归档与收藏功能 | 是 | 是 |
| 足迹地图 | 是 | 是 |
| 好友分享 | 是 | 是 |
| 人脸识别与分组 | 是 | 是 |
| 回忆(那年今日) | 是 | 是 |
| 离线支持 | 是 | 否 |
| 只读相册 | 是 | 是 |
| 照片堆叠 | 是 | 是 |
| 功能特性 | 移动端 | 网页端 |
| ------------------------------------------- | ------- | --- |
| 上传并查看照片和视频 | 是 | 是 |
| 软件运行时自动备份 | 是 | N/A |
| 选择需要备份的相册 | 是 | N/A |
| 下载照片和视频到本地 | 是 | 是 |
| 多用户支持 | 是 | 是 |
| 相册 | 是 | 是 |
| 共享相册 | 是 | 是 |
| 可拖动的快速导航栏 | 是 | 是 |
| 支持RAW格式 (HEIC, HEIF, DNG, Apple ProRaw) | 是 | 是 |
| 元数据视图EXIF, 地图) | 是 | 是 |
| 通过元数据、对象和标签进行搜索 | 是 | 是 |
| 管理功能(用户管理) | 否 | 是 |
| 后台备份 | 是 | N/A |
| 虚拟滚动 | 是 | 是 |
| OAuth 支持 | | 是 |
| API Keys|N/A|是|
| 实况照片备份和查看 | 仅 iOS | 是 |
|用户自定义存储结构|是|是|
|公共分享|否|是|
|归档与收藏功能|是|是|
|全局地图|否|是|
|好友分享|是|是|
|人像识别与分组|是|是|
|回忆(那年今日)|是|是|
|离线支持|是|否|
|只读相册|是|是|
# 支持本项目
我已经致力于本项目并且将会持续更新文档、新增功能和修复问题。但是独木不成林,我需要您给予我坚持下去的动力。
我已经致力于本项目并且将会持续更新文档、新增功能和修复问题。但是独木不成林,我需要您给予我坚持下去的动力。
就像我在 [selfhosted.show - In the episode 'The-organization-must-not-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) 节目里说的一样,这是我和团队的一项艰巨任务。并且我希望某一天我能够全职开发本项目,在此我请求您能够助我梦想成真。
如果您使用了本项目一段时间,并且觉得上面的话有道理,那么请您考虑通过下列任一方式支持我吧。
@@ -120,9 +117,3 @@
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- 比特币: 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
View File

@@ -10,6 +10,4 @@ oclif.manifest.json
.vscode
.idea
/coverage/
.reverse-geocoding-dump/
upload/
/coverage/

View File

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

View File

@@ -5,6 +5,7 @@ node_modules
.env
.env.*
!.env.example
src/api/open-api
*.md
*.json
coverage

View File

@@ -1,19 +0,0 @@
FROM ghcr.io/immich-app/base-server-dev:20240111@sha256:5acf773796f93c7a3216ffdbdb3604dc812f2b2317b84a1b57b65674826b746a 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:20240111@sha256:e917605008977f68dc3b6f7879c264cae4bff6c4186b119a6e114a60f8f5a354
VOLUME /usr/src/app/upload
EXPOSE 3001
ENTRYPOINT ["tini", "--", "/bin/sh"]

View File

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

3492
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,33 @@
{
"name": "@immich/cli",
"version": "2.0.6",
"description": "Command Line Interface (CLI) for Immich",
"main": "dist/index.js",
"bin": {
"immich": "./dist/src/index.js"
},
"license": "MIT",
"keywords": [
"immich",
"cli"
],
"name": "immich-cli",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
"axios": "^1.6.2",
"axios": "^1.4.0",
"byte-size": "^8.1.1",
"cli-progress": "^3.12.0",
"commander": "^11.0.0",
"form-data": "^4.0.0",
"glob": "^10.3.1",
"graceful-fs": "^4.2.11",
"picomatch": "^2.3.1",
"systeminformation": "^5.18.4",
"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",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.48.1",
"chai": "^4.3.7",
"eslint": "^8.43.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-unicorn": "^50.0.0",
"immich": "file:../server",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unicorn": "^47.0.0",
"jest": "^29.5.0",
"jest-extended": "^4.0.0",
"jest-message-util": "^29.5.0",
@@ -46,19 +37,15 @@
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"tslib": "^2.5.3",
"typescript": "^5.0.0"
"typescript": "^4.9.4"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"prepack": "npm run build",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"prepack": "yarn build ",
"test": "jest",
"test:cov": "jest --coverage",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"check": "tsc --noEmit",
"test:e2e": "jest --config test/e2e/jest-e2e.json --runInBand"
"format": "prettier --check ."
},
"jest": {
"clearMocks": true,
@@ -73,22 +60,9 @@
"^.+\\.ts$": "ts-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s",
"!**/open-api/**"
"<rootDir>/src/**/*.(t|j)s"
],
"moduleNameMapper": {
"^@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"
},
"repository": {
"type": "git",
"url": "github:immich-app/immich",
"directory": "cli"
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,26 @@
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 { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
import { BaseOptionsDto } from 'src/cores/dto/base-options-dto';
import os from 'os';
import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
export abstract class BaseCommand {
protected sessionService!: SessionService;
protected immichApi!: ImmichApi;
protected deviceId!: string;
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;
constructor(options: BaseOptionsDto) {
if (!options.config) {
throw new Error('Config directory is required');
}
this.sessionService = new SessionService(options.config);
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');
}
public async connect(): Promise<void> {

View File

@@ -1,6 +1,6 @@
import { BaseCommand } from '../../cli/base-command';
export class LoginKey extends BaseCommand {
export default class LoginKey extends BaseCommand {
public async run(instanceUrl: string, apiKey: string): Promise<void> {
console.log('Executing API key auth flow...');

View File

@@ -1,6 +1,6 @@
import { BaseCommand } from '../cli/base-command';
export class Logout extends BaseCommand {
export default class Logout extends BaseCommand {
public static readonly description = 'Logout and remove persisted credentials';
public async run(): Promise<void> {

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
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);

View File

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

View File

@@ -1,3 +0,0 @@
export class BaseOptionsDto {
config?: string;
}

View File

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

View File

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

View File

@@ -2,8 +2,10 @@ 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);
}
}

View File

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

View File

@@ -1,91 +0,0 @@
import crypto from 'crypto';
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;
deviceAssetId?: string;
fileCreatedAt?: string;
fileModifiedAt?: string;
sidecarPath?: string;
fileSize!: number;
albumName?: string;
constructor(path: string) {
this.path = path;
}
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();
}
getUploadFormData(): FormData {
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');
// 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: createReadStream(this.path),
deviceAssetId: this.deviceAssetId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: String(false),
};
const formData = new FormData();
for (const prop in data) {
formData.append(prop, data[prop]);
}
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
return formData;
}
async delete(): Promise<void> {
return fs.promises.unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = crypto.createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = fs.createReadStream(filePath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};
return await sha1(this.path);
}
private extractAlbumName(): string {
if (Os.platform() === 'win32') {
return this.path.split('\\').slice(-2)[0];
} else {
return this.path.split('/').slice(-2)[0];
}
}
}

View File

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

View File

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

View File

@@ -1,23 +1,9 @@
#! /usr/bin/env node
import { program, Option } from 'commander';
import Upload from './commands/upload';
import ServerInfo from './commands/server-info';
import LoginKey from './commands/login/key';
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';
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.name('immich').description('Immich command line interface');
program
.command('upload')
@@ -26,17 +12,6 @@ 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')
@@ -45,15 +20,35 @@ program
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => {
options.exclusionPatterns = options.ignore;
await new Upload(program.opts()).run(paths, options);
options.excludePatterns = options.ignore;
await new Upload().run(paths, options);
});
program
.command('import')
.description('Import existing assets')
.usage('[options] [paths...]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN')
.default(false),
)
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
.argument('[paths...]', 'One or more paths to assets to be imported')
.action(async (paths, options) => {
options.import = true;
options.excludePatterns = options.ignore;
await new Upload().run(paths, options);
});
program
.command('server-info')
.description('Display server information')
.action(async () => {
await new ServerInfo(program.opts()).run();
await new ServerInfo().run();
});
program
@@ -62,14 +57,7 @@ program
.argument('[instanceUrl]')
.argument('[apiKey]')
.action(async (paths, options) => {
await new LoginKey(program.opts()).run(paths, options);
});
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new Logout(program.opts()).run();
await new LoginKey().run(paths, options);
});
program.parse(process.argv);

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,16 @@
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' } }));
jest.mock('@immich/sdk', () => {
jest.mock('../api/open-api', () => {
return {
...jest.requireActual('@immich/sdk'),
__esModule: true,
...jest.requireActual('../api/open-api'),
UserApi: jest.fn().mockImplementation(() => {
return { getMyUserInfo: mockUserInfo };
}),
@@ -30,85 +22,74 @@ jest.mock('@immich/sdk', () => {
describe('SessionService', () => {
let sessionService: SessionService;
let consoleSpy: jest.SpyInstance;
beforeAll(() => {
consoleSpy = spyOnConsole();
// Write a dummy output before mock-fs to prevent some annoying errors
console.log();
});
beforeEach(() => {
deleteAuthFile();
sessionService = new SessionService(TEST_CONFIG_DIR);
});
afterEach(() => {
deleteAuthFile();
const configDir = '/config';
sessionService = new SessionService(configDir);
});
it('should connect to immich', async () => {
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
mockfs({
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
});
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 () => {
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
}),
);
mockfs({
'/config/auth.yml': 'foo: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\napiKey: https://test/api',
});
await sessionService.connect().catch((error) => {
expect(error).toBeInstanceOf(LoginError);
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
expect(error.message).toEqual('Instance URL missing in auth config file /config/auth.yml');
});
});
it('should error if auth file is missing api key', async () => {
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}`),
);
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');
});
});
it('should create auth file when logged in', async () => {
await sessionService.keyLogin(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
it.skip('should create auth file when logged in', async () => {
mockfs();
const data: string = await readTestAuthFile();
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
const data: string = await fs.promises.readFile('/config/auth.yml', 'utf8');
const authConfig = yaml.parse(data);
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
expect(authConfig.instanceUrl).toBe('https://test/api');
expect(authConfig.apiKey).toBe('pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
});
it('should delete auth file when logging out', async () => {
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
mockfs({
'/config/auth.yml': 'apiKey: pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg\ninstanceUrl: https://test/api',
});
await sessionService.logout();
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
await fs.promises.access('/auth.yml', fs.constants.F_OK).catch((error) => {
expect(error.message).toContain('ENOENT');
});
});
expect(consoleSpy.mock.calls).toEqual([[`Removed auth file ${TEST_AUTH_FILE}`]]);
afterEach(() => {
mockfs.restore();
});
});

View File

@@ -5,39 +5,33 @@ 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(configDir, '/auth.yml');
this.authPath = path.join(this.configDir, 'auth.yml');
}
public async connect(): Promise<ImmichApi> {
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}`);
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');
}
});
if (!apiKey) {
throw new LoginError(`API key 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);
}
this.api = new ImmichApi(instanceUrl, apiKey);
@@ -52,7 +46,7 @@ export class SessionService {
// Check if server and api key are valid
const { data: userInfo } = await this.api.userApi.getMyUserInfo().catch((error) => {
throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
throw new LoginError(`Failed to connect to the server: ${error.message}`);
});
console.log(`Logged in as ${userInfo.email}`);
@@ -65,6 +59,10 @@ 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);
@@ -80,11 +78,11 @@ export class SessionService {
private async ping(): Promise<void> {
const { data: pingResponse } = await this.api.serverInfoApi.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${this.api.apiConfiguration.instanceUrl}: ${error.message}`);
throw new Error(`Failed to connect to the server: ${error.message}`);
});
if (pingResponse.res !== 'pong') {
throw new Error(`Could not parse response. Is Immich listening on ${this.api.apiConfiguration.instanceUrl}?`);
throw new Error('Unexpected ping reply');
}
}
}

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
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;
}
}
};

View File

@@ -1,24 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"modulePaths": ["<rootDir>"],
"rootDir": "../..",
"globalSetup": "<rootDir>/test/e2e/setup.ts",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"testTimeout": 6000000,
"transform": {
"^.+\\.ts$": "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"
}
}

View File

@@ -1,48 +0,0 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@test/../e2e/api/client';
import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
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);
});
});

View File

@@ -1,42 +0,0 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@test/../e2e/api/client';
import { restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
import { ServerInfo } from 'src/commands/server-info';
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'],
]);
});
});

View File

@@ -1,42 +0,0 @@
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_CONFIG_FILE = path.normalize(`${__dirname}/../../../server/e2e/jobs/immich-e2e-config.json`);
process.env.TZ = 'Z';
};

View File

@@ -1,84 +0,0 @@
import { APIKeyCreateResponseDto } from '@app/domain';
import { api } from '@test/../e2e/api/client';
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test/../e2e/jobs/utils';
import { LoginResponseDto } from '@immich/sdk';
import { Upload } from 'src/commands/upload';
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');
});
});

View File

@@ -1,3 +0,0 @@
module.exports = async () => {
process.env.TZ = 'UTC';
};

View File

@@ -8,23 +8,17 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2021",
"target": "es2017",
"moduleResolution": "node16",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"rootDirs": ["src", "../server/src"],
"baseUrl": "./",
"paths": {
"@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/*"]
"@test": ["test"],
"@test/*": ["test/*"]
}
},
"exclude": ["dist", "node_modules", "upload"]

View File

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

View File

@@ -1,56 +1,65 @@
# See:
# - https://immich.app/docs/developer/setup
# - https://immich.app/docs/developer/troubleshooting
version: "3.8"
name: immich-dev
x-server-build: &server-common
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile
target: dev
restart: always
volumes:
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api
- ${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
ulimits:
nofile:
soft: 1048576
hard: 1048576
services:
immich-server:
container_name: immich_server
command: [ "/usr/src/app/bin/immich-dev", "immich" ]
<<: *server-common
image: immich-server-dev:latest
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:debug immich
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
ports:
- 3001:3001
- 9230:9230
env_file:
- .env
environment:
- NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on:
- redis
- database
- typesense
immich-microservices:
container_name: immich_microservices
command: [ "/usr/src/app/bin/immich-dev", "microservices" ]
<<: *server-common
image: immich-microservices:latest
# extends:
# file: hwaccel.yml
# service: hwaccel
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:debug microservices
volumes:
- ../server:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /usr/src/app/node_modules
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
- 9231:9230
environment:
- NODE_ENV=development
ulimits:
nofile:
soft: 1048576
hard: 1048576
depends_on:
- database
- immich-server
- typesense
immich-web:
container_name: immich_web
@@ -58,15 +67,19 @@ services:
build:
context: ../web
dockerfile: Dockerfile
command: "node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000"
target: dev
command: npm run dev --host
env_file:
- .env
environment:
# Rename these values for svelte public interface
- PUBLIC_IMMICH_SERVER_URL=${IMMICH_SERVER_URL}
- PUBLIC_IMMICH_API_URL_EXTERNAL=${IMMICH_API_URL_EXTERNAL}
ports:
- 2283:3000
- 3000:3000
- 24678:24678
volumes:
- ../web:/usr/src/app
- ../open-api/:/usr/src/open-api/
- /usr/src/app/node_modules
ulimits:
nofile:
@@ -89,17 +102,30 @@ services:
- model-cache:/cache
env_file:
- .env
environment:
- NODE_ENV=development
depends_on:
- database
restart: unless-stopped
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- ${UPLOAD_LOCATION}/typesense:/data
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
database:
container_name: immich_postgres
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env
environment:
@@ -111,5 +137,22 @@ services:
ports:
- 5432:5432
immich-proxy:
container_name: immich_proxy
image: immich-proxy-dev:latest
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
build:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:8080
depends_on:
- immich-server
- immich-web
restart: unless-stopped
volumes:
model-cache:

View File

@@ -1,41 +1,22 @@
version: "3.8"
name: immich-prod
x-server-build: &server-common
image: immich-server:latest
build:
context: ../
dockerfile: server/Dockerfile
volumes:
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
restart: always
services:
immich-server:
container_name: immich_server
command: [ "./start-server.sh" ]
<<: *server-common
ports:
- 2283:3001
image: immich-server:latest
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:
- redis
- database
immich-microservices:
container_name: immich_microservices
command: [ "./start-microservices.sh" ]
<<: *server-common
# extends:
# file: hwaccel.yml
# service: hwaccel
depends_on:
- redis
- database
- immich-server
- typesense
immich-machine-learning:
container_name: immich_machine_learning
@@ -44,19 +25,65 @@ services:
context: ../machine-learning
dockerfile: Dockerfile
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- model-cache:/cache
env_file:
- .env
restart: always
immich-microservices:
container_name: immich_microservices
image: immich-microservices:latest
# extends:
# file: hwaccel.yml
# service: hwaccel
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
depends_on:
- database
- immich-server
- typesense
restart: always
immich-web:
container_name: immich_web
image: immich-web:latest
build:
context: ../web
dockerfile: Dockerfile
env_file:
- .env
restart: always
depends_on:
- immich-server
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
restart: always
database:
container_name: immich_postgres
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env
environment:
@@ -64,8 +91,28 @@ services:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
volumes:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql/data
restart: always
immich-proxy:
container_name: immich_proxy
image: immich-proxy:latest
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
build:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:8080
logging:
driver: none
depends_on:
- immich-server
restart: always
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -1,17 +1,17 @@
version: '3.8'
version: "3.8"
name: 'immich-test-e2e'
name: "immich-test-e2e"
services:
immich-server:
image: immich-server-dev:latest
build:
context: ../../
dockerfile: server/Dockerfile
target: dev
entrypoint: ['/usr/local/bin/npm', 'run']
command: e2e:jobs
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run test:e2e
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
environment:
- DB_HOSTNAME=database
@@ -23,8 +23,8 @@ services:
- database
database:
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
command: -c fsync=off -c shared_preload_libraries=vectors.so
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
command: -c fsync=off
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres

View File

@@ -1,30 +1,19 @@
version: "3.8"
#
# WARNING: Make sure to use the docker-compose.yml of the current release:
#
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
#
# The compose file on main may not be compatible with the latest release.
#
name: immich
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "immich" ]
command: ["start.sh", "immich"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
- 2283:3001
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
@@ -33,7 +22,7 @@ services:
# extends:
# file: hwaccel.yml
# service: hwaccel
command: [ "start.sh", "microservices" ]
command: ["start.sh", "microservices"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
@@ -42,6 +31,7 @@ services:
depends_on:
- redis
- database
- typesense
restart: always
immich-machine-learning:
@@ -53,14 +43,33 @@ services:
- .env
restart: always
immich-web:
container_name: immich_web
image: ghcr.io/immich-app/immich-web:${IMMICH_VERSION:-release}
env_file:
- .env
restart: always
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
# remove this to get debug messages
- GLOG_minloglevel=1
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
restart: always
database:
container_name: immich_postgres
image: tensorchord/pgvecto-rs:pg14-v0.1.11@sha256:0335a1a22f8c5dd1b697f14f079934f5152eaaa216c09b61e293be285491f8ee
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env
environment:
@@ -71,6 +80,17 @@ services:
- pgdata:/var/lib/postgresql/data
restart: always
immich-proxy:
container_name: immich_proxy
image: ghcr.io/immich-app/immich-proxy:${IMMICH_VERSION:-release}
ports:
- 2283:8080
depends_on:
- immich-server
- immich-web
restart: always
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -6,7 +6,8 @@ UPLOAD_LOCATION=./library
# The Immich version to use. You can pin this to a specific version like "v1.71.0"
IMMICH_VERSION=release
# Connection secret for postgres. You should change it to a random password
# Connection secrets for postgres and typesense. You should change these to random passwords
TYPESENSE_API_KEY=some-random-text
DB_PASSWORD=postgres
# The values below this line do not need to be changed

View File

@@ -11,6 +11,7 @@ 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

View File

@@ -1,11 +0,0 @@
version: "3.8"
# ML acceleration on supported Mali ARM GPUs using ARM-NN
services:
mlaccel:
devices:
- /dev/mali0:/dev/mali0
volumes:
- /lib/firmware/mali_csffw.bin:/lib/firmware/mali_csffw.bin:ro # Mali firmware for your chipset (not always required depending on the driver)
- /usr/lib/libmali.so:/usr/lib/libmali.so:ro # Mali driver for your chipset (always required)

View File

@@ -1,70 +0,0 @@
---
title: Immich Recap 2023
authors: [alextran]
tags: [update, recap-2023]
---
Hi everyone,
Alex from Immich here.
We are entering the last few weeks of 2023, and it has been quite a year for Immich. The project has grown so much in terms of users, developers, features, maturity, and the community around it. When I started working on Immich, it was simply a challenge for myself and an opportunity to learn new technologies, crafting something fun and useful for my wife during my free time to satisfy my urge to build and create things. I never thought it would become so popular and help so many people. At the end of the day, all we have is memory. I am proud that the team and I have created something to make storing and viewing those precious memories easier without restrictions and without sacrificing our privacy. As the year closes, heres a recap of everything the project accomplished in 2023.
# Milestones
- Public shared links
- Favorites page
- Immich turned 1
- Material Design 3 on the mobile app
- Auto-link LivePhotos server-side
- iOS background backup
- Explore page
- CLIP search
- Search by metadata
- Responsive web app
- Archive page
- Asset descriptions
- 10,000 stars on GitHub
- Manage auth devices
- Map view
- Facial recognition, clustering, searching, renaming, and person management
- Partner sharing and unifying timeline between partners' users
- Custom storage label
- XMP sidecar reading
- RAW file formats
- Justified layout on the web
- Memories
- Multi-select via SHIFT
- Android Motion Photos
- 360° Photos
- Album description
- Album performance improvements (time buckets)
- Video hardware transcoding
- Slideshow mode on the web
- Configuration file
- External libraries
- Trash page
- Custom theme
- Asset Stacking
- 20,000 stars on GitHub
- Shared album activity and comments
- CLI v2
- Down to 5 containers (from 8)
# Fun Statistics
- We have gone from the release version `1.41.0` to `1.90.0` at the time of writing. On average, we see a release every 7 days.
- According to GitHub's metrics, the `immich-server` container image has been pulled almost _4 million_ times.
- According to mobile app store metrics, we have 22,000 installations on Android and 6700 installation units on iOS (opt-in only).
- Immich is making around $1200/month on average from donations. (Thank you all so much!)
- We were guests on two podcasts:
- [Self-hosted](https://selfhosted.show/110)
- [The Vergecast](https://www.theverge.com/23938533/self-hosting-local-first-software-vergecast)
- There are over 4,500 members on the Discord server.
- We have over 22,000 stars on the main GitHub repository, gaining 15,000 stars since January 2023.
Diving into the next year, the team will continue to build on the foundation we have laid out over the past year, implementing more advanced features for searching, organizing, and sharing between users. Bugs will continue to be squashed and conquered. “Shit Alex wrote'' code will continue to be replaced by beautiful, clean code from Jason, Zack, Boet, Daniel, Osorin, Mert, Fynn, Marty, Martin, and Jonathan. The team has my eternal gratitude for creating a welcoming environment for new contributors, helping, teaching, and learning from each other. Ive realized that hardly a day has gone by where the team hasnt been in communication about Immich related topics over the past year.
My long-term goal is to help hone Immich into a diamond in the FOSS space, where the UI, UX, development experiences, documentation, and quality are at a high standard while remaining free for everybody to use.
I hope you enjoy Immich and have a happy and peaceful holiday.

131
docs/docs/FAQ.md Normal file
View File

@@ -0,0 +1,131 @@
---
sidebar_position: 7
---
# FAQ
### What is the difference between the cloud icon on the mobile app?
| Icon | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| ![cloud](/img/cloud.svg) | Asset is only available in the cloud and was uploaded from some other device (like the web client) or was deleted from this device after upload |
| ![cloud-cross](/img/cloud-off.svg) | Asset is only available locally and has not yet been backed up |
| ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up in the cloud/server and still available in original on the device |
### How can I sync an existing directory with Immich's server?
Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich.
### Why are only photos and not videos being uploaded to Immich?
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes. Also check the disk space of your reverse proxy, in some cases proxies caches requests to disk before passing them on, and if disk space runs out the request fails.
### Why is Immich slow on low-memory systems like the Raspberry Pi?
Immich optionally uses machine learning for several features. However, it can be too heavy to run on a Raspberry Pi. You can [mitigate](/docs/FAQ#how-can-i-lower-immichs-cpu-usage) this or [disable](/docs/FAQ.md#how-can-i-disable-machine-learning) machine learning entirely.
### 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:
- 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.
- Set the `TYPESENSE_THREAD_POOL_SIZE` environmental variable and restart the Typesense container. For instance, `TYPESENSE_THREAD_POOL_SIZE=8` will limit it to 8 threads.
- Under Settings > Machine Learning Settings > Facial Recognition > Model Name, you can change the facial recognition model to `buffalo_s` instead of `buffalo_l`. The former is a smaller and faster model, albeit not as good.
- You _must_ re-run the Recognize Faces job for all images after this for facial recognition on new images to work properly.
- If these changes are not enough, see [below](/docs/FAQ.md#how-can-i-disable-machine-learning) for how you can disable machine learning.
### How can I disable machine learning?
:::info
Disabling machine learning will result in a poor experience for searching and the 'Explore' page, as these are reliant on it to work as intended.
:::
Machine learning can be disabled under Settings > Machine Learning Settings, either entirely or by model type. For instance, you can choose to disable smart search with CLIP, but keep facial recognition enabled. This means that the machine learning service will only process the enabled jobs.
However, disabling all jobs will not disable the machine learning service itself. To prevent it from starting up at all in this case, you can comment out the `immich-machine-learning` section of the docker-compose.yml.
### How can I disable TypeSense?
:::info
Disabling Typesense will result in a poor search experience since searching is reliant on it.
:::
You can disable Typesense by commenting out the `immich-typesense` section of the docker-compose.yml and setting `TYPESENSE_ENABLED=false` in your .env file.
### I'm getting errors about models being corrupt or failing to download. What do I do?
You can delete the model cache volume, which is where models are downloaded. This will give the service a clean environment to download the model again.
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
### In the uploads folder, why are photos stored in the wrong date?
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.
### How can I see Immich logs?
Most Immich components are typically deployed using docker. To see logs for deployed docker containers, you can use the [Docker CLI](https://docs.docker.com/engine/reference/commandline/cli/), specifically the `docker logs` command. For examples, see [Docker Help](/docs/guides/docker-help.md)
### How can I run Immich as a non-root user?
1. Set the `PUID`/`PGID` environment variables (in `.env`).
2. Set the corresponding `user` argument in `docker-compose` for each service.
3. Add an additional volume to `immich-microservices` that mounts internally to `/usr/src/app/.reverse-geocoding-dump`.
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
### How can I reset the admin password?
The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server.
### How can I backup data from Immich?
See [backup and restore](/docs/administration/backup-and-restore.md).
### How can I **purge** data from Immich?
Data for Immich comes in two forms:
1. **Metadata** stored in a postgres database, persisted via the `pg_data` volume
2. **Files** (originals, thumbs, profile, etc.), stored in the `UPLOAD_LOCATION` folder.
To remove the **Metadata** you can stop Immich and delete the volume.
```bash title="Remove Immich (containers and volumes)"
docker-compose down -v
```
After removing the containers and volumes, the **Files** can be cleaned up (if necessary) from the `UPLOAD_LOCATION` by simply deleting an unwanted files or folders.
### How can I move all data (photos, persons, albums) from one user to another?
This requires some database queries. You can do this on the command line (in the PostgreSQL container using the psql command), or you can add for example an [Adminer](https://www.adminer.org/) container to the `docker-compose.yml` file, so that you can use a web-interface.
:::warning
This is an advanced operation. If you can't to do it with the steps described here, this is not for you.
:::
1. **MAKE A BACKUP** - See [backup and restore](/docs/administration/backup-and-restore.md).
2. Find the id of both the 'source' and the 'destination' user (it's the id column in the users table)
3. Three tables need to be updated:
```sql
// reassign albums
update albums set "ownerId" = '<destinationId>' where "ownerId" = '<sourceId>';
// reassign people
update person set "ownerId" = '<destinationId>' where "ownerId" = '<sourceId>';
// reassign assets
update assets set "ownerId" = '<destinationId>' where "ownerId" = '<sourceId>'
and checksum not in (select checksum from assets where "ownerId" = '<destinationId>');
```
4. There might be left-over assets in the 'source' user's library if they are skipped by the last query because of duplicate checksums. These are probably duplicates anyway, and can probably be removed.

View File

@@ -1,311 +0,0 @@
# FAQ
## User
### How can I reset the admin password?
The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server.
### How can I see list of all users in Immich?
You can see the list of all users by running [list-users](/docs/administration/server-commands.md) Command on the Immich-server.
---
## Mobile App
### What is the difference between the cloud icons on the mobile app?
| Icon | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| ![cloud](/img/cloud.svg) | Asset is only available in the cloud and was uploaded from some other device (like the web client) or was deleted from this device after upload |
| ![cloud-cross](/img/cloud-off.svg) | Asset is only available locally and has not yet been backed up |
| ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up to the server; the original file is still on the device |
### I cannot log into the application after an update. What can I do?
First, verify that the mobile app and server are both running the same version (major and minor).
:::note
App store updates sometimes take longer because the stores (play store; Google and app store; Apple)
need to approve the update first which may take some time.
:::
If you still cannot login to the app, try the following:
- Check the mobile logs
- Make sure login credentials are correct by logging in on the web app
---
## Assets
### Can I add my existing photo library?
Yes, with an [External Library](/docs/features/libraries.md).
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
Template changes will only apply to _new_ assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
### Why are only photos and not videos being uploaded to Immich?
This often happens when using a reverse proxy (such as nginx or Cloudflare tunnel) in front of Immich. Make sure to set your reverse proxy to allow large `POST` requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Also check the disk space of your reverse proxy, in some cases proxies cache requests to disk before passing them on, and if disk space runs out the request fails.
### Why are some photos stored in the file system with the wrong date?
There are a few different scenarios that can lead to this situation. The solution is to simply run the storage migration job again. The job is only _automatically_ run once per asset, after upload. If metadata extraction originally failed, the jobs were cleared/cancelled, etc. the job may not have run automatically the first time.
### How can I hide photos from the timeline?
You can _archive_ them.
### How can I backup data from Immich?
See [Backup and Restore](/docs/administration/backup-and-restore.md).
### Does Immich support reading existing face tag metadata?
No, it currently does not.
### Does Immich support filtering of NSFW images?
No, it currently does not, but there is an [open discussion about it On Github](https://github.com/immich-app/immich/discussions/2451). You can submit a pull request or vote for the discussion.
### Why are there so many thumbnail generation jobs?
There are three thubmanil jobs for each asset:
- Blurred (thumbhash)
- Small (webp)
- Large (jpeg)
Also, there are additional jobs for person (face) thumbnails.
### What happens if an asset exists in more than one account?
There are no requirements for assets to be unique across users. If multiple users upload the same image they are processed as if they were distinct assets and jobs run and thumbnails are generated accordingly.
### How can I delete transcoded videos without deleting the original?
The transcode of an asset can be deleted by setting a transcode policy that makes it unnecessary, then running a transcoding job for that asset. This can be done on a per-asset basis by starting a transcoding job for an asset (with the _Refresh encoded videos_ button in the asset viewer options. Or, for all assets by running transcoding jobs for all assets.
To update the transcode policy, navigate to Administration > Video Transcoding Settings > Transcoding Policy and select a policy from the drop-down. This policy will determine whether an existing transcode will be deleted or overwritten in the transcoding job. If a video should be transcoded according to this policy, an existing transcode is overwritten. If not, then it is deleted.
:::note
For example, say you have existing transcodes with the policy "Videos higher than normal resolution or not in the desired format" and switch to a narrower policy: "Videos not in the desired format". If an asset was only transcoded due to its resolution, then running a transcoding job for it will now delete the existing transcode. This is because resolution is no longer part of the transcode policy and the transcode is unnecessary as a result. Likewise, if you set the policy to "Don't transcode any videos" and run transcoding jobs for all assets, this will delete all existing transcodes as they are all unnecessary.
:::
### Is it possible to compress images during backup?
No. Our golden rule is that the original assets should always be untouched, so we don't think this feature is a good fit for Immich.
### How can I move all data (photos, persons, albums) from one user to another?
This is not officially supported, but can be accomplished with some database updates. You can do this on the command line (in the PostgreSQL container using the psql command), or you can add for example an [Adminer](https://www.adminer.org/) container to the `docker-compose.yml` file, so that you can use a web-interface.
:::warning
This is an advanced operation. If you can't do it with the steps described here, this is not for you.
:::
<details>
<summary>Steps</summary>
1. **MAKE A BACKUP** - See [backup and restore](/docs/administration/backup-and-restore.md).
2. Find the id of both the 'source' and the 'destination' user (it's the id column in the users table)
3. Three tables need to be updated:
```sql
// reassign albums
UPDATE albums SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
// reassign people
UPDATE person SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>';
// reassign assets
UPDATE assets SET "ownerId" = '<destinationId>' WHERE "ownerId" = '<sourceId>'
AND CHECKSUM NOT IN (SELECT CHECKSUM FROM assets WHERE "ownerId" = '<destinationId>');
```
4. There might be left-over assets in the 'source' user's library if they are skipped by the last query because of duplicate checksums. These are probably duplicates anyway, and can probably be removed.
</details>
---
## Albums
### Can I keep my existing album structure while importing assets into Immich?
Yes. You can by use [Immich CLI](/docs/features/command-line-interface) along with the `--album` flag.
### Is there a way to reorder photos within an album?
No, not yet. For updates on this planned feature, follow the [GitHub discussion](https://github.com/immich-app/immich/discussions/1689),
---
## External Library
### Can I add an external library while keeping the existing albums structure?
We haven't put in an official mechanism to create albums from external libraries at the moment., but there are some [workarounds from the community](https://github.com/immich-app/immich/discussions/4279) which you can find here to help you achieve that.
### What happens to duplicates in external libraries?
Duplicate checking only exists for upload libraries, using the file hash. Furthermore, duplicate checking is not global, but _per library_. Therefore, a situation where the same file appears twice in the timeline is possible, especially for external libraries.
---
## Machine Learning
### How does smart search work?
Immich uses CLIP models, for more information about CLIP and its capabilities read about it [here](https://openai.com/research/clip).
### How does facial recognition work?
For face detection and recognition, Immich uses [InsightFace models](https://github.com/deepinsight/insightface/tree/master/model_zoo).
### How can I disable machine learning?
:::info
Disabling machine learning will result in a poor experience for searching and the 'Explore' page, as these are reliant on it to work as intended.
:::
Machine learning can be disabled under Administration > Settings > Machine Learning Settings, either entirely or by model type. For instance, you can choose to disable smart search with CLIP, but keep facial recognition enabled. This means that the machine learning service will only process the enabled jobs.
However, disabling all jobs will not disable the machine learning service itself. To prevent it from starting up at all in this case, you can comment out the `immich-machine-learning` section of the docker-compose.yml.
### I'm getting errors about models being corrupt or failing to download. What do I do?
You can delete the model cache volume, which is where models are downloaded to. This will give the service a clean environment to download the model again.
### Why did Immich decide to remove object detection?
The feature added keywords to images for metadata search, but wasn't used for smart search. Smart search made it unnecessary as it isn't limited to exact keywords. Combined with it causing crashes on some devices, using many dependencies and causing user confusion as to how search worked, it was better to remove the job altogether.
For more info see [here](https://github.com/immich-app/immich/pull/5903)
### Can I use a custom CLIP model?
No, this is not supported. Only models listed in the [Huggingface](https://huggingface.co/immich-app) are compatible. Feel free to make a feature request if there's a model not listed here that you think should be added.
### I want to be able to search in other languages besides English. How can I do that?
You can change to a multilingual model listed [here](https://huggingface.co/collections/immich-app/multilingual-clip-654eb08c2382f591eeb8c2a7) by going to Administration > Machine Learning Settings > Smart Search and replacing the name of the model. Be sure to re-run Smart Search on all assets after this change. You can then search in over 100 languages.
:::note
Feel free to make a feature request if there's a model you want to use that isn't in [Immich Huggingface list](https://huggingface.co/immich-app).
:::
### Does Immich support Facial Recognition for videos ?
This is not currently implemented, but may be in the future.
On the other hand, Immich does scan video thumbnails for faces, so it can perform recognition if the face is clear in the video thumbnail.
### Does Immich have animal recognition?
No.
### The immich_model-cache volume takes up a lot of space, what could be the problem?
If you installed several models and chose not to use some of them, it might be worth deleting the old models that are in immich_model-cache.
To do this you can run:
- `docker run -it --rm -v immich_model-cache:/mnt ubuntu bash`
- `cd mnt`
- `ls`
- and delete unused models with `rm -r <model_name>`.
---
## Performance
### Why is Immich slow on low-memory systems like the Raspberry Pi?
Immich optionally uses machine learning for several features. However, it can be too heavy to run on a Raspberry Pi. You can [mitigate](/docs/FAQ#can-i-lower-cpu-and-ram-usage) this or transfer to host Immich's machine-learning container on a [more powerful system](/docs/guides/remote-machine-learning) ,or [disable](/docs/FAQ#how-can-i-disable-machine-learning) machine learning entirely.
### Can I lower CPU and RAM 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 (Smart Search, Face Detection), 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.
- Under Settings > Machine Learning Settings > Facial Recognition > Model Name, you can change the facial recognition model to `buffalo_s` instead of `buffalo_l`. The former is a smaller and faster model, albeit not as good.
- You _must_ re-run the Face Detection job for all images after this for facial recognition on new images to work properly.
- If these changes are not enough, see [below](/docs/FAQ#how-can-i-disable-machine-learning) for how you can disable machine learning.
### Can I limit the amount of CPU and RAM usage?
By default, a container has no resource constraints and can use as much of a given resource as the host's kernel scheduler allows.
You can look at the [original docker docs](https://docs.docker.com/config/containers/resource_constraints/) or use this [guide](https://www.baeldung.com/ops/docker-memory-limit) to learn how to do this.
### How an I boost machine learning speed?
:::note
This advice improves throughput, not latency. This is to say that it will make Smart Search jobs process more quickly, but it won't make searching faster.
:::
You can increase throughput by increasing the job concurrency for machine learning jobs (Smart Search, Face Detection). With higher concurrency, the host will work on more assets in parallel. You can do this by navigating to Administration > Settings > Job Settings and increasing concurrency as needed.
:::danger
On a normal machine, 2 or 3 concurrent jobs can probably max the CPU, so if you're not hitting those maximums with, say, 30 jobs.
Note that storage speed and latency may quickly become the limiting factor; particularly when using HDDs.
Do not exaggerate with the amount of jobs because you're probably thoroughly overloading the server.
more info [here](https://discord.com/channels/979116623879368755/994044917355663450/1174711719994605708)
:::
### Why is Immich using so much of my CPU?
When a large amount of assets are uploaded to Immich it makes sense that the CPU and RAM will be heavily used due to machine learning work and creating image thumbnails after that, the percentage of CPU usage will drop to around 3-5% usage
---
## Docker
### How can I see Immich logs?
Most Immich components are typically deployed using docker. To see logs for deployed docker containers, you can use the [Docker CLI](https://docs.docker.com/engine/reference/commandline/cli/), specifically the `docker logs` command. For examples, see [Docker Help](/docs/guides/docker-help.md).
### How can I run Immich as a non-root user?
1. Set the `PUID`/`PGID` environment variables (in `.env`).
2. Set the corresponding `user` argument in `docker-compose` for each service.
3. Add an additional volume to `immich-microservices` that mounts internally to `/usr/src/app/.reverse-geocoding-dump`.
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
### How can I **purge** data from Immich?
Data for Immich comes in two forms:
1. **Metadata** stored in a postgres database, persisted via the `pg_data` volume
2. **Files** (originals, thumbs, profile, etc.), stored in the `UPLOAD_LOCATION` folder.
To remove the **Metadata** you can stop Immich and delete the volume.
```bash title="Remove Immich (containers and volumes)"
docker compose down -v
```
After removing the containers and volumes, the **Files** can be cleaned up (if necessary) from the `UPLOAD_LOCATION` by simply deleting any unwanted files or folders.
### Why does the machine learning service report workers crashing?
:::note
If the error says the worker is exiting, then this is normal. This is a feature intended to reduce RAM consumption when the service isn't being used.
:::
There are a few reasons why this can happen.
If the error mentions SIGKILL or error code 137, it most likely means the service is running out of memory. Consider either increasing the server's RAM or moving the service to a server with more RAM.
If it mentions SIGILL (note the lack of a K) or error code 132, it most likely means your server's CPU is incompatible. This is unlikely to occur on version 1.92.0 or later. Consider upgrading if your version of Immich is below that.
If your version of Immich is below 1.92.0 and the crash occurs after logs about tracing or exporting a model, consider either upgrading or disabling the Tag Objects job.

View File

@@ -1,7 +1,5 @@
# 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](/docs/guides/template-backup-script.md)
## Database
:::caution
@@ -19,13 +17,13 @@ docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/back
```
```bash title='Restore'
docker compose down -v # CAUTION! Deletes all Immich data to start from scratch.
docker compose pull # Update to latest version of Immich (if desired)
docker compose create # Create Docker containers for Immich apps without running them.
docker-compose down -v # CAUTION! Deletes all Immich data to start from scratch.
docker-compose pull # Update to latest version of Immich (if desired)
docker-compose create # Create Docker containers for Immich apps without running them.
docker start immich_postgres # Start Postgres server
sleep 10 # Wait for Postgres server to start up
gunzip < "/path/to/backup/dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich # Restore Backup
docker compose up -d # Start remainder of Immich apps
docker-compose up -d # Start remainder of Immich apps
```
Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.).
@@ -46,6 +44,7 @@ services:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
SCHEDULE: "@daily"
BACKUP_NUM_KEEP: 7
BACKUP_DIR: /db_dumps
volumes:
- ./db_dumps:/db_dumps
@@ -66,34 +65,3 @@ Immich stores two types of content in the filesystem: (1) original, unmodified c
1. `UPLOAD_LOCATION/library`
1. `UPLOAD_LOCATION/upload`
1. `UPLOAD_LOCATION/profile`
**1. User-Specific Folders:**
- Each user has a unique string representing them.
- The main user is "Admin" (but only for `\library\library\`)
- Other users have different string identifiers.
- You can find your user ID in Account Account Settings > Account > User ID.
**2. Asset Types and Storage Locations:**
- **Source Assets:**
- Original assets uploaded through the browser interface&mobile&CLI.
- Stored in `\library\library\<userID>`.
- **Avatar Images:**
- User profile images.
- Stored in `\library\profile\<userID>`.
- **Thumbs Images:**
- Preview images (blurred, small, large) for each asset and thumbnails for recognized faces.
- Stored in `\library\thumbs\<userID>`.
- **Encoded Assets:**
- By default, unless otherwise specified re-encoded video assets for wider compatibility .
- Stored in `\library\encoded-video\<userID>`.
- **Files in Upload Queue (Mobile):**
- Files uploaded through mobile apps.
- Temporarily located in `\library\upload\<userID>`.
- Transferred to `\library\library\<userID>` upon successful upload.
:::danger
Do not touch the files inside these folders under any circumstances except taking a backup, changing or removing an asset can cause untracked and missing files.
You can think of it as App-Which-Must-Not-Be-Named, the only access to viewing, changing and deleting assets is only through the mobile or browser interface.
:::

View File

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

View File

@@ -51,7 +51,8 @@ immich-admin list-users
{
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
email: 'immich@example.com.com',
name: 'Immich Admin',
firstName: 'Immich',
lastName: 'Admin',
storageLabel: 'admin',
externalPath: null,
profileImagePath: 'upload/profile/e65e6f88-2a30-4dbe-8dd9-1885f4889b53/e65e6f88-2a30-4dbe-8dd9-1885f4889b53.jpg',

View File

@@ -1,4 +1,4 @@
import StorageTemplate from '/docs/partials/_storage-template.md';
import StorageTemplate from '../partials/_storage-template.md';
# Storage Template

View File

@@ -1,5 +1,5 @@
import RegisterAdminUser from '/docs/partials/_register-admin.md';
import UserCreate from '/docs/partials/_user-create.md';
import RegisterAdminUser from '../partials/_register-admin.md';
import UserCreate from '../partials/_user-create.md';
# User Management

View File

@@ -2,17 +2,15 @@
sidebar_position: 1
---
import AppArchitecture from './img/app-architecture.png';
# Architecture
Immich uses a traditional client-server design, with a dedicated database for data persistence. The frontend clients communicate with backend services over HTTP using REST APIs. Below is a high level diagram of the architecture.
Immich uses a traditional client-server design, with a dedicated database for data persistence. The frontend clients communicate with backend services over HTTP using REST APIs.
## High Level Diagram
<img alt="Immich Architecture" src={AppArchitecture} className="p-4 dark:bg-immich-dark-primary my-4" />
![Immich Architecture](./img/app-architecture.png)
The diagram shows clients communicating with the server's API via REST. The server communicates with downstream systems (i.e. Redis, Postgres, Machine Learning, file system) through repository interfaces. Not shown in the diagram, is that the server is split into two separate containers `immich-server` and `immich-microservices`. The microservices container does not handle API requests or schedule cron jobs, but primarily handles incoming job requests from Redis.
The diagram shows clients communicating with the server via REST, as well as the flow of database between backend services.
## Clients
@@ -36,7 +34,7 @@ The web app is a [TypeScript](https://www.typescriptlang.org/) project that uses
### CLI
The Immich CLI is an [npm](https://www.npmjs.com/) package that lets users control their Immich instance from the command line. It uses the API to perform various tasks, especially uploading assets. See the [CLI documentation](/docs/features/command-line-interface.md) for more information.
The CLI is a [TypeScript](https://www.typescriptlang.org/) project that parses command line arguments to programmatically upload/import assets to an Immich server. See [Bulk Upload](/docs/features/bulk-upload.md) for more information about its usage.
## Server
@@ -47,6 +45,7 @@ The Immich backend is divided into several services, which are run as individual
1. `immich-machine-learning` - Execute machine learning models
1. `postgres` - Persistent data storage
1. `redis`- Queue management for `immich-microservices`
1. `typesense`- Specialized database for search, specifically with vector comparison features
### Immich Server
@@ -73,9 +72,10 @@ The Immich Microservices image uses the same `Dockerfile` as the Immich Server,
- Thumbnail Generation
- Metadata Extraction
- Video Transcoding
- Smart Search
- Object Tagging
- Facial Recognition
- Storage Template Migration
- Search (Typesense synchronization)
- Sidecar (see [XMP Sidecars](/docs/features/xmp-sidecars.md))
- Background jobs (file deletion, user deletion)
@@ -107,4 +107,10 @@ See [Database Migrations](./database-migrations.md) for more information about h
### Redis
Immich uses [Redis](https://redis.com/) via [BullMQ](https://docs.bullmq.io/) to manage job queues. Some jobs trigger subsequent jobs. For example, Smart Search and Facial Recognition relies on thumbnail generation and automatically run after one is generated.
Immich uses [Redis](https://redis.com/) via [BullMQ](https://docs.bullmq.io/) to manage job queues. Some jobs trigger subsequent jobs. For example, object detection relies on thumbnail generation and automatically run after one is generated.
### Typesense
Immich synchronizes some of the Postgres data into Typesense, so it can execute vector related queries in order to implement certain features including, facial recognition and CLIP search.
<!-- - [NGINX](https://www.nginx.com/) for internal communication between containers and load balancing when scaling. -->

View File

@@ -1,6 +1,6 @@
# Database Migrations
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.
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.
1. Run the command
@@ -9,6 +9,6 @@ npm run typeorm:migrations:generate ./src/infra/<migration-name>
```
2. Check if the migration file makes sense.
3. Move the migration file to folder `./server/src/infra/migrations` in your code editor.
3. Move the migration file to folder `./src/infra/database/migrations` in your code editor.
The server will automatically detect `*.ts` file changes and restart. Part of the server start-up process includes running any new migrations, so it will be applied immediately.

View File

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

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