Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Way
96a2725c3e feat: object storage 2024-02-18 23:16:45 +00:00
1757 changed files with 72174 additions and 72954 deletions

View File

@@ -1,31 +1,30 @@
.vscode/
.github/
.git/
design/
docker/
docs/
e2e/
fastlane/
machine-learning/
misc/
mobile/
cli/coverage/
cli/dist/
cli/node_modules/
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
server/coverage/
server/node_modules/
server/coverage/
server/.reverse-geocoding-dump/
server/upload/
server/dist/
server/www/
server/test/assets/
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
cli/node_modules/
cli/.reverse-geocoding-dump/
cli/upload/
cli/dist/
e2e/
open-api/typescript-sdk/node_modules/
open-api/typescript-sdk/build/

View File

@@ -16,4 +16,4 @@ max_line_length = off
trim_trailing_whitespace = false
[*.{yml,yaml}]
quote_type = single
quote_type = double

2
.gitattributes vendored
View File

@@ -8,6 +8,8 @@ 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/axios-client/**/* -diff -merge
open-api/typescript-sdk/axios-client/**/* linguist-generated=true
open-api/typescript-sdk/fetch-client.ts -diff -merge
open-api/typescript-sdk/fetch-client.ts linguist-generated=true

View File

@@ -6,14 +6,6 @@ body:
attributes:
value: |
Please use this form to request new feature for Immich
- type: checkboxes
attributes:
label: I have searched the existing feature requests to make sure this is not a duplicate request.
options:
- label: "Yes"
required: true
- type: textarea
id: feature
attributes:

View File

@@ -87,16 +87,6 @@ body:
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant logs below. (code formatting is
enabled, no need for backticks)
render: shell
validations:
required: false
- type: textarea
attributes:
label: Additional information

View File

@@ -45,7 +45,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.19.3"
flutter-version: "3.16.9"
cache: true
- name: Create the Keystore

View File

@@ -58,7 +58,7 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.3.0
uses: docker/setup-buildx-action@v3.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -87,7 +87,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Build and push image
uses: docker/build-push-action@v5.3.0
uses: docker/build-push-action@v5.1.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64

View File

@@ -0,0 +1,26 @@
name: Update Immich SDK
on:
workflow_dispatch:
push:
branches: ["main"]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-sdk-repos:
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.head.repo.fork }}
steps:
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'immich-app',
repo: 'immich-sdk-typescript-axios',
workflow_id: 'build.yml',
ref: 'main'
})

View File

@@ -35,7 +35,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.5.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.6.0
uses: stumpylog/image-cleaner-action/untagged@v0.5.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"

View File

@@ -66,7 +66,13 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.3.0
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
@@ -115,7 +121,7 @@ jobs:
fi
- name: Build and push image
uses: docker/build-push-action@v5.3.0
uses: docker/build-push-action@v5.1.0
with:
context: ${{ matrix.context }}
file: ${{ matrix.file }}

View File

@@ -4,16 +4,16 @@ on:
workflow_dispatch:
inputs:
serverBump:
description: 'Bump server version'
description: "Bump server version"
required: true
default: 'false'
default: "false"
type: choice
options:
- 'false'
- "false"
- minor
- patch
mobileBump:
description: 'Bump mobile build number'
description: "Bump mobile build number"
required: false
type: boolean
@@ -46,8 +46,8 @@ jobs:
with:
author_name: Alex The Bot
author_email: alex.tran1502@gmail.com
default_author: user_info
message: 'Version ${{ env.IMMICH_VERSION }}'
default_author: user_info
message: "Version ${{ env.IMMICH_VERSION }}"
tag: ${{ env.IMMICH_VERSION }}
push: true
@@ -74,7 +74,7 @@ jobs:
name: release-apk-signed
- name: Create draft release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}
@@ -85,5 +85,4 @@ jobs:
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk

View File

@@ -1,31 +0,0 @@
name: Update Immich SDK
on:
release:
types: [published]
permissions:
packages: write
jobs:
publish:
name: Publish `@immich/sdk`
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./open-api/typescript-sdk
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'
- name: Install deps
run: npm ci
- name: Build
run: npm run build
- name: Publish
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -23,7 +23,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.19.3"
flutter-version: "3.16.9"
- name: Install dependencies
run: dart pub get
@@ -32,11 +32,7 @@ jobs:
- name: Run dart analyze
run: dart analyze --fatal-infos
working-directory: ./mobile
- name: Run dart format
run: dart format lib/ --set-exit-if-changed
working-directory: ./mobile
# Enable after riverpod generator migration is completed
# - name: Run dart custom lint
# run: dart run custom_lint

View File

@@ -10,6 +10,36 @@ 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
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Run e2e tests
run: make server-e2e-jobs
doc-tests:
name: Docs
runs-on: ubuntu-latest
@@ -78,13 +108,17 @@ jobs:
with:
node-version: 20
- name: Setup typescript-sdk
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
- name: Run npm install (cli)
run: npm ci
- name: Run npm install (server)
run: npm ci
working-directory: ./server
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
@@ -101,6 +135,38 @@ jobs:
run: npm run test:cov
if: ${{ !cancelled() }}
cli-e2e-tests:
name: CLI (e2e)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./cli
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: "recursive"
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- 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 && npm run build
working-directory: ./server
- name: Run e2e tests
run: npm run test:e2e
web-unit-tests:
name: Web
runs-on: ubuntu-latest
@@ -139,8 +205,8 @@ jobs:
run: npm run test:cov
if: ${{ !cancelled() }}
e2e-tests:
name: End-to-End Tests
web-e2e-tests:
name: Web (e2e)
runs-on: ubuntu-latest
defaults:
run:
@@ -149,55 +215,23 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Run setup typescript-sdk
run: npm ci && npm run build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup cli
run: npm ci && npm run build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: npm ci
if: ${{ !cancelled() }}
- name: Run linter
run: npm run lint
if: ${{ !cancelled() }}
- name: Run formatter
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
if: ${{ !cancelled() }}
run: npx playwright install --with-deps
- name: Docker build
run: docker compose build
if: ${{ !cancelled() }}
run: docker compose -f docker/docker-compose.e2e.yml build
working-directory: ./
- name: Run e2e tests (api & cli)
run: npm run test
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
- name: Run e2e tests
run: npx playwright test
if: ${{ !cancelled() }}
mobile-unit-tests:
name: Mobile
@@ -207,8 +241,8 @@ jobs:
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.16.9'
channel: "stable"
flutter-version: "3.16.9"
- name: Run tests
working-directory: ./mobile
run: flutter test -j 1
@@ -226,7 +260,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: 3.11
cache: 'poetry'
cache: "poetry"
- name: Install dependencies
run: |
poetry install --with dev --with cpu
@@ -243,19 +277,6 @@ jobs:
run: |
poetry run pytest app --cov=app --cov-report term-missing
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@master
with:
ignore_paths: >-
**/open-api/**
**/openapi/**
**/node_modules/**
generated-api-up-to-date:
name: OpenAPI Clients
runs-on: ubuntu-latest
@@ -264,7 +285,7 @@ jobs:
- name: Run API generation
run: make open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@v19
uses: tj-actions/verify-changed-files@v18
id: verify-changed-files
with:
files: |
@@ -316,14 +337,14 @@ jobs:
- name: Generate new migrations
continue-on-error: true
run: npm run typeorm:migrations:generate ./src/migrations/TestMigration
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@v19
uses: tj-actions/verify-changed-files@v18
id: verify-changed-files
with:
files: |
server/src/migrations/
server/src/infra/migrations/
- name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |
@@ -337,11 +358,11 @@ jobs:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@v19
uses: tj-actions/verify-changed-files@v18
id: verify-changed-sql-files
with:
files: |
server/src/queries
server/src/infra/sql
- name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'

34
.vscode/settings.json vendored
View File

@@ -1,34 +0,0 @@
{
"editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": [
"javascript",
"svelte"
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"[dart]": {
"editor.formatOnSave": true,
"editor.selectionHighlight": false,
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.suggestSelection": "first",
"editor.tabCompletion": "onlySnippets",
"editor.wordBasedSuggestions": "off",
"editor.defaultFormatter": "Dart-Code.dart-code"
},
"cSpell.words": [
"immich"
],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
}
}

View File

@@ -1,5 +0,0 @@
/.github/ @bo0tzz
/docker/ @bo0tzz
/server/ @danieldietzler
/machine-learning/ @mertalev
/e2e/ @danieldietzler

View File

@@ -16,9 +16,15 @@ stage:
pull-stage:
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
server-e2e-api:
npm run e2e:api --prefix server
.PHONY: e2e
e2e:
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
docker compose -f ./docker/docker-compose.e2e.yml up --build -V --remove-orphans
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans

View File

@@ -9,26 +9,26 @@
</p>
<p align="center">
<img src="design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">High performance self-hosted photo and video management solution</h3>
<h3 align="center">Immich - High performance self-hosted photo and video backup solution</h3>
<br/>
<a href="https://immich.app">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="readme_i18n/README_ca_ES.md">Català</a>
<a href="readme_i18n/README_es_ES.md">Español</a>
<a href="readme_i18n/README_fr_FR.md">Français</a>
<a href="readme_i18n/README_it_IT.md">Italiano</a>
<a href="readme_i18n/README_ja_JP.md">日本語</a>
<a href="readme_i18n/README_ko_KR.md">한국어</a>
<a href="readme_i18n/README_de_DE.md">Deutsch</a>
<a href="readme_i18n/README_nl_NL.md">Nederlands</a>
<a href="readme_i18n/README_tr_TR.md">Türkçe</a>
<a href="readme_i18n/README_zh_CN.md">中文</a>
<a href="readme_i18n/README_ru_RU.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_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">中文</a>
<a href="README_ru_RU.md">Русский</a>
</p>
## Disclaimer
@@ -128,13 +128,3 @@ If you feel like this is the right cause and the app is something you are seeing
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
## Star History
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Iniciar sessió amb URL personalitzada">
<img src="design/immich-logo.svg" width="150" title="Iniciar sessió amb URL personalitzada">
</p>
<h3 align="center">Immich - Solució de còpia de seguretat d'alta rendiment per a fotos i vídeos auto-allotjada</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Captura de pantalla principal">
<img src="design/immich-screenshots.png" title="Captura de pantalla principal">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<a href="README.md">English</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>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login mit eigener URL">
<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">
<img src="design/immich-screenshots.png" title="Haupt-Screenshot">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Iniciar sesión con URL personalizada">
<img src="design/immich-logo.svg" width="150" title="Iniciar sesión con URL personalizada">
</p>
<h3 align="center">Immich: Una solución Self-Hosted de copia de seguridad de fotos y videos de alto rendimiento</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Captura de pantalla principal">
<img src="design/immich-screenshots.png" title="Captura de pantalla principal">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<a href="README.md">English</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>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - Solution de sauvegarde performante et auto-hébergée des photos et des vidéos</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Main Screenshot">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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_it_IT.md">Italiano</a>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Main Screenshot">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<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">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<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">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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>

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login met aangepaste URL">
<img src="design/immich-logo.svg" width="150" title="Login met aangepaste URL">
</p>
<h3 align="center">Immich - Hoogwaardige, self-hosted back-up oplossing voor foto's en video's</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Main Screenshot">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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>

View File

@@ -9,12 +9,12 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<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">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">

View File

@@ -9,16 +9,16 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - Yüksek performanslı, kendine ait barındırılan fotoğraf ve video yedekleme çözümü</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Main Screenshot">
<img src="design/immich-screenshots.png" title="Main Screenshot">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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>

View File

@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
</p>
<h3 align="center">Immich - 高性能的自托管照片和视频备份方案</h3>
<p align="center">
@@ -17,12 +17,12 @@
</p>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="界面截图">
<img src="design/immich-screenshots.png" title="界面截图">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<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>
@@ -73,22 +73,17 @@
规格: 甲骨文免费虚拟机套餐——阿姆斯特丹 4核 2.4Ghz ARM64 CPU, 24GB RAM。
```
## 活跃度
![活跃度](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image")
# 功能特性
| 功能特性 | 移动端 | 网页端 |
|---------------------------------------------|--------|--------|
| 上传并查看照片和视频 | 是 | 是 |
| 软件运行时自动备份 | 是 | N/A |
| 忽略重复的项目 | 是 | 是 |
| 选择需要备份的相册 | 是 | N/A |
| 下载照片和视频到本地 | 是 | 是 |
| 多用户支持 | 是 | 是 |
| 相册与共享相册 | 是 | 是 |
| 可拖动的快速滚动条 | 是 | 是 |
| 可拖动的快速导航栏 | 是 | 是 |
| 支持RAW格式 | 是 | 是 |
| 元数据视图EXIF、地图 | 是 | 是 |
| 通过元数据、对象、人脸和标签进行搜索 | 是 | 是 |
@@ -98,7 +93,6 @@
| OAuth 支持 | 是 | 是 |
| API Keys | N/A | 是 |
| 实况照片备份和查看 | 是 | 是 |
| 支持360度全景图显示 | 否 | 是 |
| 用户自定义存储结构 | 是 | 是 |
| 公共分享 | 否 | 是 |
| 归档与收藏功能 | 是 | 是 |
@@ -110,6 +104,7 @@
| 只读相册 | 是 | 是 |
| 照片堆叠 | 是 | 是 |
# 支持本项目
我已经致力于本项目并且我将会持续更新文档、新增功能和修复问题。但是独木不成林,我需要您给予我坚持下去的动力。
@@ -131,13 +126,3 @@
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
## Star增长曲线
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>

View File

@@ -10,6 +10,7 @@ module.exports = {
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
@@ -19,10 +20,13 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-process-exit': 'off',
'unicorn/import-style': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': [
'error',
{
ignore: ['\\.e2e-spec$', /^ignore/i],
},
],
},
};

View File

@@ -1,15 +1,12 @@
**/*.spec.js
coverage/**
src/**
test/**
upload/**
.editorconfig
.eslintignore
.eslintrc.cjs
.gitignore
.eslintrc.js
.prettierignore
.prettierrc
Dockerfile
package-lock.json
testSetup.js
tsconfig.json
vite.config.ts
vitest.config.ts
tsconfig.build.json

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine3.19@sha256:ec0c413b1d84f3f7f67ec986ba885930c57b5318d2eb3abc6960ee05d4f2eb28 as core
FROM node:20-alpine3.19 as core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

6523
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.0",
"version": "2.0.8",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -14,13 +14,13 @@
],
"devDependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
"@testcontainers/postgresql": "^10.7.1",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitest/coverage-v8": "^1.2.2",
"byte-size": "^8.1.1",
"cli-progress": "^3.12.0",
@@ -28,27 +28,28 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-unicorn": "^51.0.0",
"glob": "^10.3.1",
"immich": "file:../server",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.2.2",
"yaml": "^2.3.1"
},
"scripts": {
"build": "vite build",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"prepack": "npm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"check": "tsc --noEmit"
"check": "tsc --noEmit",
"test:e2e": "vitest --config test/e2e/vitest.config.ts"
},
"repository": {
"type": "git",
@@ -57,8 +58,5 @@
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"lodash-es": "^4.17.21"
}
}

View File

@@ -1,351 +0,0 @@
import {
Action,
AssetBulkUploadCheckResult,
AssetFileUploadResponseDto,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
defaults,
getAllAlbums,
getSupportedMediaTypes,
} from '@immich/sdk';
import byteSize from 'byte-size';
import { Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es';
import { Stats, createReadStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import os from 'node:os';
import path, { basename } from 'node:path';
import { BaseOptions, authenticate, crawl, sha1 } from 'src/utils';
const s = (count: number) => (count === 1 ? '' : 's');
// TODO figure out why `id` is missing
type AssetBulkUploadCheckResults = Array<AssetBulkUploadCheckResult & { id: string }>;
type Asset = { id: string; filepath: string };
interface UploadOptionsDto {
recursive?: boolean;
exclusionPatterns?: string[];
dryRun?: boolean;
skipHash?: boolean;
delete?: boolean;
album?: boolean;
albumName?: string;
includeHidden?: boolean;
concurrency: number;
}
class UploadFile extends File {
constructor(
private filepath: string,
private _size: number,
) {
super([], basename(filepath));
}
get size() {
return this._size;
}
stream() {
return createReadStream(this.filepath) as any;
}
}
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
await authenticate(baseOptions);
const scanFiles = await scan(paths, options);
if (scanFiles.length === 0) {
console.log('No files found, exiting');
return;
}
const { newFiles, duplicates } = await checkForDuplicates(scanFiles, options);
const newAssets = await uploadFiles(newFiles, options);
await updateAlbums([...newAssets, ...duplicates], options);
await deleteFiles(newFiles, options);
};
const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
const { image, video } = await getSupportedMediaTypes();
console.log('Crawling for assets...');
const files = await crawl({
pathsToCrawl,
recursive: options.recursive,
exclusionPatterns: options.exclusionPatterns,
includeHidden: options.includeHidden,
extensions: [...image, ...video],
});
return files;
};
const checkForDuplicates = async (files: string[], { concurrency, skipHash }: UploadOptionsDto) => {
if (skipHash) {
console.log('Skipping hash check, assuming all files are new');
return { newFiles: files, duplicates: [] };
}
const progressBar = new SingleBar(
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
progressBar.start(files.length, 0);
const newFiles: string[] = [];
const duplicates: Asset[] = [];
try {
// TODO refactor into a queue
for (const items of chunk(files, concurrency)) {
const dto = await Promise.all(items.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })));
const { results } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
for (const { id: filepath, assetId, action } of results as AssetBulkUploadCheckResults) {
if (action === Action.Accept) {
newFiles.push(filepath);
} else {
// rejects are always duplicates
duplicates.push({ id: assetId as string, filepath });
}
progressBar.increment();
}
}
} finally {
progressBar.stop();
}
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
return { newFiles, duplicates };
};
const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptionsDto): Promise<Asset[]> => {
if (files.length === 0) {
console.log('All assets were already uploaded, nothing to do.');
return [];
}
// Compute total size first
let totalSize = 0;
const statsMap = new Map<string, Stats>();
for (const filepath of files) {
const stats = await stat(filepath);
statsMap.set(filepath, stats);
totalSize += stats.size;
}
if (dryRun) {
console.log(`Would have uploaded ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`);
return [];
}
const uploadProgress = new SingleBar(
{ format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}' },
Presets.shades_classic,
);
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
let duplicateCount = 0;
let duplicateSize = 0;
let successCount = 0;
let successSize = 0;
const newAssets: Asset[] = [];
try {
for (const items of chunk(files, concurrency)) {
await Promise.all(
items.map(async (filepath) => {
const stats = statsMap.get(filepath) as Stats;
const response = await uploadFile(filepath, stats);
newAssets.push({ id: response.id, filepath });
if (response.duplicate) {
duplicateCount++;
duplicateSize += stats.size ?? 0;
} else {
successCount++;
successSize += stats.size ?? 0;
}
uploadProgress.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) });
return response;
}),
);
}
} finally {
uploadProgress.stop();
}
console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`);
if (duplicateCount > 0) {
console.log(`Skipped ${duplicateCount} duplicate asset${s(duplicateCount)} (${byteSize(duplicateSize)})`);
}
return newAssets;
};
const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadResponseDto> => {
const { baseUrl, headers } = defaults;
const assetPath = path.parse(input);
const noExtension = path.join(assetPath.dir, assetPath.name);
const sidecarsFiles = await Promise.all(
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
try {
const stats = await stat(sidecarPath);
return new UploadFile(sidecarPath, stats.size);
} catch {
return false;
}
}),
);
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
const formData = new FormData();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI');
formData.append('fileCreatedAt', stats.mtime.toISOString());
formData.append('fileModifiedAt', stats.mtime.toISOString());
formData.append('fileSize', String(stats.size));
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
const response = await fetch(`${baseUrl}/asset/upload`, {
method: 'post',
redirect: 'error',
headers: headers as Record<string, string>,
body: formData,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(await response.text());
}
return response.json();
};
const deleteFiles = async (files: string[], options: UploadOptionsDto): Promise<void> => {
if (!options.delete) {
return;
}
if (options.dryRun) {
console.log(`Would now have deleted assets, but skipped due to dry run`);
return;
}
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new SingleBar(
{ format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
deletionProgress.start(files.length, 0);
try {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: string) => unlink(input)));
deletionProgress.update(assetBatch.length);
}
} finally {
deletionProgress.stop();
}
};
const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
if (!options.album && !options.albumName) {
return;
}
const { dryRun, concurrency } = options;
const albums = await getAllAlbums({});
const existingAlbums = new Map(albums.map((album) => [album.albumName, album.id]));
const newAlbums: Set<string> = new Set();
for (const { filepath } of assets) {
const albumName = getAlbumName(filepath, options);
if (albumName && !existingAlbums.has(albumName)) {
newAlbums.add(albumName);
}
}
if (dryRun) {
// TODO print asset counts for new albums
console.log(`Would have created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Would have updated ${assets.length} asset${s(assets.length)}`);
return;
}
const progressBar = new SingleBar(
{ format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums' },
Presets.shades_classic,
);
progressBar.start(newAlbums.size, 0);
try {
for (const albumNames of chunk([...newAlbums], concurrency)) {
const items = await Promise.all(
albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } })),
);
for (const { id, albumName } of items) {
existingAlbums.set(albumName, id);
}
progressBar.increment(albumNames.length);
}
} finally {
progressBar.stop();
}
console.log(`Successfully created ${newAlbums.size} new album${s(newAlbums.size)}`);
console.log(`Successfully updated ${assets.length} asset${s(assets.length)}`);
const albumToAssets = new Map<string, string[]>();
for (const asset of assets) {
const albumName = getAlbumName(asset.filepath, options);
if (!albumName) {
continue;
}
const albumId = existingAlbums.get(albumName);
if (albumId) {
if (!albumToAssets.has(albumId)) {
albumToAssets.set(albumId, []);
}
albumToAssets.get(albumId)?.push(asset.id);
}
}
const albumUpdateProgress = new SingleBar(
{ format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
albumUpdateProgress.start(assets.length, 0);
try {
for (const [albumId, assets] of albumToAssets.entries()) {
for (const assetBatch of chunk(assets, Math.min(1000 * concurrency, 65_000))) {
await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
albumUpdateProgress.increment(assetBatch.length);
}
}
} finally {
albumUpdateProgress.stop();
}
};
const getAlbumName = (filepath: string, options: UploadOptionsDto) => {
const folderName = os.platform() === 'win32' ? filepath.split('\\').at(-2) : filepath.split('/').at(-2);
return options.albumName ?? folderName;
};

View File

@@ -1,48 +0,0 @@
import { getMyUserInfo } from '@immich/sdk';
import { existsSync } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils';
export const login = async (url: string, key: string, options: BaseOptions) => {
console.log(`Logging in to ${url}`);
const { configDirectory: configDir } = options;
await connect(url, key);
const [error, userInfo] = await withError(getMyUserInfo());
if (error) {
logError(error, 'Failed to load user info');
process.exit(1);
}
console.log(`Logged in as ${userInfo.email}`);
if (!existsSync(configDir)) {
// Create config folder if it doesn't exist
const created = await mkdir(configDir, { recursive: true });
if (!created) {
console.log(`Failed to create config folder: ${configDir}`);
return;
}
}
await writeAuthFile(configDir, { url, key });
console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`);
};
export const logout = async (options: BaseOptions) => {
console.log('Logging out...');
const { configDirectory: configDir } = options;
const authFile = getAuthFilePath(configDir);
if (existsSync(authFile)) {
await unlink(authFile);
console.log(`Removed auth file: ${authFile}`);
}
console.log('Successfully logged out');
};

View File

@@ -0,0 +1,20 @@
import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
import { ImmichApi } from 'src/services/api.service';
import { SessionService } from '../services/session.service';
export abstract class BaseCommand {
protected sessionService!: SessionService;
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;
constructor(options: { configDirectory?: string }) {
if (!options.configDirectory) {
throw new Error('Config directory is required');
}
this.sessionService = new SessionService(options.configDirectory);
}
public async connect(): Promise<ImmichApi> {
return await this.sessionService.connect();
}
}

View File

@@ -0,0 +1,7 @@
import { BaseCommand } from './base-command';
export class LoginCommand extends BaseCommand {
public async run(instanceUrl: string, apiKey: string): Promise<void> {
await this.sessionService.login(instanceUrl, apiKey);
}
}

View File

@@ -0,0 +1,8 @@
import { BaseCommand } from './base-command';
export class LogoutCommand extends BaseCommand {
public static readonly description = 'Logout and remove persisted credentials';
public async run(): Promise<void> {
await this.sessionService.logout();
}
}

View File

@@ -0,0 +1,17 @@
import { BaseCommand } from './base-command';
export class ServerInfoCommand extends BaseCommand {
public async run() {
const api = await this.connect();
const versionInfo = await api.getServerVersion();
const mediaTypes = await api.getSupportedMediaTypes();
const statistics = await api.getAssetStatistics();
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(
`Statistics:\n Images: ${statistics.images}\n Videos: ${statistics.videos}\n Total: ${statistics.total}`,
);
}
}

View File

@@ -1,24 +0,0 @@
import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { BaseOptions, authenticate } from 'src/utils';
export const serverInfo = async (options: BaseOptions) => {
const { url } = await authenticate(options);
const [versionInfo, mediaTypes, stats, userInfo] = await Promise.all([
getServerVersion(),
getSupportedMediaTypes(),
getAssetStatistics({}),
getMyUserInfo(),
]);
console.log(`Server Info (via ${userInfo.email})`);
console.log(` Url: ${url}`);
console.log(` Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(` Formats:`);
console.log(` Images: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(` Videos: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(` Statistics:`);
console.log(` Images: ${stats.images}`);
console.log(` Videos: ${stats.videos}`);
console.log(` Total: ${stats.total}`);
};

View File

@@ -0,0 +1,274 @@
import byteSize from 'byte-size';
import cliProgress from 'cli-progress';
import { createHash } from 'node:crypto';
import fs, { createReadStream } from 'node:fs';
import { access, constants, stat, unlink } from 'node:fs/promises';
import os from 'node:os';
import { basename } from 'node:path';
import { ImmichApi } from 'src/services/api.service';
import { CrawlService } from '../services/crawl.service';
import { BaseCommand } from './base-command';
class Asset {
readonly path: string;
readonly deviceId!: string;
deviceAssetId?: string;
fileCreatedAt?: Date;
fileModifiedAt?: Date;
sidecarPath?: string;
fileSize!: number;
albumName?: string;
constructor(path: string) {
this.path = path;
}
async prepare() {
const stats = await stat(this.path);
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replaceAll(/\s+/g, '');
this.fileCreatedAt = stats.mtime;
this.fileModifiedAt = stats.mtime;
this.fileSize = stats.size;
this.albumName = this.extractAlbumName();
}
async getUploadFormData(): Promise<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: Blob | undefined = undefined;
try {
await access(sideCarPath, constants.R_OK);
sidecarData = new File([await fs.openAsBlob(sideCarPath)], basename(sideCarPath));
} catch {}
const data: any = {
assetData: new File([await fs.openAsBlob(this.path)], basename(this.path)),
deviceAssetId: this.deviceAssetId,
deviceId: 'CLI',
fileCreatedAt: this.fileCreatedAt,
fileModifiedAt: this.fileModifiedAt,
isFavorite: String(false),
};
const formData = new FormData();
for (const property in data) {
formData.append(property, data[property]);
}
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
return formData;
}
async delete(): Promise<void> {
return unlink(this.path);
}
public async hash(): Promise<string> {
const sha1 = (filePath: string) => {
const hash = createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = 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 | undefined {
return os.platform() === 'win32' ? this.path.split('\\').at(-2) : this.path.split('/').at(-2);
}
}
export class UploadOptionsDto {
recursive? = false;
exclusionPatterns?: string[] = [];
dryRun? = false;
skipHash? = false;
delete? = false;
album? = false;
albumName? = '';
includeHidden? = false;
}
export class UploadCommand extends BaseCommand {
uploadLength!: number;
public async run(paths: string[], options: UploadOptionsDto): Promise<void> {
const api = await this.connect();
const formatResponse = await api.getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
const inputFiles: string[] = [];
for (const pathArgument of paths) {
const fileStat = await fs.promises.lstat(pathArgument);
if (fileStat.isFile()) {
inputFiles.push(pathArgument);
}
}
const files: string[] = await crawlService.crawl({
pathsToCrawl: paths,
recursive: options.recursive,
exclusionPatterns: options.exclusionPatterns,
includeHidden: options.includeHidden,
});
files.push(...inputFiles);
if (files.length === 0) {
console.log('No assets found, exiting');
return;
}
const assetsToUpload = files.map((path) => new Asset(path));
const uploadProgress = new cliProgress.SingleBar(
{
format: '{bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}: {filename}',
},
cliProgress.Presets.shades_classic,
);
let totalSize = 0;
let sizeSoFar = 0;
let totalSizeUploaded = 0;
let uploadCounter = 0;
for (const asset of assetsToUpload) {
// Compute total size first
await asset.prepare();
totalSize += asset.fileSize;
if (options.albumName) {
asset.albumName = options.albumName;
}
}
const existingAlbums = await api.getAllAlbums();
uploadProgress.start(totalSize, 0);
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
try {
for (const asset of assetsToUpload) {
uploadProgress.update({
filename: asset.path,
});
let skipUpload = false;
let skipAsset = false;
let existingAssetId: string | undefined = undefined;
if (!options.skipHash) {
const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] };
const checkResponse = await api.checkBulkUpload(assetBulkUploadCheckDto);
skipUpload = checkResponse.results[0].action === 'reject';
const isDuplicate = checkResponse.results[0].reason === 'duplicate';
if (isDuplicate) {
existingAssetId = checkResponse.results[0].assetId;
}
skipAsset = skipUpload && !isDuplicate;
}
if (!skipAsset && !options.dryRun) {
if (!skipUpload) {
const formData = await asset.getUploadFormData();
const response = await this.uploadAsset(api, formData);
const json = await response.json();
existingAssetId = json.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 response = await api.createAlbum({ albumName: asset.albumName });
album = response;
existingAlbums.push(album);
}
if (existingAssetId) {
await api.addAssetsToAlbum(album.id, {
ids: [existingAssetId],
});
}
}
}
sizeSoFar += asset.fileSize;
uploadProgress.update(sizeSoFar, { value_formatted: byteSize(sizeSoFar) });
}
} finally {
uploadProgress.stop();
}
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
if (uploadCounter === 0) {
console.log('All assets were already uploaded, nothing to do.');
} 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`);
} else {
console.log('Deleting assets that have been uploaded...');
const deletionProgress = new cliProgress.SingleBar(cliProgress.Presets.shades_classic);
deletionProgress.start(files.length, 0);
for (const asset of assetsToUpload) {
if (!options.dryRun) {
await asset.delete();
}
deletionProgress.increment();
}
deletionProgress.stop();
console.log('Deletion complete');
}
}
}
private async uploadAsset(api: ImmichApi, data: FormData): Promise<Response> {
const url = api.instanceUrl + '/asset/upload';
const response = await fetch(url, {
method: 'post',
redirect: 'error',
headers: {
'x-api-key': api.apiKey,
},
body: data,
});
if (response.status !== 200 && response.status !== 201) {
throw new Error(await response.text());
}
return response;
}
}

View File

@@ -2,10 +2,11 @@
import { Command, Option } from 'commander';
import os from 'node:os';
import path from 'node:path';
import { upload } from 'src/commands/asset';
import { login, logout } from 'src/commands/auth';
import { serverInfo } from 'src/commands/server-info';
import { version } from '../package.json';
import { LoginCommand } from './commands/login.command';
import { LogoutCommand } from './commands/logout.command';
import { ServerInfoCommand } from './commands/server-info.command';
import { UploadCommand } from './commands/upload.command';
const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
@@ -14,37 +15,17 @@ const program = new Command()
.version(version)
.description('Command line interface for Immich')
.addOption(
new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
new Option('-d, --config-directory', 'Configuration directory where auth.yml will be stored')
.env('IMMICH_CONFIG_DIR')
.default(defaultConfigDirectory),
)
.addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL'))
.addOption(new Option('-k, --key [key]', 'Immich API key').env('IMMICH_API_KEY'));
program
.command('login')
.alias('login-key')
.description('Login using an API key')
.argument('url', 'Immich server URL')
.argument('key', 'Immich API key')
.action((url, key) => login(url, key, program.opts()));
program
.command('logout')
.description('Remove stored credentials')
.action(() => logout(program.opts()));
program
.command('server-info')
.description('Display server information')
.action(() => serverInfo(program.opts()));
);
program
.command('upload')
.description('Upload assets')
.usage('[paths...] [options]')
.usage('[options] [paths...]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default([]))
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS'))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
.addOption(
@@ -62,13 +43,34 @@ program
.env('IMMICH_DRY_RUN')
.default(false),
)
.addOption(
new Option('-c, --concurrency <number>', 'Number of assets to upload at the same time')
.env('IMMICH_UPLOAD_CONCURRENCY')
.default(4),
)
.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((paths, options) => upload(paths, program.opts(), options));
.action(async (paths, options) => {
options.exclusionPatterns = options.ignore;
await new UploadCommand(program.opts()).run(paths, options);
});
program
.command('server-info')
.description('Display server information')
.action(async () => {
await new ServerInfoCommand(program.opts()).run();
});
program
.command('login-key')
.description('Login using an API key')
.argument('[instanceUrl]')
.argument('[apiKey]')
.action(async (paths, options) => {
await new LoginCommand(program.opts()).run(paths, options);
});
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new LogoutCommand(program.opts()).run();
});
program.parse(process.argv);

View File

@@ -0,0 +1,106 @@
import {
ApiKeyCreateDto,
AssetBulkUploadCheckDto,
BulkIdsDto,
CreateAlbumDto,
CreateAssetDto,
LoginCredentialDto,
SignUpDto,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
createApiKey,
getAllAlbums,
getAllAssets,
getAssetStatistics,
getMyUserInfo,
getServerVersion,
getSupportedMediaTypes,
login,
pingServer,
signUpAdmin,
uploadFile,
} from '@immich/sdk';
/**
* Wraps the underlying API to abstract away the options and make API calls mockable for testing.
*/
export class ImmichApi {
private readonly options;
constructor(
public instanceUrl: string,
public apiKey: string,
) {
this.options = {
baseUrl: instanceUrl,
headers: {
'x-api-key': apiKey,
},
};
}
setApiKey(apiKey: string) {
this.apiKey = apiKey;
if (!this.options.headers) {
throw new Error('missing headers');
}
this.options.headers['x-api-key'] = apiKey;
}
addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto) {
return addAssetsToAlbum({ id, bulkIdsDto }, this.options);
}
checkBulkUpload(assetBulkUploadCheckDto: AssetBulkUploadCheckDto) {
return checkBulkUpload({ assetBulkUploadCheckDto }, this.options);
}
createAlbum(createAlbumDto: CreateAlbumDto) {
return createAlbum({ createAlbumDto }, this.options);
}
createApiKey(apiKeyCreateDto: ApiKeyCreateDto, options: { headers: { Authorization: string } }) {
return createApiKey({ apiKeyCreateDto }, { ...this.options, ...options });
}
getAllAlbums() {
return getAllAlbums({}, this.options);
}
getAllAssets() {
return getAllAssets({}, this.options);
}
getAssetStatistics() {
return getAssetStatistics({}, this.options);
}
getMyUserInfo() {
return getMyUserInfo(this.options);
}
getServerVersion() {
return getServerVersion(this.options);
}
getSupportedMediaTypes() {
return getSupportedMediaTypes(this.options);
}
login(loginCredentialDto: LoginCredentialDto) {
return login({ loginCredentialDto }, this.options);
}
pingServer() {
return pingServer(this.options);
}
signUpAdmin(signUpDto: SignUpDto) {
return signUpAdmin({ signUpDto }, this.options);
}
uploadFile(createAssetDto: CreateAssetDto) {
return uploadFile({ createAssetDto }, this.options);
}
}

View File

@@ -1,31 +1,14 @@
import mockfs from 'mock-fs';
import { CrawlOptions, crawl } from 'src/utils';
import { CrawlOptions, CrawlService } from './crawl.service';
interface Test {
test: string;
options: Omit<CrawlOptions, 'extensions'>;
options: CrawlOptions;
files: Record<string, boolean>;
}
const cwd = process.cwd();
const extensions = [
'.jpg',
'.jpeg',
'.png',
'.heif',
'.heic',
'.tif',
'.nef',
'.webp',
'.tiff',
'.dng',
'.gif',
'.mov',
'.mp4',
'.webm',
];
const tests: Test[] = [
{
test: 'should return empty when crawling an empty path list',
@@ -268,7 +251,12 @@ const tests: Test[] = [
},
];
describe('crawl', () => {
describe(CrawlService.name, () => {
const sut = new CrawlService(
['.jpg', '.jpeg', '.png', '.heif', '.heic', '.tif', '.nef', '.webp', '.tiff', '.dng', '.gif'],
['.mov', '.mp4', '.webm'],
);
afterEach(() => {
mockfs.restore();
});
@@ -278,7 +266,7 @@ describe('crawl', () => {
it(test, async () => {
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
const actual = await crawl({ ...options, extensions });
const actual = await sut.crawl(options);
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => file);

View File

@@ -0,0 +1,70 @@
import { glob } from 'glob';
import * as fs from 'node:fs';
export class CrawlOptions {
pathsToCrawl!: string[];
recursive? = false;
includeHidden? = false;
exclusionPatterns?: string[];
}
export class CrawlService {
private readonly extensions!: string[];
constructor(image: string[], video: string[]) {
this.extensions = [...image, ...video].map((extension) => extension.replace('.', ''));
}
async crawl(options: CrawlOptions): Promise<string[]> {
const { recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
if (!pathsToCrawl) {
return [];
}
const patterns: 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;
}
}
}
let searchPattern: string;
if (patterns.length === 1) {
searchPattern = patterns[0];
} else if (patterns.length === 0) {
return crawledFiles;
} else {
searchPattern = '{' + patterns.join(',') + '}';
}
if (recursive) {
searchPattern = searchPattern + '/**/';
}
searchPattern = `${searchPattern}/*.{${this.extensions.join(',')}}`;
const globbedFiles = await glob(searchPattern, {
absolute: true,
nocase: true,
nodir: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
return [...crawledFiles, ...globbedFiles].sort();
}
}

View File

@@ -0,0 +1,111 @@
import fs from 'node:fs';
import yaml from 'yaml';
import {
TEST_AUTH_FILE,
TEST_CONFIG_DIR,
TEST_IMMICH_API_KEY,
TEST_IMMICH_INSTANCE_URL,
createTestAuthFile,
deleteAuthFile,
readTestAuthFile,
spyOnConsole,
} from '../../test/cli-test-utils';
import { SessionService } from './session.service';
const mocks = vi.hoisted(() => {
return {
getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })),
pingServer: vi.fn(() => Promise.resolve({ res: 'pong' })),
};
});
vi.mock('./api.service', async (importOriginal) => {
const module = await importOriginal<typeof import('./api.service')>();
// @ts-expect-error this is only a partial implementation of the return value
module.ImmichApi.prototype.getMyUserInfo = mocks.getMyUserInfo;
module.ImmichApi.prototype.pingServer = mocks.pingServer;
return module;
});
describe('SessionService', () => {
let sessionService: SessionService;
beforeEach(() => {
deleteAuthFile();
sessionService = new SessionService(TEST_CONFIG_DIR);
});
afterEach(() => {
deleteAuthFile();
});
it('should connect to immich', async () => {
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.connect();
expect(mocks.pingServer).toHaveBeenCalledTimes(1);
});
it('should error if no auth file exists', async () => {
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,
}),
);
await sessionService.connect().catch((error) => {
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
});
});
it('should error if auth file is missing api key', async () => {
await createTestAuthFile(
JSON.stringify({
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await expect(sessionService.connect()).rejects.toThrow(`API key missing in auth config file ${TEST_AUTH_FILE}`);
});
it('should create auth file when logged in', async () => {
await sessionService.login(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
const data: string = await readTestAuthFile();
const authConfig = yaml.parse(data);
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
});
it('should delete auth file when logging out', async () => {
const consoleSpy = spyOnConsole();
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.logout();
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
expect(error.message).toContain('ENOENT');
});
expect(consoleSpy.mock.calls).toEqual([
['Logging out...'],
[`Removed auth file ${TEST_AUTH_FILE}`],
['Successfully logged out'],
]);
});
});

View File

@@ -0,0 +1,101 @@
import { existsSync } from 'node:fs';
import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import yaml from 'yaml';
import { ImmichApi } from './api.service';
class LoginError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class SessionService {
readonly configDirectory!: string;
readonly authPath!: string;
constructor(configDirectory: string) {
this.configDirectory = configDirectory;
this.authPath = path.join(configDirectory, '/auth.yml');
}
async connect(): Promise<ImmichApi> {
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
let apiKey = process.env.IMMICH_API_KEY;
if (!instanceUrl || !apiKey) {
await access(this.authPath, constants.F_OK).catch((error) => {
if (error.code === 'ENOENT') {
throw new LoginError('No auth file exist. Please login first');
}
});
const data: string = await 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}`);
}
if (!apiKey) {
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
}
}
const api = new ImmichApi(instanceUrl, apiKey);
const pingResponse = await api.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${instanceUrl}: ${error.message}`, error);
});
if (pingResponse.res !== 'pong') {
throw new Error(`Could not parse response. Is Immich listening on ${instanceUrl}?`);
}
return api;
}
async login(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
console.log('Logging in...');
const api = new ImmichApi(instanceUrl, apiKey);
// Check if server and api key are valid
const userInfo = await api.getMyUserInfo().catch((error) => {
throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
});
console.log(`Logged in as ${userInfo.email}`);
if (!existsSync(this.configDirectory)) {
// Create config folder if it doesn't exist
const created = await mkdir(this.configDirectory, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${this.configDirectory}`);
}
}
await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }), { mode: 0o600 });
console.log('Wrote auth info to ' + this.authPath);
return api;
}
async logout(): Promise<void> {
console.log('Logging out...');
if (existsSync(this.authPath)) {
await unlink(this.authPath);
console.log('Removed auth file ' + this.authPath);
}
console.log('Successfully logged out');
}
}

View File

@@ -1,172 +0,0 @@
import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk';
import { glob } from 'glob';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { readFile, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import yaml from 'yaml';
export interface BaseOptions {
configDirectory: string;
key?: string;
url?: string;
}
export type AuthDto = { url: string; key: string };
type OldAuthDto = { instanceUrl: string; apiKey: string };
export const authenticate = async (options: BaseOptions): Promise<AuthDto> => {
const { configDirectory: configDir, url, key } = options;
// provided in command
if (url && key) {
return connect(url, key);
}
// fallback to auth file
const config = await readAuthFile(configDir);
const auth = await connect(config.url, config.key);
if (auth.url !== config.url) {
await writeAuthFile(configDir, auth);
}
return auth;
};
export const connect = async (url: string, key: string) => {
const wellKnownUrl = new URL('.well-known/immich', url);
try {
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
const endpoint = new URL(wellKnown.api.endpoint, url).toString();
if (endpoint !== url) {
console.debug(`Discovered API at ${endpoint}`);
}
url = endpoint;
} catch {
// noop
}
defaults.baseUrl = url;
defaults.headers = { 'x-api-key': key };
const [error] = await withError(getMyUserInfo());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
process.exit(1);
}
return { url, key };
};
export const logError = (error: unknown, message: string) => {
if (isHttpError(error)) {
console.error(`${message}: ${error.status}`);
console.error(JSON.stringify(error.data, undefined, 2));
} else {
console.error(`${message} - ${error}`);
}
};
export const getAuthFilePath = (dir: string) => join(dir, 'auth.yml');
export const readAuthFile = async (dir: string) => {
try {
const data = await readFile(getAuthFilePath(dir));
// TODO add class-transform/validation
const auth = yaml.parse(data.toString()) as AuthDto | OldAuthDto;
const { instanceUrl, apiKey } = auth as OldAuthDto;
if (instanceUrl && apiKey) {
return { url: instanceUrl, key: apiKey };
}
return auth as AuthDto;
} catch (error: Error | any) {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
console.log('No auth file exists. Please login first.');
process.exit(1);
}
throw error;
}
};
export const writeAuthFile = async (dir: string, auth: AuthDto) =>
writeFile(getAuthFilePath(dir), yaml.stringify(auth), { mode: 0o600 });
export const withError = async <T>(promise: Promise<T>): Promise<[Error, undefined] | [undefined, T]> => {
try {
const result = await promise;
return [undefined, result];
} catch (error: Error | any) {
return [error, undefined];
}
};
export interface CrawlOptions {
pathsToCrawl: string[];
recursive?: boolean;
includeHidden?: boolean;
exclusionPatterns?: string[];
extensions: string[];
}
export const crawl = async (options: CrawlOptions): Promise<string[]> => {
const { extensions: extensionsWithPeriod, recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
const extensions = extensionsWithPeriod.map((extension) => extension.replace('.', ''));
if (pathsToCrawl.length === 0) {
return [];
}
const patterns: string[] = [];
const crawledFiles: string[] = [];
for await (const currentPath of pathsToCrawl) {
try {
const stats = await stat(currentPath);
if (stats.isFile() || stats.isSymbolicLink()) {
crawledFiles.push(currentPath);
} else {
patterns.push(currentPath);
}
} catch (error: any) {
if (error.code === 'ENOENT') {
patterns.push(currentPath);
} else {
throw error;
}
}
}
let searchPattern: string;
if (patterns.length === 1) {
searchPattern = patterns[0];
} else if (patterns.length === 0) {
return crawledFiles;
} else {
searchPattern = '{' + patterns.join(',') + '}';
}
if (recursive) {
searchPattern = searchPattern + '/**/';
}
searchPattern = `${searchPattern}/*.{${extensions.join(',')}}`;
const globbedFiles = await glob(searchPattern, {
absolute: true,
nocase: true,
nodir: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
return [...crawledFiles, ...globbedFiles].sort();
};
export const sha1 = (filepath: string) => {
const hash = createHash('sha1');
return new Promise<string>((resolve, reject) => {
const rs = createReadStream(filepath);
rs.on('error', reject);
rs.on('data', (chunk) => hash.update(chunk));
rs.on('end', () => resolve(hash.digest('hex')));
});
};

View File

@@ -0,0 +1,52 @@
import fs from 'node:fs';
import path from 'node:path';
import { ImmichApi } from 'src/services/api.service';
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 = { configDirectory: TEST_CONFIG_DIR };
export const setup = async () => {
const api = new ImmichApi(process.env.IMMICH_INSTANCE_URL as string, '');
await api.signUpAdmin({ email: 'cli@immich.app', password: 'password', name: 'Administrator' });
const admin = await api.login({ email: 'cli@immich.app', password: 'password' });
const apiKey = await api.createApiKey(
{ name: 'CLI Test' },
{ headers: { Authorization: `Bearer ${admin.accessToken}` } },
);
api.setApiKey(apiKey.secret);
return api;
};
export const spyOnConsole = () => vi.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

@@ -0,0 +1,65 @@
import { restoreTempFolder, testApp } from '@test-utils';
import { readFile, stat } from 'node:fs/promises';
import { CLI_BASE_OPTIONS, TEST_AUTH_FILE, deleteAuthFile, setup, spyOnConsole } from 'test/cli-test-utils';
import yaml from 'yaml';
import { LoginCommand } from '../../src/commands/login.command';
describe(`login-key (e2e)`, () => {
let apiKey: string;
let instanceUrl: string;
spyOnConsole();
beforeAll(async () => {
await testApp.create();
if (process.env.IMMICH_INSTANCE_URL) {
instanceUrl = process.env.IMMICH_INSTANCE_URL;
} else {
throw new Error('IMMICH_INSTANCE_URL environment variable not set');
}
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
deleteAuthFile();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
const api = await setup();
apiKey = api.apiKey;
deleteAuthFile();
});
it('should error when providing an invalid API key', async () => {
await expect(new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, 'invalid')).rejects.toThrow(
`Failed to connect to server ${instanceUrl}: Error: 401`,
);
});
it('should log in when providing the correct API key', async () => {
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
});
it('should create an auth file when logging in', async () => {
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
const data: string = await readFile(TEST_AUTH_FILE, 'utf8');
const parsedConfig = yaml.parse(data);
expect(parsedConfig).toEqual(expect.objectContaining({ instanceUrl, apiKey }));
});
it('should create an auth file with chmod 600', async () => {
await new LoginCommand(CLI_BASE_OPTIONS).run(instanceUrl, apiKey);
const stats = await stat(TEST_AUTH_FILE);
const mode = (stats.mode & 0o777).toString(8);
expect(mode).toEqual('600');
});
});

View File

@@ -0,0 +1,34 @@
import { restoreTempFolder, testApp } from '@test-utils';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { ServerInfoCommand } from '../../src/commands/server-info.command';
describe(`server-info (e2e)`, () => {
const consoleSpy = spyOnConsole();
beforeAll(async () => {
await testApp.create();
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
const api = await setup();
process.env.IMMICH_API_KEY = api.apiKey;
});
it('should show server version', async () => {
await new ServerInfoCommand(CLI_BASE_OPTIONS).run();
expect(consoleSpy.mock.calls).toEqual([
[expect.stringMatching(new RegExp('Server Version: \\d+.\\d+.\\d+'))],
[expect.stringMatching('Image Types: .*')],
[expect.stringMatching('Video Types: .*')],
['Statistics:\n Images: 0\n Videos: 0\n Total: 0'],
]);
});
});

42
cli/test/e2e/setup.ts Normal file
View File

@@ -0,0 +1,42 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { access } from 'node:fs/promises';
import path from 'node:path';
export const directoryExists = (directory: string) =>
access(directory)
.then(() => true)
.catch(() => false);
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;
}
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.2.0')
.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

@@ -0,0 +1,79 @@
import { IMMICH_TEST_ASSET_PATH, restoreTempFolder, testApp } from '@test-utils';
import { ImmichApi } from 'src/services/api.service';
import { CLI_BASE_OPTIONS, setup, spyOnConsole } from 'test/cli-test-utils';
import { UploadCommand } from '../../src/commands/upload.command';
describe(`upload (e2e)`, () => {
let api: ImmichApi;
spyOnConsole();
beforeAll(async () => {
await testApp.create();
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
api = await setup();
process.env.IMMICH_API_KEY = api.apiKey;
});
it('should upload a folder recursively', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const assets = await api.getAllAssets();
expect(assets.length).toBeGreaterThan(4);
});
it('should not create a new album', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(0);
});
it('should create album from folder name', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
album: true,
});
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const natureAlbum = albums[0];
expect(natureAlbum.albumName).toEqual('nature');
});
it('should add existing assets to album', async () => {
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
});
// upload again, but this time add to album
await new UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
album: true,
});
const albums = await api.getAllAlbums();
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 UploadCommand(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
recursive: true,
albumName: 'testAlbum',
});
const albums = await api.getAllAlbums();
expect(albums.length).toEqual(1);
const testAlbum = albums[0];
expect(testAlbum.albumName).toEqual('testAlbum');
});
});

View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@test-utils': new URL('../../../server/dist/test-utils/utils.js', import.meta.url).pathname,
},
},
test: {
include: ['**/*.e2e-spec.ts'],
globals: true,
globalSetup: 'test/e2e/setup.ts',
pool: 'forks',
poolOptions: {
forks: {
maxForks: 1,
minForks: 1,
},
},
testTimeout: 10_000,
},
});

3
cli/test/global-setup.js Normal file
View File

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

3
cli/testSetup.js Normal file
View File

@@ -0,0 +1,3 @@
// add all jest-extended matchers
import * as matchers from 'jest-extended';
expect.extend(matchers);

View File

@@ -15,7 +15,19 @@
"incremental": true,
"skipLibCheck": true,
"esModuleInterop": true,
"rootDirs": ["src", "../server/src"],
"baseUrl": "./",
"paths": {
"@test": ["../server/test"],
"@test/*": ["../server/test/*"],
"@test-utils": ["../server/src/test-utils/utils"],
"@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/*"]
},
"types": ["vitest/globals"]
},
"exclude": ["dist", "node_modules"]

View File

@@ -1,5 +1,4 @@
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
build: {
@@ -15,5 +14,4 @@ export default defineConfig({
// bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/,
},
plugins: [tsconfigPaths()],
});

BIN
design/admin-interface.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
design/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
design/backup-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

BIN
design/bitcoin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
design/cardano.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
design/feature-panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
design/home-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Router_Medium_x5F_Black_00000159464448132936669960000002337362428709113490_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 266.25"
style="enable-background:new 0 0 792 266.25;" xml:space="preserve">
<style type="text/css">
.st0{fill:#ACCBFA;}
.st1{fill:#FA2921;}
.st2{fill:#ED79B5;}
.st3{fill:#FFB400;}
.st4{fill:#1E83F7;}
.st5{fill:#18C249;}
</style>
<g>
<path class="st0" d="M268.73,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
C257.04,68.36,262.39,63.18,268.73,63.18z M258.88,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
c5.84,0,10.52,4.67,10.52,10.68c0,2.84-0.83,7.68-0.83,10.68v38.73c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.52,10.68
c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
<path class="st0" d="M394.28,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
c0-2.84,0.83-7.68,0.83-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C399.12,182.55,394.28,177.88,394.28,171.87z"/>
<path class="st0" d="M528.5,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
c0-2.84,0.84-7.68,0.84-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C533.35,182.55,528.5,177.88,528.5,171.87z"/>
<path class="st0" d="M576.92,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
C565.23,68.36,570.57,63.18,576.92,63.18z M567.07,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
s10.52,4.67,10.52,10.68c0,2.84-0.84,7.68-0.84,10.68v38.73c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.52,10.68
s-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
<path class="st0" d="M601.79,141.31c0-23.54,14.69-42.57,39.07-42.57c12.86,0,24.71,5.84,30.05,14.53c2,3.17,2.34,5.01,2.34,6.51
c0,5.18-4.01,9.52-9.85,9.52c-3.84,0-7.34-2.17-8.85-6.01c-2.34-5.18-6.85-8.18-13.69-8.18c-12.86,0-20.03,11.52-20.03,26.04
c0,14.69,7.51,26.04,20.53,26.04c7.01,0,12.02-2.5,14.36-7.68c1.67-3.51,4.84-6.51,9.18-6.51c6.01,0,9.68,4.17,9.68,9.35
c0,2.5-1,5.51-3.17,8.35c-5.51,7.35-15.86,13.19-30.05,13.19C616.32,183.89,601.79,165.19,601.79,141.31z"/>
<path class="st0" d="M737.69,171.87c0-2.84,0.67-7.68,0.67-10.68v-28.55c0-10.18-5.68-17.2-15.36-17.2
c-6.68,0-12.35,3.17-16.03,8.35v37.4c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.67,10.68-10.52,10.68s-10.52-4.67-10.52-10.68
c0-2.84,0.84-7.68,0.84-10.68v-80.8c0-3.01-0.84-7.85-0.84-10.68c0-6.01,4.84-10.68,10.52-10.68c5.84,0,10.52,4.67,10.52,10.68
c0,2.84-0.67,7.68-0.67,10.68v27.21c5.01-5.51,12.19-8.85,21.37-8.85c17.2,0,29.55,12.86,29.55,31.22v31.22
c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68C742.36,182.55,737.69,177.88,737.69,171.87z"/>
</g>
<g>
<path class="st1" d="M114.82,96.21c11.92,10.55,21.52,21.86,27.7,32.52c10.62-18.99,17.71-41.55,17.8-55.92c0-0.1,0-0.19,0-0.28
c0-21.26-21.21-29.54-39.48-29.54s-39.48,8.28-39.48,29.54c0,0.29,0,0.68,0,1.15C91.54,78.2,103.61,86.29,114.82,96.21z"/>
<path class="st2" d="M49.8,154.19c7.45-8.29,18.88-17.27,31.77-24.86c13.72-8.07,27.44-13.71,39.49-16.3
c-14.78-15.96-34.04-29.68-47.68-34.21c-0.1-0.03-0.18-0.06-0.27-0.09c-20.22-6.57-34.65,11.05-40.3,28.42s-4.33,40.11,15.89,46.68
C48.99,153.93,49.35,154.05,49.8,154.19z"/>
<path class="st3" d="M209.07,106.86c-5.65-17.38-20.07-34.99-40.3-28.42c-0.28,0.09-0.65,0.21-1.09,0.35
c-1.16,11.08-5.12,25.07-11.09,38.79c-6.35,14.6-14.14,27.23-22.36,36.39c21.34,4.23,44.99,4,58.68-0.35
c0.1-0.03,0.19-0.06,0.27-0.09C213.4,146.97,214.71,124.24,209.07,106.86z"/>
<path class="st4" d="M102.8,171.18c-3.44-15.54-4.56-30.34-3.3-42.59c-19.75,9.12-38.75,23.2-47.27,34.78
c-0.06,0.08-0.11,0.16-0.16,0.23c-12.5,17.2-0.2,36.37,14.58,47.11s36.81,16.51,49.31-0.69c0.17-0.24,0.4-0.55,0.68-0.93
C111.05,199.44,106.04,185.79,102.8,171.18z"/>
<path class="st5" d="M189.48,162.49c-10.9,2.33-25.42,2.88-40.32,1.44c-15.84-1.53-30.26-5.03-41.52-10.02
c2.57,21.6,10.09,44.02,18.47,55.7c0.06,0.08,0.11,0.16,0.16,0.23c12.5,17.2,34.52,11.43,49.31,0.69
c14.78-10.74,27.08-29.9,14.58-47.11C189.99,163.18,189.76,162.86,189.48,162.49z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -1,62 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Router_Medium_x5F_White" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px" viewBox="0 0 792 266.25" style="enable-background:new 0 0 792 266.25;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4251B0;}
.st1{fill:#FA2921;}
.st2{fill:#ED79B5;}
.st3{fill:#FFB400;}
.st4{fill:#1E83F7;}
.st5{fill:#18C249;}
</style>
<g>
<path class="st0" d="M268.73,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
C257.04,68.36,262.39,63.18,268.73,63.18z M258.88,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
c5.84,0,10.52,4.67,10.52,10.68c0,2.84-0.83,7.68-0.83,10.68v38.73c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.52,10.68
c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
<path class="st0" d="M394.28,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
c0-2.84,0.83-7.68,0.83-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C399.12,182.55,394.28,177.88,394.28,171.87z"/>
<path class="st0" d="M528.5,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
c0-2.84,0.84-7.68,0.84-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C533.35,182.55,528.5,177.88,528.5,171.87z"/>
<path class="st0" d="M576.92,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
C565.23,68.36,570.57,63.18,576.92,63.18z M567.07,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
s10.52,4.67,10.52,10.68c0,2.84-0.84,7.68-0.84,10.68v38.73c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.52,10.68
s-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
<path class="st0" d="M601.79,141.31c0-23.54,14.69-42.57,39.07-42.57c12.86,0,24.71,5.84,30.05,14.53c2,3.17,2.34,5.01,2.34,6.51
c0,5.18-4.01,9.52-9.85,9.52c-3.84,0-7.34-2.17-8.85-6.01c-2.34-5.18-6.85-8.18-13.69-8.18c-12.86,0-20.03,11.52-20.03,26.04
c0,14.69,7.51,26.04,20.53,26.04c7.01,0,12.02-2.5,14.36-7.68c1.67-3.51,4.84-6.51,9.18-6.51c6.01,0,9.68,4.17,9.68,9.35
c0,2.5-1,5.51-3.17,8.35c-5.51,7.35-15.86,13.19-30.05,13.19C616.32,183.89,601.79,165.19,601.79,141.31z"/>
<path class="st0" d="M737.69,171.87c0-2.84,0.67-7.68,0.67-10.68v-28.55c0-10.18-5.68-17.2-15.36-17.2
c-6.68,0-12.35,3.17-16.03,8.35v37.4c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.67,10.68-10.52,10.68s-10.52-4.67-10.52-10.68
c0-2.84,0.84-7.68,0.84-10.68v-80.8c0-3.01-0.84-7.85-0.84-10.68c0-6.01,4.84-10.68,10.52-10.68c5.84,0,10.52,4.67,10.52,10.68
c0,2.84-0.67,7.68-0.67,10.68v27.21c5.01-5.51,12.19-8.85,21.37-8.85c17.2,0,29.55,12.86,29.55,31.22v31.22
c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68C742.36,182.55,737.69,177.88,737.69,171.87z"/>
</g>
<g>
<path class="st1" d="M114.82,96.21c11.92,10.55,21.52,21.86,27.7,32.52c10.62-18.99,17.71-41.55,17.8-55.92c0-0.1,0-0.19,0-0.28
c0-21.26-21.21-29.54-39.48-29.54s-39.48,8.28-39.48,29.54c0,0.29,0,0.68,0,1.15C91.54,78.2,103.61,86.29,114.82,96.21z"/>
<path class="st2" d="M49.8,154.19c7.45-8.29,18.88-17.27,31.77-24.86c13.72-8.07,27.44-13.71,39.49-16.3
c-14.78-15.96-34.04-29.68-47.68-34.21c-0.1-0.03-0.18-0.06-0.27-0.09c-20.22-6.57-34.65,11.05-40.3,28.42s-4.33,40.11,15.89,46.68
C48.99,153.93,49.35,154.05,49.8,154.19z"/>
<path class="st3" d="M209.07,106.86c-5.65-17.38-20.07-34.99-40.3-28.42c-0.28,0.09-0.65,0.21-1.09,0.35
c-1.16,11.08-5.12,25.07-11.09,38.79c-6.35,14.6-14.14,27.23-22.36,36.39c21.34,4.23,44.99,4,58.68-0.35
c0.1-0.03,0.19-0.06,0.27-0.09C213.4,146.97,214.71,124.24,209.07,106.86z"/>
<path class="st4" d="M102.8,171.18c-3.44-15.54-4.56-30.34-3.3-42.59c-19.75,9.12-38.75,23.2-47.27,34.78
c-0.06,0.08-0.11,0.16-0.16,0.23c-12.5,17.2-0.2,36.37,14.58,47.11s36.81,16.51,49.31-0.69c0.17-0.24,0.4-0.55,0.68-0.93
C111.05,199.44,106.04,185.79,102.8,171.18z"/>
<path class="st5" d="M189.48,162.49c-10.9,2.33-25.42,2.88-40.32,1.44c-15.84-1.53-30.26-5.03-41.52-10.02
c2.57,21.6,10.09,44.02,18.47,55.7c0.06,0.08,0.11,0.16,0.16,0.23c12.5,17.2,34.52,11.43,49.31,0.69
c14.78-10.74,27.08-29.9,14.58-47.11C189.99,163.18,189.76,162.86,189.48,162.49z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Router_Medium_x5F_Black_00000037681990313894948460000012967653829507626171_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792"
style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#ACCBFA;}
.st1{fill:#FA2921;}
.st2{fill:#ED79B5;}
.st3{fill:#FFB400;}
.st4{fill:#1E83F7;}
.st5{fill:#18C249;}
</style>
<g>
<path class="st0" d="M110.16,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C95.71,543.8,102.32,537.4,110.16,537.4z M97.98,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M265.44,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.81,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.69-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.81,0,10.74,4.54,11.98,10.53
c6.19-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.73-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C271.43,685.04,265.44,679.26,265.44,671.82z"/>
<path class="st0" d="M431.45,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.82,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.68-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.82,0,10.74,4.54,11.98,10.53
c6.2-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.72-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C437.44,685.04,431.45,679.26,431.45,671.82z"/>
<path class="st0" d="M491.33,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C476.87,543.8,483.48,537.4,491.33,537.4z M479.15,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M522.09,634.04c0-29.11,18.17-52.65,48.32-52.65c15.9,0,30.56,7.23,37.17,17.97c2.48,3.92,2.89,6.19,2.89,8.05
c0,6.4-4.96,11.77-12.18,11.77c-4.75,0-9.08-2.68-10.94-7.43c-2.89-6.4-8.47-10.12-16.93-10.12c-15.9,0-24.78,14.25-24.78,32.21
c0,18.17,9.29,32.21,25.4,32.21c8.67,0,14.87-3.1,17.76-9.5c2.06-4.34,5.99-8.05,11.36-8.05c7.43,0,11.98,5.16,11.98,11.56
c0,3.1-1.24,6.81-3.92,10.32c-6.82,9.09-19.62,16.31-37.17,16.31C540.06,686.69,522.09,663.56,522.09,634.04z"/>
<path class="st0" d="M690.17,671.82c0-3.51,0.83-9.5,0.83-13.22v-35.3c0-12.6-7.02-21.27-19-21.27c-8.26,0-15.28,3.92-19.82,10.32
v46.25c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.78,13.22-13.01,13.22s-13.01-5.78-13.01-13.22c0-3.51,1.03-9.5,1.03-13.22v-99.94
c0-3.72-1.03-9.71-1.03-13.22c0-7.43,5.99-13.22,13.01-13.22c7.23,0,13.01,5.78,13.01,13.22c0,3.51-0.83,9.5-0.83,13.22v33.66
c6.2-6.81,15.07-10.94,26.43-10.94c21.27,0,36.55,15.9,36.55,38.61v38.61c0,3.72,1.03,9.71,1.03,13.22
c0,7.43-5.99,13.22-13.01,13.22C695.95,685.04,690.17,679.26,690.17,671.82z"/>
</g>
<g>
<path class="st1" d="M376.76,216.42c28.32,25.07,51.15,51.95,65.83,77.27c25.23-45.12,42.08-98.73,42.3-132.88
c0-0.24,0-0.46,0-0.66c0-50.53-50.41-70.2-93.82-70.2s-93.82,19.66-93.82,70.2c0,0.69,0,1.62,0,2.73
C321.44,173.62,350.14,192.84,376.76,216.42z"/>
<path class="st2" d="M222.27,354.21c17.7-19.69,44.85-41.04,75.5-59.08c32.6-19.19,65.21-32.59,93.83-38.73
c-35.11-37.94-80.89-70.53-113.31-81.29c-0.23-0.07-0.44-0.14-0.63-0.21c-48.06-15.61-82.34,26.25-95.75,67.54
c-13.42,41.29-10.29,95.31,37.77,110.92C220.33,353.58,221.21,353.86,222.27,354.21z"/>
<path class="st3" d="M600.73,241.74c-13.42-41.29-47.69-83.15-95.75-67.54c-0.66,0.21-1.54,0.5-2.6,0.84
c-2.75,26.34-12.16,59.57-26.36,92.17c-15.09,34.68-33.6,64.69-53.14,86.48c50.7,10.05,106.9,9.52,139.45-0.83
c0.23-0.07,0.44-0.14,0.63-0.21C611.02,337.05,614.15,283.03,600.73,241.74z"/>
<path class="st4" d="M348.22,394.58c-8.17-36.93-10.84-72.09-7.84-101.2c-46.93,21.67-92.08,55.14-112.33,82.64
c-0.14,0.19-0.27,0.37-0.39,0.54c-29.7,40.88-0.48,86.42,34.64,111.94s87.46,39.24,117.16-1.64c0.41-0.56,0.95-1.31,1.6-2.21
C367.81,461.72,355.9,429.3,348.22,394.58z"/>
<path class="st5" d="M554.19,373.91c-25.9,5.53-60.41,6.84-95.81,3.42c-37.65-3.64-71.91-11.96-98.67-23.82
c6.11,51.33,23.99,104.61,43.89,132.37c0.14,0.19,0.27,0.37,0.39,0.54c29.7,40.88,82.04,27.16,117.16,1.64S585.5,417,555.8,376.12
C555.39,375.56,554.85,374.81,554.19,373.91z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Router_Medium_x5F_White_00000062189486027058041470000012691761407447023025_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792"
style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4251B0;}
.st1{fill:#FA2921;}
.st2{fill:#ED79B5;}
.st3{fill:#FFB400;}
.st4{fill:#1E83F7;}
.st5{fill:#18C249;}
</style>
<g>
<path class="st0" d="M110.16,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C95.71,543.8,102.32,537.4,110.16,537.4z M97.98,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M265.44,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.81,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.69-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.81,0,10.74,4.54,11.98,10.53
c6.19-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.73-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C271.43,685.04,265.44,679.26,265.44,671.82z"/>
<path class="st0" d="M431.45,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.82,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.68-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.82,0,10.74,4.54,11.98,10.53
c6.2-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.72-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C437.44,685.04,431.45,679.26,431.45,671.82z"/>
<path class="st0" d="M491.33,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C476.87,543.8,483.48,537.4,491.33,537.4z M479.15,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M522.09,634.04c0-29.11,18.17-52.65,48.32-52.65c15.9,0,30.56,7.23,37.17,17.97c2.48,3.92,2.89,6.19,2.89,8.05
c0,6.4-4.96,11.77-12.18,11.77c-4.75,0-9.08-2.68-10.94-7.43c-2.89-6.4-8.47-10.12-16.93-10.12c-15.9,0-24.78,14.25-24.78,32.21
c0,18.17,9.29,32.21,25.4,32.21c8.67,0,14.87-3.1,17.76-9.5c2.06-4.34,5.99-8.05,11.36-8.05c7.43,0,11.98,5.16,11.98,11.56
c0,3.1-1.24,6.81-3.92,10.32c-6.82,9.09-19.62,16.31-37.17,16.31C540.06,686.69,522.09,663.56,522.09,634.04z"/>
<path class="st0" d="M690.17,671.82c0-3.51,0.83-9.5,0.83-13.22v-35.3c0-12.6-7.02-21.27-19-21.27c-8.26,0-15.28,3.92-19.82,10.32
v46.25c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.78,13.22-13.01,13.22s-13.01-5.78-13.01-13.22c0-3.51,1.03-9.5,1.03-13.22v-99.94
c0-3.72-1.03-9.71-1.03-13.22c0-7.43,5.99-13.22,13.01-13.22c7.23,0,13.01,5.78,13.01,13.22c0,3.51-0.83,9.5-0.83,13.22v33.66
c6.2-6.81,15.07-10.94,26.43-10.94c21.27,0,36.55,15.9,36.55,38.61v38.61c0,3.72,1.03,9.71,1.03,13.22
c0,7.43-5.99,13.22-13.01,13.22C695.95,685.04,690.17,679.26,690.17,671.82z"/>
</g>
<g>
<path class="st1" d="M376.76,216.42c28.32,25.07,51.15,51.95,65.83,77.27c25.23-45.12,42.08-98.73,42.3-132.88
c0-0.24,0-0.46,0-0.66c0-50.53-50.41-70.2-93.82-70.2s-93.82,19.66-93.82,70.2c0,0.69,0,1.62,0,2.73
C321.44,173.62,350.14,192.84,376.76,216.42z"/>
<path class="st2" d="M222.27,354.21c17.7-19.69,44.85-41.04,75.5-59.08c32.6-19.19,65.21-32.59,93.83-38.73
c-35.11-37.94-80.89-70.53-113.31-81.29c-0.23-0.07-0.44-0.14-0.63-0.21c-48.06-15.61-82.34,26.25-95.75,67.54
c-13.42,41.29-10.29,95.31,37.77,110.92C220.33,353.58,221.21,353.86,222.27,354.21z"/>
<path class="st3" d="M600.73,241.74c-13.42-41.29-47.69-83.15-95.75-67.54c-0.66,0.21-1.54,0.5-2.6,0.84
c-2.75,26.34-12.16,59.57-26.36,92.17c-15.09,34.68-33.6,64.69-53.14,86.48c50.7,10.05,106.9,9.52,139.45-0.83
c0.23-0.07,0.44-0.14,0.63-0.21C611.02,337.05,614.15,283.03,600.73,241.74z"/>
<path class="st4" d="M348.22,394.58c-8.17-36.93-10.84-72.09-7.84-101.2c-46.93,21.67-92.08,55.14-112.33,82.64
c-0.14,0.19-0.27,0.37-0.39,0.54c-29.7,40.88-0.48,86.42,34.64,111.94s87.46,39.24,117.16-1.64c0.41-0.56,0.95-1.31,1.6-2.21
C367.81,461.72,355.9,429.3,348.22,394.58z"/>
<path class="st5" d="M554.19,373.91c-25.9,5.53-60.41,6.84-95.81,3.42c-37.65-3.64-71.91-11.96-98.67-23.82
c6.11,51.33,23.99,104.61,43.89,132.37c0.14,0.19,0.27,0.37,0.39,0.54c29.7,40.88,82.04,27.16,117.16,1.64S585.5,417,555.8,376.12
C555.39,375.56,554.85,374.81,554.19,373.91z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,29 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 792 792" style="enable-background:new 0 0 792 792;" xml:space="preserve">
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="svg2781" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 564.2 553.5"
style="enable-background:new 0 0 564.2 553.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FA2921;}
.st1{fill:#ED79B5;}
.st2{fill:#FFB400;}
.st3{fill:#1E83F7;}
.st4{fill:#18C249;}
.st0{fill:#4081EF;stroke:#512D8C;stroke-miterlimit:10;}
.st1{fill:#31A452;stroke:#512D8C;stroke-miterlimit:10;}
.st2{fill:#DE7FB3;stroke:#512D8C;stroke-miterlimit:10;}
.st3{fill:#FFB800;stroke:#512D8C;stroke-miterlimit:10;}
.st4{fill:#E64132;stroke:#512D8C;stroke-miterlimit:10;}
.st5{fill:#F2F5FB;stroke:#512D8C;stroke-miterlimit:10;}
</style>
<g id="Flower_00000077325900055813483940000000694823054982625702_">
<path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3
c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72
C300.01,209.24,339.15,235.47,375.48,267.63z"/>
<path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84
c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15
c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
<path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15
c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14
c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
<path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76
c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24
c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
<path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5
c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24
c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
</g>
<path class="st0" d="M210.5,549.6c-2.2-0.2-5.5-1-9.7-2.2c-52.4-15.7-99-46.5-133.8-88.5c-8.8-10.7-17.2-22.4-19.4-27.5
c-8.1-18.1-6.3-38.7,4.8-55.4c5-7.5,13.2-15,20.5-18.7c1.2-0.6,54.1-20,55.8-20.4c0.5-0.1,0.5,0.2-0.3,2.1c-0.7,1.7-1,3.1-1.1,5.5
l-0.1,3.2l2.8,5.8c8.7,17.9,19.2,32.7,33.2,46.4c6.3,6.2,7.8,7.6,13.8,12.3c22.7,18.1,52,30.7,79.9,34.3c2.5,0.3,5,0.8,5.7,1
c2.8,0.9,7.7-0.8,11-3.7l1.8-1.6l-0.2,4.8c-0.1,2.7-0.6,15.4-1,28.3c-0.6,20.3-0.8,24-1.5,27.5c-3.9,20.7-18.6,37.5-38.4,44.1
c-4.6,1.5-8,2.2-13.1,2.7C216.6,550.1,215.3,550,210.5,549.6z"/>
<path class="st1" d="M339.8,549.4c-4-0.4-9.4-1.6-13.2-2.9c-3.4-1.2-10-4.4-12.5-6.1c-10.9-7.4-19-17.9-23.1-30
c-2.2-6.7-2.3-7.5-3.3-36.9c-0.5-14.9-0.9-27.9-0.9-28.9l0-1.9l2.3,1.8c2.6,2,6.6,3.4,8.5,3.1c0.6-0.1,3-0.5,5.3-0.8
c37.7-5.3,71.2-22.2,97.4-49.1c12.2-12.5,21.4-25.5,29.9-42.4l3.5-7l0-3.6c0-3.1-0.1-3.8-1-5.7c-0.5-1.2-0.9-2.1-0.9-2.2
c0.2-0.2,55.3,20.1,56.9,20.9c2.6,1.3,6.6,4.1,9.9,7c9.2,7.7,16.1,19.4,18.8,31.8c0.7,3.1,0.8,4.8,0.8,11.3c0,8.6-0.5,11.7-2.9,18.7
c-1.7,5-2.9,7.2-7.1,13.1c-7.6,11-15.3,20.5-25.2,31.2c-32.8,35.4-76.5,62.5-123.4,76.3C351.6,549.6,347.2,550.1,339.8,549.4z"/>
<path class="st2" d="M255.6,438c-25.9-4.2-50.7-14.9-71.7-31c-5.2-4-8.7-7.1-14.1-12.4c-12.7-12.5-21.9-24.9-30.5-41.4
c-2.3-4.4-2.4-4.7-2.4-7.1c0-8.8,8.5-15.2,16.9-12.7c5.6,1.7,9.6,6.8,9.7,12.2c0,2.6-0.8,4.6-2.6,6.2c-1.2,1.1-3.2,1.9-4.6,1.9
c-1.2,0-3.3-0.8-4.3-1.6c-2.1-1.8-2-1,0.4,3.2c19.3,33.8,52.3,59.1,90,69.1c5.7,1.5,11.5,2.7,11.8,2.4c0.1-0.1-0.4-0.8-1.3-1.6
c-5.1-4.5-2.3-11.7,5-12.8c5.4-0.8,11.4,2.7,13.9,8c0.8,1.7,1,2.5,1,5.3s-0.1,3.5-1,5.3c-2,4.3-6.8,7.9-10.3,7.8
C260.6,438.7,257.9,438.3,255.6,438z"/>
<path class="st0" d="M297.6,438.2c-3.4-1.3-6.4-4.3-7.8-8.1c-1.1-2.9-0.9-7.3,0.5-10.2c2.6-5.3,8.7-8.5,14.4-7.5
c2.9,0.5,4.7,1.9,6,4.3c0.8,1.6,1,2.2,0.8,3.6c-0.3,2.2-0.9,3.3-2.7,4.8c-0.8,0.7-1.4,1.4-1.3,1.5c0.5,0.5,13.4-2.7,21.3-5.4
c33.6-11.3,62.5-35.1,80.4-66.1c2.5-4.4,2.6-5,0.5-3.2c-2.8,2.4-7,1.9-9.6-1c-4-4.6-0.7-13.8,6.1-16.9c2-0.9,2.7-1,5.5-1
c2.9,0,3.5,0.1,5.6,1.1c4.4,2.1,7.4,6.4,7.8,11c0.2,2.2,0.1,2.3-2.2,6.9c-23,45.9-67,78.1-117.2,85.9
C300.2,438.8,299.4,438.9,297.6,438.2z"/>
<path class="st1" d="M211.1,398.5c-4.7-0.9-8.7-2.7-12.9-5.9c-10.8-8.1-13.5-22.3-6.6-33.7c0.7-1.2,1.1-2.2,1-2.4
c-0.2-0.2-1.2-0.6-2.3-1.1c-7.6-3-13-10.6-13.5-19.1c-0.5-7.4,3.1-15,9-19.4c1-0.7,2.2-1.5,2.6-1.8c0.8-0.4,68.9-22.7,69.4-22.7
c0.2,0,0.7,0.7,1.2,1.5c0.5,0.8,1.6,2.3,2.4,3.3c1.2,1.4,1.5,1.9,1.2,2.3c-0.2,0.3-6.9,9.5-14.8,20.5
c-15.9,21.9-15.5,21.3-13.4,23.4c1.3,1.3,2.9,1.4,4.4,0.3c0.6-0.4,7.5-9.7,15.5-20.7c11.2-15.4,14.6-19.9,15-19.7
c0.9,0.4,5.5,1.9,6.6,2.1l1,0.2l0,35.3c0,39.7,0,38.8-2.5,44c-2.6,5.3-7.2,9.3-12.7,11.2c-3.7,1.3-6.8,1.6-10.2,1
c-5.5-0.9-9.8-3.2-13.7-7.4l-2.2-2.4l-0.6,0.9c-3,4.3-8.6,8.1-14,9.5C218.2,398.6,213.2,398.9,211.1,398.5z"/>
<path class="st3" d="M342.9,398.5c-5.5-0.9-9.9-3.2-14.3-7.6l-3.2-3.2l-0.7,1c-2.3,3.3-6.8,6.5-11.1,7.9c-3.7,1.2-9.2,1.4-12.6,0.3
c-7.1-2.1-12.7-7.4-15.2-14.3l-0.9-2.6v-37.1v-37.1l1.8-0.4c1-0.2,2.7-0.8,3.9-1.2c1.1-0.5,2.1-0.8,2.2-0.7c0.1,0.1,6.5,9,14.4,19.9
c7.8,10.9,14.7,20.1,15.2,20.5c2.2,1.9,5.4,0.4,5.4-2.6c0-1.4-1-2.9-13.8-20.5c-7.6-10.5-14.2-19.6-14.7-20.4l-0.9-1.3l1.4-1.7
c0.8-0.9,1.9-2.5,2.5-3.4l1-1.6l34.4,11.2c18.9,6.2,35.1,11.6,35.9,12.1c6.8,4,11.1,11.3,11.1,19.1c0,4.1-0.5,6.4-2.4,10.2
c-2,4.1-5.5,7.6-9.6,9.7c-1.6,0.8-3.2,1.5-3.4,1.5c-1,0-0.9,0.7,0.3,2.6c2.8,4.3,4,8.5,3.9,13.7c0,8.1-3.7,15.2-10.6,20.3
C356.4,397.6,349.5,399.5,342.9,398.5z"/>
<path class="st2" d="M53.9,341.9c-0.5-0.1-2.3-0.4-3.9-0.7c-15.6-2.6-30.4-12.6-38.8-26.2c-3.5-5.7-6.4-13.2-7.8-19.9
c-1.2-6.1-0.8-28.1,0.8-43.1c4.5-43,19-84.3,42.2-120.7c6.5-10.2,14.9-21.5,18.2-24.6c17.8-16.6,43.1-20.5,64.8-10
c4.3,2.1,8.8,5.1,12.7,8.6c2.8,2.4,5.8,6.1,20.9,25.5c9.7,12.5,17.8,22.8,17.9,23c0.2,0.2-0.9,0.4-3.2,0.4c-2.5,0-4.1,0.2-5.7,0.7
c-2.1,0.7-2.6,1.1-7.9,6.3c-8.2,8.1-14.4,15.3-20.3,23.9c-15.5,22.2-25.4,47.7-28.8,74.8c-2.2,16.9-1.6,37.5,1.6,52.3
c0.3,1.4,0.5,2.8,0.4,3c-0.1,0.2,0.2,1.3,0.8,2.4c1.1,2.4,4.3,5.7,6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2-13.1,3.8-27.6,8
c-16.4,4.7-27.7,7.8-29.8,8.1C64.1,342.1,56.1,342.3,53.9,341.9z"/>
<path class="st3" d="M494.7,341.7c-2.1-0.3-33.8-9.1-56.5-15.8l-2.5-0.7l1.6-0.8c3.4-1.7,7.2-6.6,7.3-9.6c0-0.7,0.4-3.3,0.8-5.8
c3.9-22.7,3.1-46.1-2.5-68.4c-6.4-25.5-18.6-49.2-35.8-69.1c-4.6-5.3-14.8-15.4-16.4-16.1c-2.4-1.1-5.1-1.6-8-1.4l-2.7,0.2l1.2-1.5
c0.7-0.8,8.5-10.8,17.5-22.3c8.9-11.5,17.2-21.8,18.5-23.1c2.6-2.7,7-6.2,10.3-8.2c19.3-11.6,43-11.1,61.6,1.2
c5.4,3.6,8.2,6.2,12.3,11.7c26.4,34.5,44,73.7,52.3,116.2c3.4,17.6,4.9,33.3,5,52.4c0,13-0.2,14.8-2.5,21.8
C547.8,328.6,521.7,345.2,494.7,341.7z"/>
<path class="st4" d="M133.9,318.5c-2-0.5-4.6-1.9-6-3.3c-2.5-2.4-3.1-3.5-3.7-7.3c-4.4-27.3-2.2-54,6.7-79.3
c5.3-15.1,13.5-30.5,23-43.1c5.8-7.8,16.6-19.5,19-20.7c4.7-2.4,11.3-1.2,15.2,2.7c5.4,5.4,5.2,13.9-0.3,19.1
c-4.3,4-9.4,4.4-12.6,0.9c-1.7-1.9-2.2-3.9-1.7-6.4c0.2-1.1,0.3-2,0.2-2.2c-0.3-0.3-3.6,3.3-8.3,9.1c-17.6,21.8-28.5,48-31.9,76.5
c-1.1,9.3-1,26.4,0.1,34.6c0.3,1.8,0.8,1.9,1.4,0.1c0.9-2.6,4-4.7,6.8-4.7c3,0,5.9,2.2,7.5,5.7c0.6,1.3,0.8,2.3,0.8,5.2
c0,3.3-0.1,3.8-1.1,5.7c-1.4,2.7-4.6,5.7-7.1,6.6C139.4,318.6,135.8,318.9,133.9,318.5z"/>
<path class="st1" d="M422.6,318.5c-3.7-0.6-7.7-3.6-9.4-7.1c-3.8-7.5,0.1-16.9,6.9-16.9c3.1,0,5.8,2,6.9,5.2
c0.4,1.2,0.5,1.3,0.7,0.7c1.3-3.7,1.7-26.4,0.6-35.7c-3.6-29.6-14.5-55.3-33-77.9c-5.5-6.7-8.4-9.4-7.1-6.6c0.7,1.4,0.5,4.3-0.3,5.9
c-0.9,1.7-3.2,3.5-5,3.8c-3.2,0.6-7.9-1.6-10.2-4.8c-6.5-8.8-0.5-21.2,10.4-21.4c4.6-0.1,5.2,0.3,11.2,6.4
c12.1,12.3,21.1,24.9,28.8,40.3c13.2,26.3,18.6,54.9,16.1,84.5c-0.5,5.6-2,15.7-2.6,17.1c-1.3,2.8-4.8,5.5-8.4,6.5
C425.9,318.9,425.1,318.9,422.6,318.5z"/>
<path class="st0" d="M178.2,307.2c-6-1.3-12.2-6.2-14.9-11.7c-3.4-7-3.1-15.1,0.9-21.6c0.7-1.2,1.2-2.3,1.1-2.4
c-0.1-0.1-1.1-0.6-2.1-1c-3.9-1.5-8.1-4.8-10.7-8.3c-4.6-6.2-6.1-14.6-3.9-22.1c2.9-10.3,9.4-16.8,19.1-19.3c2.8-0.7,9-0.8,11.7,0
c1.1,0.3,2.2,0.5,2.4,0.5c0.2,0,0.3-0.7,0.3-1.5c0-2.9,0.8-5.8,2.4-9.2c5.2-10.8,18.1-15.5,29-10.5c2.7,1.2,6.2,3.8,7.8,5.8
c0.7,0.8,10.3,14,21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1-1.9,2.6-2.5,3.5c-0.6,1-1.2,1.7-1.5,1.6c-4.5-1.7-46.7-15-47.7-15
c-1.9,0-3.1,1.3-3.1,3.2c0,1,0.2,1.7,0.8,2.3c0.6,0.6,7.8,3.1,24.5,8.5l23.7,7.7l-0.1,4.3l-0.1,4.3L223,295.9
c-18,5.9-33.9,10.9-35.2,11.2C184.7,307.8,181.2,307.8,178.2,307.2z"/>
<path class="st4" d="M372.5,306.8c-1.8-0.5-17.5-5.6-35-11.3l-31.8-10.4l1-4.3v-4.3l22.6-7.7c15-4.9,24-8,24.6-8.5
c0.7-0.6,0.9-1.1,0.9-2.2c0-2-1.2-3.3-3.1-3.3c-0.9,0-10.5,2.9-24.7,7.5c-12.8,4.1-23.4,7.5-23.6,7.5c-0.1,0-0.7-0.8-1.3-1.9
c-0.6-1-1.6-2.5-2.2-3.2c-0.7-0.7-1.2-1.5-1.2-1.6c0-0.2,9.6-13.5,21.4-29.6c18.9-26,21.6-29.6,23.6-31.1c5.7-4.4,13.1-5.8,19.7-3.9
c9,2.7,16.1,11.6,16.1,20.3c0,2.3-0.1,2.3,3.1,1.5c4.7-1.1,11.5-0.5,16,1.5c4.6,2,9,6,11.5,10.2c2.1,3.6,3.9,9.4,4.2,13.2
c0.3,5.2-1.1,10.7-4,15.3c-2.6,4.1-7.8,8.3-12.1,9.8c-0.9,0.3-1.7,0.8-1.7,1c0,0.2,0.4,1,0.9,1.7c2.4,3.6,3.6,7.7,3.5,12.7
c0,5.8-2.1,10.7-6.4,15.1c-4,4.1-8.9,6.3-14.9,6.5C376.3,307.7,375.3,307.6,372.5,306.8z"/>
<path class="st5" d="M276.2,298.9c-6.1-1.6-11.4-6.8-13.2-12.9c-0.7-2.4-0.7-7.5,0-9.9c1.7-5.8,6.6-10.8,12.3-12.5
c2.7-0.8,7.2-0.9,10-0.2c6.2,1.6,11.6,7.1,13.2,13.3c1.6,6-0.3,12.6-5,17.3C288.9,298.6,282.2,300.5,276.2,298.9z"/>
<path class="st2" d="M248.3,229.8c-13.3-18.3-21.2-29.6-22-31.1c-1.4-3-1.9-5.5-1.9-9.4c0-14.1,13.1-24.4,27.1-21.4
c1.4,0.3,2.6,0.5,2.7,0.5s0.3-1.3,0.4-2.8c0.8-10.7,8.4-19.6,18.9-22.4c3.9-1,10.6-1,14.5,0c8.9,2.3,15.9,9.3,18.2,18.2
c0.4,1.5,0.7,3.7,0.7,4.9c0,1.2,0.1,2.1,0.3,2.1s1.5-0.3,3-0.6c7.4-1.6,15.2,0.7,20.5,6c4.3,4.3,6.6,9.6,6.6,15.6
c0,4-0.6,6.5-2.4,10c-0.6,1.2-10.4,15-21.7,30.7c-17.8,24.5-20.8,28.5-21.4,28.3c-0.4-0.1-1.9-0.6-3.4-1.1c-1.5-0.5-2.9-0.9-3.3-0.9
c-0.7,0-0.7-0.8-0.3-25.5v-25.5l-1.4-0.9c-1-1.1-2.5-1.5-3.8-0.9c-2,0.8-2-0.5-1.8,27.2v25.8h-1.2c-0.5-0.2-2.4,0.3-4,0.9
s-3.1,1.1-3.2,1.1C269.2,258.5,259.8,245.6,248.3,229.8z"/>
<path class="st3" d="M210.9,164.8c-4.1-0.9-7.7-3.6-9.6-7.4c-1.4-2.8-1.7-7.3-0.5-10.3c1.7-4.5,3.9-6.1,15.6-11.2
c15.8-7,31.4-11.1,49.2-12.9c7.3-0.8,23.2-0.8,30.6,0c17.4,1.8,33.3,6,49.1,13c7.3,3.2,12.5,6.1,13.6,7.5c4.3,5.6,3.8,12.7-1.1,17.6
c-5.1,5.1-12.9,5.4-18.1,0.7c-2-1.8-3-3.5-3.4-5.6c-0.7-4,2.9-8.1,7.3-8.2c1.4,0,1.5-0.1,1.1-0.5c-0.3-0.3-2.2-1.2-4.3-2.1
c-33.2-14.5-70.5-16.4-105-5.4c-7.5,2.4-19,7.2-18.6,7.7c0.1,0.2,0.8,0.3,1.6,0.3c5.6,0,9.1,6.2,6.1,10.8
C221.6,163.3,215.9,165.9,210.9,164.8z"/>
<path class="st4" d="M174.7,123.4c-8.9-13.1-16.8-25.1-17.5-26.6c-1.6-3.3-3.6-9.2-4.4-13c-2.6-12.5-0.9-25.8,5-37.5
c4.2-8.3,11.2-16.3,18.6-21.3c5-3.4,6.1-3.9,12.8-6.3c23.1-8.2,47.2-13.1,73.4-15c7.5-0.6,28.5-0.6,36.3,0
c25.5,1.8,50.6,6.9,73,14.8c6.4,2.2,8.2,3.1,13.1,6.5c9.8,6.6,18.1,17.5,22,29.2c2.2,6.5,2.7,10,2.7,17.9c0,7.9-0.5,11.3-2.7,17.9
c-2.3,6.8-3.7,9.1-20.3,33.6l-16.1,23.8l-0.4-2.2c-0.2-1.2-0.9-3-1.4-4c-1-1.8-4.4-5.6-4.7-5.2c-0.1,0.1-1.2-0.4-2.4-1.1
c-9.1-5.2-21.9-10.5-33.2-13.9c-37-11-77.2-8.8-113,6.1c-4.9,2.1-17.7,8.4-19.2,9.5c-2.2,1.6-5.1,6.8-5.1,9c0,0.4-0.1,1-0.3,1.2
C191,147,184.7,138,174.7,123.4z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

BIN
design/ios-qr-code.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
design/login-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
design/login-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

BIN
design/nsc3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
design/nsc4.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
design/nsc6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

BIN
design/search-screen.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

BIN
design/shared-albums.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

BIN
design/web-admin.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
design/web-detail.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

BIN
design/web-home.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -2,6 +2,8 @@
# - https://immich.app/docs/developer/setup
# - https://immich.app/docs/developer/troubleshooting
version: "3.8"
name: immich-dev
x-server-build: &server-common
@@ -28,7 +30,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
command: ['/usr/src/app/bin/immich-dev', 'immich']
command: [ "/usr/src/app/bin/immich-dev", "immich" ]
<<: *server-common
ports:
- 3001:3001
@@ -39,7 +41,7 @@ services:
immich-microservices:
container_name: immich_microservices
command: ['/usr/src/app/bin/immich-dev', 'microservices']
command: [ "/usr/src/app/bin/immich-dev", "microservices" ]
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
@@ -55,7 +57,7 @@ services:
image: immich-web-dev:latest
build:
context: ../web
command: ['/usr/src/app/bin/immich-web']
command: [ "/usr/src/app/bin/immich-web" ]
env_file:
- .env
ports:
@@ -97,7 +99,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
database:
container_name: immich_postgres
@@ -112,29 +114,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
# set IMMICH_METRICS=true in .env to enable metrics
# immich-prometheus:
# container_name: immich_prometheus
# ports:
# - 9090:9090
# image: prom/prometheus
# volumes:
# - ./prometheus.yml:/etc/prometheus/prometheus.yml
# - prometheus-data:/prometheus
# first login uses admin/admin
# add data source for http://immich-prometheus:9090 to get started
# immich-grafana:
# container_name: immich_grafana
# command: ['./run.sh', '-disable-reporting']
# ports:
# - 3000:3000
# image: grafana/grafana:10.3.3-ubuntu
# volumes:
# - grafana-data:/var/lib/grafana
volumes:
model-cache:
prometheus-data:
grafana-data:

View File

@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
name: immich-e2e
@@ -13,40 +13,36 @@ x-server-build: &server-common
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- REDIS_HOSTNAME=redis
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_METRICS=true
volumes:
- upload:/usr/src/app/upload
- ../server/test/assets:/data/assets
depends_on:
- redis
- database
services:
immich-server:
container_name: immich-e2e-server
command: ['./start.sh', 'immich']
command: [ "./start.sh", "immich" ]
<<: *server-common
ports:
- 2283:3001
immich-microservices:
container_name: immich-e2e-microservices
command: ['./start.sh', 'microservices']
command: [ "./start.sh", "microservices" ]
<<: *server-common
redis:
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
restart: always
database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
command: -c fsync=off -c shared_preload_libraries=vectors.so
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: immich
ports:
- 5433:5432
- 5432:5432
volumes:
model-cache:

View File

@@ -1,3 +1,5 @@
version: "3.8"
name: immich-prod
x-server-build: &server-common
@@ -15,7 +17,7 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich_server
command: ['start.sh', 'immich']
command: [ "./start-server.sh" ]
<<: *server-common
ports:
- 2283:3001
@@ -25,7 +27,7 @@ services:
immich-microservices:
container_name: immich_microservices
command: ['start.sh', 'microservices']
command: [ "./start-microservices.sh" ]
<<: *server-common
# extends:
# file: hwaccel.transcoding.yml
@@ -54,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
restart: always
database:
@@ -71,28 +73,5 @@ services:
ports:
- 5432:5432
# set IMMICH_METRICS=true in .env to enable metrics
immich-prometheus:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:4f6c47e39a9064028766e8c95890ed15690c30f00c4ba14e7ce6ae1ded0295b1
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
# first login uses admin/admin
# add data source for http://immich-prometheus:9090 to get started
immich-grafana:
container_name: immich_grafana
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:10.4.2-ubuntu@sha256:4f55071b556fb03f12b41423c98a185ed6695ed9ff2558e35805f0dd765fd958
volumes:
- grafana-data:/var/lib/grafana
volumes:
model-cache:
prometheus-data:
grafana-data:

View File

@@ -1,3 +1,5 @@
version: "3.8"
#
# WARNING: Make sure to use the docker-compose.yml of the current release:
#
@@ -12,7 +14,7 @@ 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
@@ -31,7 +33,7 @@ services:
# extends: # uncomment this section for hardware acceleration - see https://immich.app/docs/features/hardware-transcoding
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
command: ['start.sh', 'microservices']
command: [ "start.sh", "microservices" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
@@ -58,19 +60,20 @@ services:
redis:
container_name: immich_redis
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
restart: always
database:
container_name: immich_postgres
image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
volumes:
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
- pgdata:/var/lib/postgresql/data
restart: always
volumes:
pgdata:
model-cache:

View File

@@ -14,6 +14,5 @@ DB_PASSWORD=postgres
DB_HOSTNAME=immich_postgres
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
DB_DATA_LOCATION=./postgres
REDIS_HOSTNAME=immich_redis

View File

@@ -27,6 +27,8 @@ services:
count: 1
capabilities:
- gpu
- compute
- video
openvino:
device_cgroup_rules:

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