mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into filter-by-person
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
24.11.1
|
24.13.0
|
||||||
|
|||||||
@@ -30,18 +30,6 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
IOS_CERTIFICATE_PASSWORD:
|
IOS_CERTIFICATE_PASSWORD:
|
||||||
required: true
|
required: true
|
||||||
IOS_PROVISIONING_PROFILE:
|
|
||||||
required: true
|
|
||||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
|
||||||
required: true
|
|
||||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
|
||||||
required: true
|
|
||||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE:
|
|
||||||
required: true
|
|
||||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION:
|
|
||||||
required: true
|
|
||||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION:
|
|
||||||
required: true
|
|
||||||
FASTLANE_TEAM_ID:
|
FASTLANE_TEAM_ID:
|
||||||
required: true
|
required: true
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -96,7 +84,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -115,7 +103,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Restore Gradle Cache
|
- name: Restore Gradle Cache
|
||||||
id: cache-gradle-restore
|
id: cache-gradle-restore
|
||||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -165,14 +153,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
path: mobile/build/app/outputs/flutter-apk/*.apk
|
path: mobile/build/app/outputs/flutter-apk/*.apk
|
||||||
|
|
||||||
- name: Save Gradle Cache
|
- name: Save Gradle Cache
|
||||||
id: cache-gradle-save
|
id: cache-gradle-save
|
||||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -194,7 +182,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -240,35 +228,14 @@ jobs:
|
|||||||
mkdir -p ~/.appstoreconnect/private_keys
|
mkdir -p ~/.appstoreconnect/private_keys
|
||||||
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
|
echo "$API_KEY_CONTENT" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${API_KEY_ID}.p8
|
||||||
|
|
||||||
- name: Import Certificate and Provisioning Profiles
|
- name: Import Certificate
|
||||||
env:
|
env:
|
||||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
||||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
|
||||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
|
||||||
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
|
||||||
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
|
||||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
|
|
||||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
|
|
||||||
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
|
|
||||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
run: |
|
run: |
|
||||||
# Decode certificate
|
# Decode certificate
|
||||||
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
||||||
|
|
||||||
# Decode provisioning profiles based on environment
|
|
||||||
if [[ "$ENVIRONMENT" == "development" ]]; then
|
|
||||||
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE" | base64 --decode > profile_dev.mobileprovision
|
|
||||||
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_dev_share.mobileprovision
|
|
||||||
echo "$IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_dev_widget.mobileprovision
|
|
||||||
ls -lh profile_dev*.mobileprovision
|
|
||||||
else
|
|
||||||
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
|
|
||||||
echo "$IOS_PROVISIONING_PROFILE_SHARE_EXTENSION" | base64 --decode > profile_share.mobileprovision
|
|
||||||
echo "$IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION" | base64 --decode > profile_widget.mobileprovision
|
|
||||||
ls -lh profile*.mobileprovision
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create keychain and import certificate
|
- name: Create keychain and import certificate
|
||||||
env:
|
env:
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
||||||
@@ -319,7 +286,7 @@ jobs:
|
|||||||
security delete-keychain build.keychain || true
|
security delete-keychain build.keychain || true
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: ios-release-ipa
|
name: ios-release-ipa
|
||||||
path: mobile/ios/Runner.ipa
|
path: mobile/ios/Runner.ipa
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
needs: [get_body, should_run]
|
needs: [get_body, should_run]
|
||||||
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
if: ${{ needs.should_run.outputs.should_run == 'true' }}
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/immich-app/mdq:main@sha256:237cdae7783609c96f18037a513d38088713cf4a2e493a3aa136d0c45490749a
|
image: ghcr.io/immich-app/mdq:main@sha256:ab9f163cd5d5cec42704a26ca2769ecf3f10aa8e7bae847f1d527cdf075946e6
|
||||||
outputs:
|
outputs:
|
||||||
checked: ${{ steps.get_checkbox.outputs.checked }}
|
checked: ${{ steps.get_checkbox.outputs.checked }}
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
|
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -85,7 +86,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: docs-build-output
|
name: docs-build-output
|
||||||
path: docs/build/
|
path: docs/build/
|
||||||
|
|||||||
@@ -125,13 +125,13 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||||
|
|
||||||
- name: Load parameters
|
- name: Load parameters
|
||||||
id: parameters
|
id: parameters
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup Mise
|
||||||
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
|
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
|
||||||
|
|
||||||
- name: Destroy Docs Subdomain
|
- name: Destroy Docs Subdomain
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: 'Checkout'
|
- name: 'Checkout'
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|||||||
@@ -56,14 +56,14 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -136,13 +136,13 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download APK
|
- name: Download APK
|
||||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create PR
|
- name: Create PR
|
||||||
id: create-pr
|
id: create-pr
|
||||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -74,7 +74,7 @@ jobs:
|
|||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Download APK
|
- name: Download APK
|
||||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
+22
-25
@@ -69,7 +69,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -161,7 +161,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -203,7 +203,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -247,7 +247,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -285,7 +285,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -298,9 +298,9 @@ jobs:
|
|||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm --filter=immich-web install --frozen-lockfile
|
run: pnpm --filter=immich-i18n install --frozen-lockfile
|
||||||
- name: Format
|
- name: Format
|
||||||
run: pnpm --filter=immich-web format:i18n
|
run: pnpm --filter=immich-i18n format:fix
|
||||||
- name: Find file changes
|
- name: Find file changes
|
||||||
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
|
||||||
id: verify-changed-files
|
id: verify-changed-files
|
||||||
@@ -333,7 +333,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -379,7 +379,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -418,7 +418,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -473,7 +473,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -505,7 +505,7 @@ jobs:
|
|||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive test results
|
- name: Archive test results
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-web-test-results-${{ matrix.runner }}
|
name: e2e-web-test-results-${{ matrix.runner }}
|
||||||
@@ -534,7 +534,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -566,17 +566,14 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
with:
|
||||||
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
python-version: 3.11
|
||||||
# with:
|
|
||||||
# python-version: 3.11
|
|
||||||
# cache: 'uv'
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra cpu
|
uv sync --extra cpu
|
||||||
@@ -610,7 +607,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -639,7 +636,7 @@ jobs:
|
|||||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -661,7 +658,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -723,7 +720,7 @@ jobs:
|
|||||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Contributing to Immich
|
||||||
|
|
||||||
|
We appreciate every contribution, and we're happy about every new contributor. So please feel invited to help make Immich a better product!
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
To get you started quickly we have detailed guides for the dev setup on our [website](https://docs.immich.app/developer/setup). If you prefer, you can also use [Devcontainers](https://docs.immich.app/developer/devcontainers).
|
||||||
|
There are also additional resources about Immich's architecture, database migrations, the use of OpenAPI, and more in our [developer documentation](https://docs.immich.app/developer/architecture).
|
||||||
|
|
||||||
|
## General
|
||||||
|
|
||||||
|
Please try to keep pull requests as focused as possible. A PR should do exactly one thing and not bleed into other, unrelated areas. The smaller a PR, the fewer changes are likely needed, and the quicker it will likely be merged. For larger/more impactful PRs, please reach out to us first to discuss your plans. The best way to do this is through our [Discord](https://discord.immich.app). We have a dedicated `#contributing` channel there. Additionally, please fill out the entire template when opening a PR.
|
||||||
|
|
||||||
|
## Finding work
|
||||||
|
|
||||||
|
If you are looking for something to work on, there are discussions and issues with a `good-first-issue` label on them. These are always a good starting point. If none of them sound interesting or fit your skill set, feel free to reach out on our Discord. We're happy to help you find something to work on!
|
||||||
|
|
||||||
|
## Use of generative AI
|
||||||
|
|
||||||
|
We generally discourage PRs entirely generated by an LLM. For any part generated by an LLM, please put extra effort into your self-review. By using generative AI without proper self-review, the time you save ends up being more work we need to put in for proper reviews and code cleanup. Please keep that in mind when submitting code by an LLM. Clearly state the use of LLMs/(generative) AI in your pull request as requested by the template.
|
||||||
|
|
||||||
|
## Feature freezes
|
||||||
|
|
||||||
|
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
|
||||||
|
|
||||||
|
* Sharing/Asset ownership
|
||||||
|
* (External) libraries
|
||||||
|
|
||||||
|
## Non-code contributions
|
||||||
|
|
||||||
|
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.11.1
|
24.13.0
|
||||||
|
|||||||
+3
-3
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.10.3",
|
"@types/node": "^24.10.8",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"typescript-eslint": "^8.28.0",
|
"typescript-eslint": "^8.28.0",
|
||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vite-tsconfig-paths": "^5.0.0",
|
"vite-tsconfig-paths": "^6.0.0",
|
||||||
"vitest": "^3.0.0",
|
"vitest": "^3.0.0",
|
||||||
"vitest-fetch-mock": "^0.4.0",
|
"vitest-fetch-mock": "^0.4.0",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1"
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
"micromatch": "^4.0.8"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.1"
|
"node": "24.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
|
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
@@ -146,6 +146,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
|
healthcheck:
|
||||||
|
disable: false
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
# immich-prometheus:
|
# immich-prometheus:
|
||||||
# container_name: immich_prometheus
|
# container_name: immich_prometheus
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
|
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -77,13 +77,15 @@ services:
|
|||||||
- 5432:5432
|
- 5432:5432
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
restart: always
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
disable: false
|
||||||
|
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
immich-prometheus:
|
immich-prometheus:
|
||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:d936808bdea528155c0154a922cd42fd75716b8bb7ba302641350f9f3eaeba09
|
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
@@ -95,7 +97,7 @@ services:
|
|||||||
command: ['./run.sh', '-disable-reporting']
|
command: ['./run.sh', '-disable-reporting']
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
|
image: grafana/grafana:12.3.1-ubuntu@sha256:d57f1365197aec34c4d80869d8ca45bb7787c7663904950dab214dfb40c1c2fd
|
||||||
volumes:
|
volumes:
|
||||||
- grafana-data:/var/lib/grafana
|
- grafana-data:/var/lib/grafana
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:fb8d272e529ea567b9bf1302245796f21a2672b8368ca3fcb938ac334e613c8f
|
image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -69,6 +69,8 @@ services:
|
|||||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
restart: always
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
disable: false
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.11.1
|
24.13.0
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
|
|||||||
|
|
||||||
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
||||||
|
|
||||||
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase product keys directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
||||||
|
|
||||||
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Immich is known to work with Postgres versions `>= 14, < 19`.
|
|||||||
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
|
VectorChord is known to work with pgvector versions `>= 0.7, < 0.9`.
|
||||||
|
|
||||||
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
|
The Immich server will check the VectorChord version on startup to ensure compatibility, and refuse to start if a compatible version is not found.
|
||||||
The current accepted range for VectorChord is `>= 0.3, < 0.6`.
|
The current accepted range for VectorChord is `>= 0.3, < 2.0`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Specifying the connection URL
|
## Specifying the connection URL
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ sidebar_position: 2
|
|||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
Make sure to read the [`CONTRIBUTING.md`](https://github.com/immich-app/immich/blob/main/CONTRIBUTING.md) before you dive into the code.
|
||||||
|
:::
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
|
If there's a feature you're planning to work on, just give us a heads up in [#contributing](https://discord.com/channels/979116623879368755/1071165397228855327) on [our Discord](https://discord.immich.app) so we can:
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ For RKMPP to work:
|
|||||||
|
|
||||||
5. (Optional) Enable hardware decoding for optimal performance.
|
5. (Optional) Enable hardware decoding for optimal performance.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>immich.json</summary>
|
||||||
|
|
||||||
|
If you use a [configuration file](/install/config-file.md), use the `accel` option to select the hardware (e.g. `qsv` for Intel or `nvenc` for Nvidia). Set `accelDecode` to `true` if you want hardware decoding.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ffmpeg": {
|
||||||
|
"accel": "qsv",
|
||||||
|
"accelDecode": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
#### Single Compose File
|
#### Single Compose File
|
||||||
|
|
||||||
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.
|
Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.transcoding.yml`][hw-file] file into the `immich-server` service directly.
|
||||||
|
|||||||
@@ -95,11 +95,3 @@ Enter the cloud on the top right -> cog wheel on the top right -> select the syn
|
|||||||
If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums
|
If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums
|
||||||
It will only reflect files you add.
|
It will only reflect files you add.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
If the same asset is in more than one album it will only sync to the first album it's in, after that it won't sync again even if the user clicks sync albums manually.
|
|
||||||
To overcome this limitation, the files must be removed from the ignore list by
|
|
||||||
App settings -> Advanced -> Duplicate Assets -> Clear
|
|
||||||
|
|
||||||
:::info
|
|
||||||
Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the ignore list again at the end of the synchronization.
|
|
||||||
:::
|
|
||||||
|
|||||||
@@ -112,4 +112,40 @@ You can then make a new panel, specifying Prometheus as the data source for it.
|
|||||||
|
|
||||||
-- TODO: add images and more details here
|
-- TODO: add images and more details here
|
||||||
|
|
||||||
|
## Structured Logging
|
||||||
|
|
||||||
|
In addition to Prometheus metrics, Immich supports structured JSON logging which is ideal for log aggregation systems like Grafana Loki, ELK Stack, Datadog, Splunk, and others.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
By default, Immich outputs human-readable console logs. To enable JSON logging, set the `IMMICH_LOG_FORMAT` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IMMICH_LOG_FORMAT=json
|
||||||
|
```
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
The default is `IMMICH_LOG_FORMAT=console` for human-readable logs with colors during development. For production deployments using log aggregation, use `IMMICH_LOG_FORMAT=json`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### JSON Log Format
|
||||||
|
|
||||||
|
When enabled, logs are output in structured JSON format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"level":"log","pid":36,"timestamp":1766533331507,"message":"Initialized websocket server","context":"WebsocketRepository"}
|
||||||
|
{"level":"warn","pid":48,"timestamp":1766533331629,"message":"Unable to open /build/www/index.html, skipping SSR.","context":"ApiService"}
|
||||||
|
{"level":"error","pid":36,"timestamp":1766533331690,"message":"Failed to load plugin immich-core:","context":"Error"}
|
||||||
|
```
|
||||||
|
|
||||||
|
This format includes:
|
||||||
|
|
||||||
|
- `level`: Log level (log, warn, error, etc.)
|
||||||
|
- `pid`: Process ID
|
||||||
|
- `timestamp`: Unix timestamp in milliseconds
|
||||||
|
- `message`: Log message
|
||||||
|
- `context`: Service or component that generated the log
|
||||||
|
|
||||||
|
For more information on log formats, see [`IMMICH_LOG_FORMAT`](/install/environment-variables.md#general).
|
||||||
|
|
||||||
[prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml
|
[prom-file]: https://github.com/immich-app/immich/releases/latest/download/prometheus.yml
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ You can create a public link to share a group of photos or videos, or an album,
|
|||||||
The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance.
|
The public shared link is generated with a random URL, which acts as as a secret to avoid the link being guessed by unwanted parties, for instance.
|
||||||
|
|
||||||
```
|
```
|
||||||
https://immich.yourdomain.com/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
|
https://my.immich.app/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a public share link
|
### Creating a public share link
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
|||||||
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
|
||||||
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
|
||||||
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
|
||||||
|
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
|
||||||
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
|
||||||
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
|
||||||
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
|
||||||
@@ -43,6 +44,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
|||||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||||
|
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
||||||
|
|
||||||
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
||||||
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ Hardware and software requirements for Immich:
|
|||||||
- Immich runs well in a virtualized environment when running in a full virtual machine.
|
- Immich runs well in a virtualized environment when running in a full virtual machine.
|
||||||
The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users.
|
The use of Docker in LXC containers is [not recommended](https://pve.proxmox.com/wiki/Linux_Container), but may be possible for advanced users.
|
||||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||||
- **RAM**: Minimum 4GB, recommended 6GB.
|
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||||
|
|
||||||
:::tip
|
:::note RAM requirements
|
||||||
|
For a smooth experience, especially during asset upload, Immich requires at least 6GB of RAM.
|
||||||
|
For systems with only 4GB of RAM, Immich can be run with machine learning features disabled.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip Postgres setup
|
||||||
Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience.
|
Good performance and a stable connection to the Postgres database is critical to a smooth Immich experience.
|
||||||
The Postgres database files are typically between 1-3 GB in size.
|
The Postgres database files are typically between 1-3 GB in size.
|
||||||
For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind.
|
For this reason, the Postgres database (`DB_DATA_LOCATION`) should ideally use local SSD storage, and never a network share of any kind.
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const config = {
|
|||||||
locales: ['en'],
|
locales: ['en'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mermaid diagrams
|
||||||
|
markdown: {
|
||||||
|
mermaid: true,
|
||||||
|
},
|
||||||
|
themes: ['@docusaurus/theme-mermaid'],
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
async function myPlugin(context, options) {
|
async function myPlugin(context, options) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-1
@@ -20,6 +20,7 @@
|
|||||||
"@docusaurus/core": "~3.9.0",
|
"@docusaurus/core": "~3.9.0",
|
||||||
"@docusaurus/preset-classic": "~3.9.0",
|
"@docusaurus/preset-classic": "~3.9.0",
|
||||||
"@docusaurus/theme-common": "~3.9.0",
|
"@docusaurus/theme-common": "~3.9.0",
|
||||||
|
"@docusaurus/theme-mermaid": "~3.9.0",
|
||||||
"@mdi/js": "^7.3.67",
|
"@mdi/js": "^7.3.67",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mdi/react": "^1.6.1",
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.0",
|
||||||
@@ -57,6 +58,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.1"
|
"node": "24.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-11
@@ -8,19 +8,19 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Overpass';
|
font-family: 'GoogleSans';
|
||||||
src: url('/fonts/overpass/Overpass.ttf') format('truetype-variations');
|
src: url('/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
|
||||||
font-weight: 1 999;
|
font-weight: 410 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
ascent-override: 106.25%;
|
ascent-override: 106.25%;
|
||||||
size-adjust: 106.25%;
|
size-adjust: 106.25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Overpass Mono';
|
font-family: 'GoogleSansCode';
|
||||||
src: url('/fonts/overpass/OverpassMono.ttf') format('truetype-variations');
|
src: url('/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
|
||||||
font-weight: 1 999;
|
font-weight: 1 900;
|
||||||
font-style: normal;
|
font-style: monospace;
|
||||||
ascent-override: 106.25%;
|
ascent-override: 106.25%;
|
||||||
size-adjust: 106.25%;
|
size-adjust: 106.25%;
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,8 @@ img {
|
|||||||
|
|
||||||
/* You can override the default Infima variables here. */
|
/* You can override the default Infima variables here. */
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Overpass', sans-serif;
|
font-family: 'GoogleSans', sans-serif;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
--ifm-color-primary: #4250af;
|
--ifm-color-primary: #4250af;
|
||||||
--ifm-color-primary-dark: #4250af;
|
--ifm-color-primary-dark: #4250af;
|
||||||
--ifm-color-primary-darker: #4250af;
|
--ifm-color-primary-darker: #4250af;
|
||||||
@@ -48,6 +49,16 @@ img {
|
|||||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: 'GoogleSans', sans-serif;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--ifm-color-primary: #adcbfa;
|
--ifm-color-primary: #adcbfa;
|
||||||
@@ -71,15 +82,22 @@ div[class^='announcementBar_'] {
|
|||||||
padding: 10px 10px 10px 16px;
|
padding: 10px 10px 10px 16px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__list-item-collapsible {
|
.menu__list-item-collapsible {
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu__link--active {
|
.menu__link--active {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-of-contents__link {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 450;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* workaround for version switcher PR 15894 */
|
/* workaround for version switcher PR 15894 */
|
||||||
@@ -88,13 +106,14 @@ div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
|
font-family: 'GoogleSansCode';
|
||||||
}
|
}
|
||||||
|
|
||||||
.buy-button {
|
.buy-button {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
font-family: 'Overpass', sans-serif;
|
font-family: 'GoogleSans', sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);
|
box-shadow: 0 0 5px 2px rgba(181, 206, 254, 0.4);
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25
|
||||||
|
RUN corepack enable
|
||||||
|
ADD package.json *.ts ./
|
||||||
|
RUN pnpm install
|
||||||
|
EXPOSE 2286
|
||||||
|
CMD ["pnpm", "run", "start"]
|
||||||
@@ -125,7 +125,7 @@ const setup = async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const onStart = () => console.log(`[auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
||||||
const app = oidc.listen(port, host, onStart);
|
const app = oidc.listen(port, host, onStart);
|
||||||
return () => app.close();
|
return () => app.close();
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@immich/e2e-auth-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "auth-server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "tsx startup.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jose": "^5.6.3",
|
||||||
|
"@types/oidc-provider": "^9.0.0",
|
||||||
|
"oidc-provider": "^9.0.0",
|
||||||
|
"tsx": "^4.20.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import setup from './auth-server'
|
||||||
|
|
||||||
|
const teardown = await setup()
|
||||||
|
process.on('exit', () => {
|
||||||
|
teardown()
|
||||||
|
console.log('[e2e-auth-server] stopped')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
24.11.1
|
24.13.0
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
name: immich-e2e
|
name: immich-e2e
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
e2e-auth-server:
|
||||||
|
build:
|
||||||
|
context: ../e2e-auth-server
|
||||||
|
ports:
|
||||||
|
- 2286:2286
|
||||||
|
|
||||||
immich-server:
|
immich-server:
|
||||||
container_name: immich-e2e-server
|
container_name: immich-e2e-server
|
||||||
image: immich-server:latest
|
image: immich-server:latest
|
||||||
@@ -27,8 +33,6 @@ services:
|
|||||||
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
|
||||||
volumes:
|
volumes:
|
||||||
- ./test-assets:/test-assets
|
- ./test-assets:/test-assets
|
||||||
extra_hosts:
|
|
||||||
- 'auth-server:host-gateway'
|
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
+4
-6
@@ -22,12 +22,12 @@
|
|||||||
"@eslint/js": "^9.8.0",
|
"@eslint/js": "^9.8.0",
|
||||||
"@faker-js/faker": "^10.1.0",
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@immich/cli": "file:../cli",
|
"@immich/cli": "file:../cli",
|
||||||
|
"@immich/e2e-auth-server": "file:../e2e-auth-server",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.10.3",
|
"@types/node": "^24.10.8",
|
||||||
"@types/oidc-provider": "^9.0.0",
|
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
@@ -36,11 +36,9 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-unicorn": "^62.0.0",
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
"exiftool-vendored": "^34.0.0",
|
"exiftool-vendored": "^34.3.0",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jose": "^5.6.3",
|
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"oidc-provider": "^9.0.0",
|
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
@@ -54,6 +52,6 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.11.1"
|
"node": "24.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||||
|
import { errorDto } from 'src/responses';
|
||||||
|
import { app, utils } from 'src/utils';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('/admin/database-backups', () => {
|
||||||
|
let cookie: string | undefined;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /', async () => {
|
||||||
|
it('should succeed and be empty', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
backups: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a created backup', async () => {
|
||||||
|
await utils.createJob(admin.accessToken, {
|
||||||
|
name: ManualJobName.BackupDatabase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backups: [
|
||||||
|
expect.objectContaining({
|
||||||
|
filename: expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/),
|
||||||
|
filesize: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /', async () => {
|
||||||
|
it('should delete backup', async () => {
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.delete(`/admin/database-backups`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ backups: [filename] });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const { status: listStatus, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(listStatus).toBe(200);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backups: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => action: restore database flow
|
||||||
|
|
||||||
|
describe.sequential('POST /start-restore', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should not work when the server is configured', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||||
|
await utils.resetDatabase(); // reset database before running this test
|
||||||
|
|
||||||
|
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: true,
|
||||||
|
action: 'select_database_restore',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => action: restore database
|
||||||
|
|
||||||
|
describe.sequential('POST /backups/restore', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.disconnectDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await utils.connectDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
|
||||||
|
let filename = await utils.createBackup(admin.accessToken);
|
||||||
|
|
||||||
|
// work-around until test is running on released version
|
||||||
|
await utils.move(
|
||||||
|
`/data/backups/${filename}`,
|
||||||
|
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||||
|
);
|
||||||
|
filename = 'immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz';
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
|
||||||
|
await utils.prepareTestBackup('corrupted');
|
||||||
|
|
||||||
|
const { status, headers } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: 'development-corrupted.sql.gz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: 'Something went wrong, see logs!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: status2, body: body2 } = await request(app)
|
||||||
|
.get('/admin/maintenance/status')
|
||||||
|
.set('cookie', cookie!)
|
||||||
|
.send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body2).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: expect.stringContaining('IM CORRUPTED'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('rollback to restore point if backup is missing admin', { timeout: 60_000 }, async () => {
|
||||||
|
await utils.prepareTestBackup('empty');
|
||||||
|
|
||||||
|
const { status, headers } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: 'development-empty.sql.gz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: 'Something went wrong, see logs!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: status2, body: body2 } = await request(app)
|
||||||
|
.get('/admin/maintenance/status')
|
||||||
|
.set('cookie', cookie!)
|
||||||
|
.send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body2).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: expect.stringContaining('Server health check failed, no admin exists.'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
|
|||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
// => outside of maintenance mode
|
// => outside of maintenance mode
|
||||||
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /status', async () => {
|
||||||
|
it('to always indicate we are not in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: false,
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /login', async () => {
|
describe('POST /login', async () => {
|
||||||
it('should not work out of maintenance mode', async () => {
|
it('should not work out of maintenance mode', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||||
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
|
|||||||
describe.sequential('POST /', () => {
|
describe.sequential('POST /', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||||
|
active: false,
|
||||||
action: 'end',
|
action: 'end',
|
||||||
});
|
});
|
||||||
expect(status).toBe(401);
|
expect(status).toBe(401);
|
||||||
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
|
|||||||
.send({
|
.send({
|
||||||
action: 'start',
|
action: 'start',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
|
|
||||||
cookie = headers['set-cookie'][0].split(';')[0];
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { body } = await request(app).get('/server/config');
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 5e2,
|
interval: 500,
|
||||||
timeout: 1e4,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /status', async () => {
|
||||||
|
it('to indicate we are in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: true,
|
||||||
|
action: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /login', async () => {
|
describe('POST /login', async () => {
|
||||||
it('should fail without cookie or token in body', async () => {
|
it('should fail without cookie or token in body', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||||
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { body } = await request(app).get('/server/config');
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 5e2,
|
interval: 500,
|
||||||
timeout: 1e4,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeFalsy();
|
.toBeFalsy();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
|
||||||
import {
|
import {
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SystemConfigOAuthDto,
|
SystemConfigOAuthDto,
|
||||||
@@ -8,13 +9,12 @@ import {
|
|||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
import { OAuthClient, OAuthUser } from 'src/setup/auth-server';
|
|
||||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const authServer = {
|
const authServer = {
|
||||||
internal: 'http://auth-server:2286',
|
internal: 'http://e2e-auth-server:2286',
|
||||||
external: 'http://127.0.0.1:2286',
|
external: 'http://127.0.0.1:2286',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe('/shared-links', () => {
|
|||||||
let user1: LoginResponseDto;
|
let user1: LoginResponseDto;
|
||||||
let user2: LoginResponseDto;
|
let user2: LoginResponseDto;
|
||||||
let album: AlbumResponseDto;
|
let album: AlbumResponseDto;
|
||||||
let metadataAlbum: AlbumResponseDto;
|
|
||||||
let deletedAlbum: AlbumResponseDto;
|
let deletedAlbum: AlbumResponseDto;
|
||||||
let linkWithDeletedAlbum: SharedLinkResponseDto;
|
let linkWithDeletedAlbum: SharedLinkResponseDto;
|
||||||
let linkWithPassword: SharedLinkResponseDto;
|
let linkWithPassword: SharedLinkResponseDto;
|
||||||
@@ -41,18 +40,9 @@ describe('/shared-links', () => {
|
|||||||
|
|
||||||
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
|
[asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]);
|
||||||
|
|
||||||
[album, deletedAlbum, metadataAlbum] = await Promise.all([
|
[album, deletedAlbum] = await Promise.all([
|
||||||
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }),
|
||||||
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
createAlbum({ createAlbumDto: { albumName: 'deleted album' } }, { headers: asBearerAuth(user2.accessToken) }),
|
||||||
createAlbum(
|
|
||||||
{
|
|
||||||
createAlbumDto: {
|
|
||||||
albumName: 'metadata album',
|
|
||||||
assetIds: [asset1.id],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ headers: asBearerAuth(user1.accessToken) },
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
|
||||||
@@ -75,14 +65,14 @@ describe('/shared-links', () => {
|
|||||||
password: 'foo',
|
password: 'foo',
|
||||||
}),
|
}),
|
||||||
utils.createSharedLink(user1.accessToken, {
|
utils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Album,
|
type: SharedLinkType.Individual,
|
||||||
albumId: metadataAlbum.id,
|
assetIds: [asset1.id],
|
||||||
showMetadata: true,
|
showMetadata: true,
|
||||||
slug: 'metadata-album',
|
slug: 'metadata-slug',
|
||||||
}),
|
}),
|
||||||
utils.createSharedLink(user1.accessToken, {
|
utils.createSharedLink(user1.accessToken, {
|
||||||
type: SharedLinkType.Album,
|
type: SharedLinkType.Individual,
|
||||||
albumId: metadataAlbum.id,
|
assetIds: [asset1.id],
|
||||||
showMetadata: false,
|
showMetadata: false,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -95,9 +85,7 @@ describe('/shared-links', () => {
|
|||||||
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
|
||||||
expect(resp.status).toBe(200);
|
expect(resp.status).toBe(200);
|
||||||
expect(resp.header['content-type']).toContain('text/html');
|
expect(resp.header['content-type']).toContain('text/html');
|
||||||
expect(resp.text).toContain(
|
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct asset count in meta tag for empty album', async () => {
|
it('should have correct asset count in meta tag for empty album', async () => {
|
||||||
@@ -144,9 +132,7 @@ describe('/shared-links', () => {
|
|||||||
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
|
const resp = await request(baseUrl).get(`/s/${linkWithMetadata.slug}`);
|
||||||
expect(resp.status).toBe(200);
|
expect(resp.status).toBe(200);
|
||||||
expect(resp.header['content-type']).toContain('text/html');
|
expect(resp.header['content-type']).toContain('text/html');
|
||||||
expect(resp.text).toContain(
|
expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`);
|
||||||
`<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,12 +257,12 @@ describe('/shared-links', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return metadata for album shared link', async () => {
|
it('should return metadata for individual shared link', async () => {
|
||||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
|
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.assets).toHaveLength(0);
|
expect(body.assets).toHaveLength(1);
|
||||||
expect(body.album).toBeDefined();
|
expect(body.album).not.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not return metadata for album shared link without metadata', async () => {
|
it('should not return metadata for album shared link without metadata', async () => {
|
||||||
@@ -284,7 +270,7 @@ describe('/shared-links', () => {
|
|||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.assets).toHaveLength(1);
|
expect(body.assets).toHaveLength(1);
|
||||||
expect(body.album).toBeDefined();
|
expect(body.album).not.toBeDefined();
|
||||||
|
|
||||||
const asset = body.assets[0];
|
const asset = body.assets[0];
|
||||||
expect(asset).not.toHaveProperty('exifInfo');
|
expect(asset).not.toHaveProperty('exifInfo');
|
||||||
|
|||||||
@@ -26,6 +26,5 @@ export const makeRandomImage = () => {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error('Ran out of random asset data');
|
throw new Error('Ran out of random asset data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -346,6 +346,9 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
|||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
resized: true,
|
resized: true,
|
||||||
checksum: asset.checksum,
|
checksum: asset.checksum,
|
||||||
|
width: exifInfo.exifImageWidth ?? 1,
|
||||||
|
height: exifInfo.exifImageHeight ?? 1,
|
||||||
|
isEdited: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AssetResponseDto } from '@immich/sdk';
|
||||||
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
import { BrowserContext, Page, Request, Route } from '@playwright/test';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -63,15 +64,33 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/*', async (route, request) => {
|
await context.route('**/api/assets/*', async (route, request) => {
|
||||||
const url = new URL(request.url());
|
if (request.method() === 'GET') {
|
||||||
const pathname = url.pathname;
|
const url = new URL(request.url());
|
||||||
const assetId = basename(pathname);
|
const pathname = url.pathname;
|
||||||
const asset = getAsset(timelineRestData, assetId);
|
const assetId = basename(pathname);
|
||||||
return route.fulfill({
|
let asset = getAsset(timelineRestData, assetId);
|
||||||
status: 200,
|
if (changes.assetDeletions.includes(asset!.id)) {
|
||||||
contentType: 'application/json',
|
asset = {
|
||||||
json: asset,
|
...asset,
|
||||||
});
|
isTrashed: true,
|
||||||
|
} as AssetResponseDto;
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: asset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets', async (route, request) => {
|
||||||
|
if (request.method() === 'DELETE') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await route.fallback();
|
||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/*/ocr', async (route) => {
|
await context.route('**/api/assets/*/ocr', async (route) => {
|
||||||
@@ -117,17 +136,28 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/albums/**', async (route, request) => {
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||||
const match = request.url().match(pattern);
|
if (albumsMatch) {
|
||||||
if (!match) {
|
const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
|
||||||
return route.continue();
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: album,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
|
return route.fallback();
|
||||||
return route.fulfill({
|
});
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
await context.route('**/api/albums**', async (route, request) => {
|
||||||
json: album,
|
const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
|
||||||
});
|
if (allAlbums) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+93
-8
@@ -6,7 +6,9 @@ import {
|
|||||||
CheckExistingAssetsDto,
|
CheckExistingAssetsDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
|
JobCreateDto,
|
||||||
MaintenanceAction,
|
MaintenanceAction,
|
||||||
|
ManualJobName,
|
||||||
MetadataSearchDto,
|
MetadataSearchDto,
|
||||||
Permission,
|
Permission,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
checkExistingAssets,
|
checkExistingAssets,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
|
createJob,
|
||||||
createLibrary,
|
createLibrary,
|
||||||
createPartner,
|
createPartner,
|
||||||
createPerson,
|
createPerson,
|
||||||
@@ -28,10 +31,12 @@ import {
|
|||||||
createStack,
|
createStack,
|
||||||
createUserAdmin,
|
createUserAdmin,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
|
deleteDatabaseBackup,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
getQueuesLegacy,
|
getQueuesLegacy,
|
||||||
|
listDatabaseBackups,
|
||||||
login,
|
login,
|
||||||
runQueueCommandLegacy,
|
runQueueCommandLegacy,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
@@ -52,11 +57,15 @@ import {
|
|||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
import { exec, spawn } from 'node:child_process';
|
import { exec, spawn } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { mkdtemp } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
import { createGzip } from 'node:zlib';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import { loginDto, signupDto } from 'src/fixtures';
|
import { loginDto, signupDto } from 'src/fixtures';
|
||||||
@@ -84,8 +93,9 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
|
|||||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||||
export const immichCli = (args: string[]) =>
|
export const immichCli = (args: string[]) =>
|
||||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||||
export const immichAdmin = (args: string[]) =>
|
export const dockerExec = (args: string[]) =>
|
||||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
||||||
|
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
|
||||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
@@ -149,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
connectDatabase: async () => {
|
||||||
|
if (!client) {
|
||||||
|
client = new pg.Client(dbUrl);
|
||||||
|
client.on('end', () => (client = null));
|
||||||
|
client.on('error', () => (client = null));
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnectDatabase: async () => {
|
||||||
|
if (client) {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
resetDatabase: async (tables?: string[]) => {
|
resetDatabase: async (tables?: string[]) => {
|
||||||
try {
|
try {
|
||||||
if (!client) {
|
client = await utils.connectDatabase();
|
||||||
client = new pg.Client(dbUrl);
|
|
||||||
await client.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
tables = tables || [
|
tables = tables || [
|
||||||
// TODO e2e test for deleting a stack, since it is quite complex
|
// TODO e2e test for deleting a stack, since it is quite complex
|
||||||
@@ -481,6 +505,9 @@ export const utils = {
|
|||||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
|
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
|
||||||
|
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||||
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
@@ -559,6 +586,45 @@ export const utils = {
|
|||||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async move(source: string, dest: string) {
|
||||||
|
return executeCommand('docker', ['exec', 'immich-e2e-server', 'mv', source, dest]).promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
createBackup: async (accessToken: string) => {
|
||||||
|
await utils.createJob(accessToken, {
|
||||||
|
name: ManualJobName.BackupDatabase,
|
||||||
|
});
|
||||||
|
|
||||||
|
return utils.poll(
|
||||||
|
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||||
|
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||||
|
({ body }) => body.backups[0].filename,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetBackups: async (accessToken: string) => {
|
||||||
|
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||||
|
|
||||||
|
const backupFiles = backups.map((b) => b.filename);
|
||||||
|
await deleteDatabaseBackup(
|
||||||
|
{ databaseBackupDeleteDto: { backups: backupFiles } },
|
||||||
|
{ headers: asBearerAuth(accessToken) },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
prepareTestBackup: async (generate: 'empty' | 'corrupted') => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||||
|
const fn = join(dir, 'file');
|
||||||
|
|
||||||
|
const sql = Readable.from(generate === 'corrupted' ? 'IM CORRUPTED;' : 'SELECT 1;');
|
||||||
|
const gzip = createGzip();
|
||||||
|
const writeStream = createWriteStream(fn);
|
||||||
|
await pipeline(sql, gzip, writeStream);
|
||||||
|
|
||||||
|
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
|
||||||
|
.promise;
|
||||||
|
},
|
||||||
|
|
||||||
resetAdminConfig: async (accessToken: string) => {
|
resetAdminConfig: async (accessToken: string) => {
|
||||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||||
@@ -601,6 +667,25 @@ export const utils = {
|
|||||||
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
||||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
|
||||||
|
let timeout = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const data = await cb();
|
||||||
|
if (validate(data)) {
|
||||||
|
return map ? map(data) : data;
|
||||||
|
}
|
||||||
|
timeout++;
|
||||||
|
if (timeout >= 10) {
|
||||||
|
throw 'Could not clean up test.';
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5e2));
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
createDefaultTimelineConfig,
|
||||||
|
generateTimelineData,
|
||||||
|
SeededRandom,
|
||||||
|
selectRandom,
|
||||||
|
TimelineAssetConfig,
|
||||||
|
TimelineData,
|
||||||
|
} from 'src/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
|
||||||
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
import { assetViewerUtils, cancelAllPollers } from 'src/web/specs/timeline/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
test.describe('asset-viewer', () => {
|
||||||
|
const rng = new SeededRandom(529);
|
||||||
|
let adminUserId: string;
|
||||||
|
let timelineRestData: TimelineData;
|
||||||
|
const assets: TimelineAssetConfig[] = [];
|
||||||
|
const yearMonths: string[] = [];
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
const changes: Changes = {
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
adminUserId = faker.string.uuid();
|
||||||
|
testContext.adminId = adminUserId;
|
||||||
|
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||||
|
for (const timeBucket of timelineRestData.buckets.values()) {
|
||||||
|
assets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
for (const yearMonth of timelineRestData.buckets.keys()) {
|
||||||
|
const [year, month] = yearMonth.split('-');
|
||||||
|
yearMonths.push(`${year}-${Number(month)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBaseMockApiRoutes(context, adminUserId);
|
||||||
|
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
cancelAllPollers();
|
||||||
|
testContext.slowBucket = false;
|
||||||
|
changes.albumAdditions = [];
|
||||||
|
changes.assetDeletions = [];
|
||||||
|
changes.assetArchivals = [];
|
||||||
|
changes.assetFavorites = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('/photos/:id', () => {
|
||||||
|
test('Navigate to next asset via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.getByLabel('View next asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate to previous asset via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.getByLabel('View previous asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate to next asset via keyboard (ArrowRight)', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate to previous asset via keyboard (ArrowLeft)', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - 1].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate forward 5 times via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await page.getByLabel('View next asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index + i].id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate backward 5 times via button', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
await page.getByLabel('View previous asset').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - i]);
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${assets[index - i].id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Navigate forward then backward via keyboard', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
|
||||||
|
// Navigate forward 3 times
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate backward 3 times to return to original
|
||||||
|
for (let i = 2; i >= 0; i--) {
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we're back at the original asset
|
||||||
|
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Verify no next button on last asset', async ({ page }) => {
|
||||||
|
const lastAsset = assets.at(-1)!;
|
||||||
|
await page.goto(`/photos/${lastAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
|
||||||
|
|
||||||
|
// Verify next button doesn't exist
|
||||||
|
await expect(page.getByLabel('View next asset')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Verify no previous button on first asset', async ({ page }) => {
|
||||||
|
const firstAsset = assets[0];
|
||||||
|
await page.goto(`/photos/${firstAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, firstAsset);
|
||||||
|
|
||||||
|
// Verify previous button doesn't exist
|
||||||
|
await expect(page.getByLabel('View previous asset')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Delete photo advances to next', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
});
|
||||||
|
test('Delete photo advances to next (2x)', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||||
|
});
|
||||||
|
test('Delete last photo advances to prev', async ({ page }) => {
|
||||||
|
const asset = assets.at(-1)!;
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
});
|
||||||
|
test('Delete last photo advances to prev (2x)', async ({ page }) => {
|
||||||
|
const asset = assets.at(-1)!;
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 1]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index - 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.describe('/trash/photos/:id', () => {
|
||||||
|
test('Delete trashed photo advances to next', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
});
|
||||||
|
test('Delete trashed photo advances to next 2x', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 1]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 2]);
|
||||||
|
});
|
||||||
|
test('Delete trashed photo advances to prev', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||||
|
});
|
||||||
|
test('Delete trashed photo advances to prev 2x', async ({ page }) => {
|
||||||
|
const asset = selectRandom(assets, rng);
|
||||||
|
const index = assets.indexOf(asset);
|
||||||
|
const deletedAssets = assets.slice(index - 10, index + 10).map((asset) => asset.id);
|
||||||
|
changes.assetDeletions.push(...deletedAssets);
|
||||||
|
await page.goto(`/trash/photos/${assets[index + 9].id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 9]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 8]);
|
||||||
|
await page.getByLabel('Delete').click();
|
||||||
|
// confirm dialog
|
||||||
|
await page.getByRole('button').getByText('Delete').click();
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, assets[index + 7]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Database Backups', () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore a backup from settings', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
// work-around until test is running on released version
|
||||||
|
await utils.move(
|
||||||
|
`/data/backups/${filename}`,
|
||||||
|
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle backup restore failure', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
await utils.prepareTestBackup('corrupted');
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('/admin/maintenance**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rollback to restore point if backup is missing admin', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
await utils.prepareTestBackup('empty');
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await expect(page.getByText('Server health check failed, no admin exists.')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('/admin/maintenance**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore a backup from onboarding', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
// work-around until test is running on released version
|
||||||
|
await utils.move(
|
||||||
|
`/data/backups/${filename}`,
|
||||||
|
'/data/backups/immich-db-backup-20260114T184016-v2.5.0-pg14.19.sql.gz',
|
||||||
|
);
|
||||||
|
|
||||||
|
await utils.resetDatabase();
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.waitForURL('/maintenance**');
|
||||||
|
} catch {
|
||||||
|
// when chained with the rest of the tests
|
||||||
|
// this navigation may fail..? not sure why...
|
||||||
|
await page.goto('/maintenance');
|
||||||
|
await page.waitForURL('/maintenance**');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await page.waitForURL('/photos', { timeout: 60_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
|
|||||||
test('enter and exit maintenance mode', async ({ context, page }) => {
|
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
await page.goto('/admin/system-settings?isOpen=maintenance');
|
await page.goto('/admin/maintenance');
|
||||||
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Page, expect, test } from '@playwright/test';
|
|||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
function imageLocator(page: Page) {
|
function imageLocator(page: Page) {
|
||||||
return page.getByAltText('Image taken on').locator('visible=true');
|
return page.getByAltText('Image taken').locator('visible=true');
|
||||||
}
|
}
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ test.describe('Timeline', () => {
|
|||||||
});
|
});
|
||||||
changes.albumAdditions.push(...requestJson.ids);
|
changes.albumAdditions.push(...requestJson.ids);
|
||||||
});
|
});
|
||||||
await page.getByText('Done').click();
|
await page.getByText('Add assets').click();
|
||||||
await expect(put).resolves.toEqual({
|
await expect(put).resolves.toEqual({
|
||||||
ids: [
|
ids: [
|
||||||
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
||||||
|
|||||||
@@ -181,8 +181,12 @@ export const assetViewerUtils = {
|
|||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
await page
|
await page
|
||||||
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
|
.locator(
|
||||||
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
|
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||||
|
)
|
||||||
|
.or(
|
||||||
|
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||||
|
)
|
||||||
.waitFor();
|
.waitFor();
|
||||||
},
|
},
|
||||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ test.describe('User Administration', () => {
|
|||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByLabel('Admin User').click();
|
await page.getByLabel('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -85,7 +85,7 @@ test.describe('User Administration', () => {
|
|||||||
await expect(page.getByLabel('Admin User')).toBeChecked();
|
await expect(page.getByLabel('Admin User')).toBeChecked();
|
||||||
await page.getByLabel('Admin User').click();
|
await page.getByLabel('Admin User').click();
|
||||||
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
await expect(page.getByLabel('Admin User')).not.toBeChecked();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
// skip `docker compose up` if `make e2e` was already run
|
// skip `docker compose up` if `make e2e` was already run
|
||||||
const globalSetup: string[] = ['src/setup/auth-server.ts'];
|
const globalSetup: string[] = [];
|
||||||
try {
|
try {
|
||||||
await fetch('http://127.0.0.1:2285/api/server-info/ping');
|
await fetch('http://127.0.0.1:2285/api/server-info/ping');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"jsonRecursiveSort": true,
|
||||||
|
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
|
||||||
|
"plugins": ["prettier-plugin-sort-json"]
|
||||||
|
}
|
||||||
+84
-9
@@ -18,6 +18,7 @@
|
|||||||
"add_a_title": "Add a title",
|
"add_a_title": "Add a title",
|
||||||
"add_action": "Add action",
|
"add_action": "Add action",
|
||||||
"add_action_description": "Click to add an action to perform",
|
"add_action_description": "Click to add an action to perform",
|
||||||
|
"add_assets": "Add assets",
|
||||||
"add_birthday": "Add a birthday",
|
"add_birthday": "Add a birthday",
|
||||||
"add_endpoint": "Add endpoint",
|
"add_endpoint": "Add endpoint",
|
||||||
"add_exclusion_pattern": "Add exclusion pattern",
|
"add_exclusion_pattern": "Add exclusion pattern",
|
||||||
@@ -187,10 +188,21 @@
|
|||||||
"machine_learning_smart_search_enabled": "Enable smart search",
|
"machine_learning_smart_search_enabled": "Enable smart search",
|
||||||
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
||||||
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
||||||
|
"maintenance_delete_backup": "Delete Backup",
|
||||||
|
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
|
||||||
|
"maintenance_delete_error": "Failed to delete backup.",
|
||||||
|
"maintenance_restore_backup": "Restore Backup",
|
||||||
|
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
|
||||||
|
"maintenance_restore_backup_different_version": "This backup was created with a different version of Immich!",
|
||||||
|
"maintenance_restore_backup_unknown_version": "Couldn't determine backup version.",
|
||||||
|
"maintenance_restore_database_backup": "Restore database backup",
|
||||||
|
"maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
|
||||||
"maintenance_settings": "Maintenance",
|
"maintenance_settings": "Maintenance",
|
||||||
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
||||||
"maintenance_start": "Start maintenance mode",
|
"maintenance_start": "Switch to maintenance mode",
|
||||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||||
|
"maintenance_upload_backup": "Upload database backup file",
|
||||||
|
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
|
||||||
"manage_concurrency": "Manage Concurrency",
|
"manage_concurrency": "Manage Concurrency",
|
||||||
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
||||||
"manage_log_settings": "Manage log settings",
|
"manage_log_settings": "Manage log settings",
|
||||||
@@ -478,6 +490,7 @@
|
|||||||
"album_summary": "Album summary",
|
"album_summary": "Album summary",
|
||||||
"album_updated": "Album updated",
|
"album_updated": "Album updated",
|
||||||
"album_updated_setting_description": "Receive an email notification when a shared album has new assets",
|
"album_updated_setting_description": "Receive an email notification when a shared album has new assets",
|
||||||
|
"album_upload_assets": "Upload assets from your computer and add to album",
|
||||||
"album_user_left": "Left {album}",
|
"album_user_left": "Left {album}",
|
||||||
"album_user_removed": "Removed {user}",
|
"album_user_removed": "Removed {user}",
|
||||||
"album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?",
|
"album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?",
|
||||||
@@ -601,7 +614,7 @@
|
|||||||
"backup_album_selection_page_select_albums": "Select albums",
|
"backup_album_selection_page_select_albums": "Select albums",
|
||||||
"backup_album_selection_page_selection_info": "Selection Info",
|
"backup_album_selection_page_selection_info": "Selection Info",
|
||||||
"backup_album_selection_page_total_assets": "Total unique assets",
|
"backup_album_selection_page_total_assets": "Total unique assets",
|
||||||
"backup_albums_sync": "Backup albums synchronization",
|
"backup_albums_sync": "Backup Albums Synchronization",
|
||||||
"backup_all": "All",
|
"backup_all": "All",
|
||||||
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
|
||||||
"backup_background_service_complete_notification": "Asset backup complete",
|
"backup_background_service_complete_notification": "Asset backup complete",
|
||||||
@@ -734,6 +747,18 @@
|
|||||||
"checksum": "Checksum",
|
"checksum": "Checksum",
|
||||||
"choose_matching_people_to_merge": "Choose matching people to merge",
|
"choose_matching_people_to_merge": "Choose matching people to merge",
|
||||||
"city": "City",
|
"city": "City",
|
||||||
|
"cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?",
|
||||||
|
"cleanup_confirm_prompt_title": "Remove from this device?",
|
||||||
|
"cleanup_deleted_assets": "Moved {count} assets to device trash",
|
||||||
|
"cleanup_deleting": "Moving to trash...",
|
||||||
|
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
|
||||||
|
"cleanup_found_assets": "Found {count} backed up assets",
|
||||||
|
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
|
||||||
|
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
|
||||||
|
"cleanup_preview_title": "Assets to remove ({count})",
|
||||||
|
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
|
||||||
|
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
|
||||||
|
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"clear_all": "Clear all",
|
"clear_all": "Clear all",
|
||||||
"clear_all_recent_searches": "Clear all recent searches",
|
"clear_all_recent_searches": "Clear all recent searches",
|
||||||
@@ -819,13 +844,20 @@
|
|||||||
"created_at": "Created",
|
"created_at": "Created",
|
||||||
"creating_linked_albums": "Creating linked albums...",
|
"creating_linked_albums": "Creating linked albums...",
|
||||||
"crop": "Crop",
|
"crop": "Crop",
|
||||||
|
"crop_aspect_ratio_fixed": "Fixed",
|
||||||
|
"crop_aspect_ratio_free": "Free",
|
||||||
|
"crop_aspect_ratio_original": "Original",
|
||||||
"curated_object_page_title": "Things",
|
"curated_object_page_title": "Things",
|
||||||
"current_device": "Current device",
|
"current_device": "Current device",
|
||||||
"current_pin_code": "Current PIN code",
|
"current_pin_code": "Current PIN code",
|
||||||
"current_server_address": "Current server address",
|
"current_server_address": "Current server address",
|
||||||
|
"custom_date": "Custom date",
|
||||||
"custom_locale": "Custom Locale",
|
"custom_locale": "Custom Locale",
|
||||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||||
"custom_url": "Custom URL",
|
"custom_url": "Custom URL",
|
||||||
|
"cutoff_date_description": "Remove photos and videos older than",
|
||||||
|
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||||
|
"cutoff_year": "{count, plural, one {year} other {years}}",
|
||||||
"daily_title_text_date": "E, MMM dd",
|
"daily_title_text_date": "E, MMM dd",
|
||||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
@@ -907,6 +939,7 @@
|
|||||||
"download_include_embedded_motion_videos": "Embedded videos",
|
"download_include_embedded_motion_videos": "Embedded videos",
|
||||||
"download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file",
|
"download_include_embedded_motion_videos_description": "Include videos embedded in motion photos as a separate file",
|
||||||
"download_notfound": "Download not found",
|
"download_notfound": "Download not found",
|
||||||
|
"download_original": "Download original",
|
||||||
"download_paused": "Download paused",
|
"download_paused": "Download paused",
|
||||||
"download_settings": "Download",
|
"download_settings": "Download",
|
||||||
"download_settings_description": "Manage settings related to asset download",
|
"download_settings_description": "Manage settings related to asset download",
|
||||||
@@ -916,6 +949,7 @@
|
|||||||
"download_waiting_to_retry": "Waiting to retry",
|
"download_waiting_to_retry": "Waiting to retry",
|
||||||
"downloading": "Downloading",
|
"downloading": "Downloading",
|
||||||
"downloading_asset_filename": "Downloading asset {filename}",
|
"downloading_asset_filename": "Downloading asset {filename}",
|
||||||
|
"downloading_from_icloud": "Downloading from iCloud",
|
||||||
"downloading_media": "Downloading media",
|
"downloading_media": "Downloading media",
|
||||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||||
"duplicates": "Duplicates",
|
"duplicates": "Duplicates",
|
||||||
@@ -948,9 +982,13 @@
|
|||||||
"editor": "Editor",
|
"editor": "Editor",
|
||||||
"editor_close_without_save_prompt": "The changes will not be saved",
|
"editor_close_without_save_prompt": "The changes will not be saved",
|
||||||
"editor_close_without_save_title": "Close editor?",
|
"editor_close_without_save_title": "Close editor?",
|
||||||
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
|
"editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
|
||||||
"editor_crop_tool_h2_rotation": "Rotation",
|
"editor_flip_horizontal": "Flip horizontal",
|
||||||
"editor_mode": "Editor mode",
|
"editor_flip_vertical": "Flip vertical",
|
||||||
|
"editor_orientation": "Orientation",
|
||||||
|
"editor_reset_all_changes": "Reset changes",
|
||||||
|
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||||
|
"editor_rotate_right": "Rotate 90° clockwise",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"email_notifications": "Email notifications",
|
"email_notifications": "Email notifications",
|
||||||
"empty_folder": "This folder is empty",
|
"empty_folder": "This folder is empty",
|
||||||
@@ -1082,6 +1120,7 @@
|
|||||||
"unable_to_scan_library": "Unable to scan library",
|
"unable_to_scan_library": "Unable to scan library",
|
||||||
"unable_to_set_feature_photo": "Unable to set feature photo",
|
"unable_to_set_feature_photo": "Unable to set feature photo",
|
||||||
"unable_to_set_profile_picture": "Unable to set profile picture",
|
"unable_to_set_profile_picture": "Unable to set profile picture",
|
||||||
|
"unable_to_set_rating": "Unable to set rating",
|
||||||
"unable_to_submit_job": "Unable to submit job",
|
"unable_to_submit_job": "Unable to submit job",
|
||||||
"unable_to_trash_asset": "Unable to trash asset",
|
"unable_to_trash_asset": "Unable to trash asset",
|
||||||
"unable_to_unlink_account": "Unable to unlink account",
|
"unable_to_unlink_account": "Unable to unlink account",
|
||||||
@@ -1096,6 +1135,7 @@
|
|||||||
"unable_to_update_workflow": "Unable to update workflow",
|
"unable_to_update_workflow": "Unable to update workflow",
|
||||||
"unable_to_upload_file": "Unable to upload file"
|
"unable_to_upload_file": "Unable to upload file"
|
||||||
},
|
},
|
||||||
|
"errors_text": "Errors",
|
||||||
"exclusion_pattern": "Exclusion pattern",
|
"exclusion_pattern": "Exclusion pattern",
|
||||||
"exif": "Exif",
|
"exif": "Exif",
|
||||||
"exif_bottom_sheet_description": "Add Description...",
|
"exif_bottom_sheet_description": "Add Description...",
|
||||||
@@ -1140,13 +1180,14 @@
|
|||||||
"features": "Features",
|
"features": "Features",
|
||||||
"features_in_development": "Features in Development",
|
"features_in_development": "Features in Development",
|
||||||
"features_setting_description": "Manage the app features",
|
"features_setting_description": "Manage the app features",
|
||||||
"file_name": "File name",
|
"file_name": "File name: {file_name}",
|
||||||
"file_name_or_extension": "File name or extension",
|
"file_name_or_extension": "File name or extension",
|
||||||
"file_size": "File size",
|
"file_size": "File size",
|
||||||
"filename": "Filename",
|
"filename": "Filename",
|
||||||
"filetype": "Filetype",
|
"filetype": "Filetype",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"filter_description": "Conditions to filter the target assets",
|
"filter_description": "Conditions to filter the target assets",
|
||||||
|
"filter_options": "Filter options",
|
||||||
"filter_people": "Filter people",
|
"filter_people": "Filter people",
|
||||||
"filter_places": "Filter places",
|
"filter_places": "Filter places",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
@@ -1159,6 +1200,9 @@
|
|||||||
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
|
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
|
||||||
"forgot_pin_code_question": "Forgot your PIN?",
|
"forgot_pin_code_question": "Forgot your PIN?",
|
||||||
"forward": "Forward",
|
"forward": "Forward",
|
||||||
|
"free_up_space": "Free Up Space",
|
||||||
|
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
|
||||||
|
"free_up_space_settings_subtitle": "Free up device storage",
|
||||||
"full_path": "Full path: {path}",
|
"full_path": "Full path: {path}",
|
||||||
"gcast_enabled": "Google Cast",
|
"gcast_enabled": "Google Cast",
|
||||||
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
|
||||||
@@ -1275,6 +1319,8 @@
|
|||||||
"json_error": "JSON error",
|
"json_error": "JSON error",
|
||||||
"keep": "Keep",
|
"keep": "Keep",
|
||||||
"keep_all": "Keep All",
|
"keep_all": "Keep All",
|
||||||
|
"keep_favorites": "Keep favorites",
|
||||||
|
"keep_favorites_description": "Favorite assets will not be deleted from your device",
|
||||||
"keep_this_delete_others": "Keep this, delete others",
|
"keep_this_delete_others": "Keep this, delete others",
|
||||||
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
|
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
|
||||||
"keyboard_shortcuts": "Keyboard shortcuts",
|
"keyboard_shortcuts": "Keyboard shortcuts",
|
||||||
@@ -1369,10 +1415,28 @@
|
|||||||
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
||||||
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
||||||
"main_menu": "Main menu",
|
"main_menu": "Main menu",
|
||||||
|
"maintenance_action_restore": "Restoring Database",
|
||||||
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
||||||
"maintenance_end": "End maintenance mode",
|
"maintenance_end": "End maintenance mode",
|
||||||
"maintenance_end_error": "Failed to end maintenance mode.",
|
"maintenance_end_error": "Failed to end maintenance mode.",
|
||||||
"maintenance_logged_in_as": "Currently logged in as {user}",
|
"maintenance_logged_in_as": "Currently logged in as {user}",
|
||||||
|
"maintenance_restore_from_backup": "Restore From Backup",
|
||||||
|
"maintenance_restore_library": "Restore Your Library",
|
||||||
|
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
|
||||||
|
"maintenance_restore_library_description": "Restoring Database",
|
||||||
|
"maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
|
||||||
|
"maintenance_restore_library_folder_no_files": "{folder} is missing files!",
|
||||||
|
"maintenance_restore_library_folder_pass": "readable and writable",
|
||||||
|
"maintenance_restore_library_folder_read_fail": "not readable",
|
||||||
|
"maintenance_restore_library_folder_write_fail": "not writable",
|
||||||
|
"maintenance_restore_library_hint_missing_files": "You may be missing important files",
|
||||||
|
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
|
||||||
|
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
|
||||||
|
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
|
||||||
|
"maintenance_task_backup": "Creating a backup of the existing database…",
|
||||||
|
"maintenance_task_migrations": "Running database migrations…",
|
||||||
|
"maintenance_task_restore": "Restoring the chosen backup…",
|
||||||
|
"maintenance_task_rollback": "Restore failed, rolling back to restore point…",
|
||||||
"maintenance_title": "Temporarily Unavailable",
|
"maintenance_title": "Temporarily Unavailable",
|
||||||
"make": "Make",
|
"make": "Make",
|
||||||
"manage_geolocation": "Manage location",
|
"manage_geolocation": "Manage location",
|
||||||
@@ -1434,6 +1498,8 @@
|
|||||||
"minimize": "Minimize",
|
"minimize": "Minimize",
|
||||||
"minute": "Minute",
|
"minute": "Minute",
|
||||||
"minutes": "Minutes",
|
"minutes": "Minutes",
|
||||||
|
"mirror_horizontal": "Horizontal",
|
||||||
|
"mirror_vertical": "Vertical",
|
||||||
"missing": "Missing",
|
"missing": "Missing",
|
||||||
"mobile_app": "Mobile App",
|
"mobile_app": "Mobile App",
|
||||||
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
|
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
|
||||||
@@ -1445,6 +1511,7 @@
|
|||||||
"move_down": "Move down",
|
"move_down": "Move down",
|
||||||
"move_off_locked_folder": "Move out of locked folder",
|
"move_off_locked_folder": "Move out of locked folder",
|
||||||
"move_to": "Move to",
|
"move_to": "Move to",
|
||||||
|
"move_to_device_trash": "Move to device trash",
|
||||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||||
"move_to_locked_folder": "Move to locked folder",
|
"move_to_locked_folder": "Move to locked folder",
|
||||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||||
@@ -1627,6 +1694,7 @@
|
|||||||
"photos_and_videos": "Photos & Videos",
|
"photos_and_videos": "Photos & Videos",
|
||||||
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
|
"photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}",
|
||||||
"photos_from_previous_years": "Photos from previous years",
|
"photos_from_previous_years": "Photos from previous years",
|
||||||
|
"photos_only": "Photos only",
|
||||||
"pick_a_location": "Pick a location",
|
"pick_a_location": "Pick a location",
|
||||||
"pick_custom_range": "Custom range",
|
"pick_custom_range": "Custom range",
|
||||||
"pick_date_range": "Select a date range",
|
"pick_date_range": "Select a date range",
|
||||||
@@ -1702,10 +1770,12 @@
|
|||||||
"purchase_settings_server_activated": "The server product key is managed by the admin",
|
"purchase_settings_server_activated": "The server product key is managed by the admin",
|
||||||
"query_asset_id": "Query Asset ID",
|
"query_asset_id": "Query Asset ID",
|
||||||
"queue_status": "Queuing {count}/{total}",
|
"queue_status": "Queuing {count}/{total}",
|
||||||
|
"rate_asset": "Rate Asset",
|
||||||
"rating": "Star rating",
|
"rating": "Star rating",
|
||||||
"rating_clear": "Clear rating",
|
"rating_clear": "Clear rating",
|
||||||
"rating_count": "{count, plural, one {# star} other {# stars}}",
|
"rating_count": "{count, plural, one {# star} other {# stars}}",
|
||||||
"rating_description": "Display the EXIF rating in the info panel",
|
"rating_description": "Display the EXIF rating in the info panel",
|
||||||
|
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
|
||||||
"reaction_options": "Reaction options",
|
"reaction_options": "Reaction options",
|
||||||
"read_changelog": "Read Changelog",
|
"read_changelog": "Read Changelog",
|
||||||
"readonly_mode_disabled": "Read-only mode disabled",
|
"readonly_mode_disabled": "Read-only mode disabled",
|
||||||
@@ -1805,9 +1875,11 @@
|
|||||||
"saved_settings": "Saved settings",
|
"saved_settings": "Saved settings",
|
||||||
"say_something": "Say something",
|
"say_something": "Say something",
|
||||||
"scaffold_body_error_occurred": "Error occurred",
|
"scaffold_body_error_occurred": "Error occurred",
|
||||||
|
"scan": "Scan",
|
||||||
"scan_all_libraries": "Scan All Libraries",
|
"scan_all_libraries": "Scan All Libraries",
|
||||||
"scan_library": "Scan",
|
"scan_library": "Scan",
|
||||||
"scan_settings": "Scan Settings",
|
"scan_settings": "Scan Settings",
|
||||||
|
"scanning": "Scanning",
|
||||||
"scanning_for_album": "Scanning for album...",
|
"scanning_for_album": "Scanning for album...",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"search_albums": "Search albums",
|
"search_albums": "Search albums",
|
||||||
@@ -1879,6 +1951,7 @@
|
|||||||
"select_all_in": "Select all in {group}",
|
"select_all_in": "Select all in {group}",
|
||||||
"select_avatar_color": "Select avatar color",
|
"select_avatar_color": "Select avatar color",
|
||||||
"select_count": "{count, plural, one {Select #} other {Select #}}",
|
"select_count": "{count, plural, one {Select #} other {Select #}}",
|
||||||
|
"select_cutoff_date": "Select cutoff date",
|
||||||
"select_face": "Select face",
|
"select_face": "Select face",
|
||||||
"select_featured_photo": "Select featured photo",
|
"select_featured_photo": "Select featured photo",
|
||||||
"select_from_computer": "Select from computer",
|
"select_from_computer": "Select from computer",
|
||||||
@@ -2153,7 +2226,7 @@
|
|||||||
"trigger": "Trigger",
|
"trigger": "Trigger",
|
||||||
"trigger_asset_uploaded": "Asset Uploaded",
|
"trigger_asset_uploaded": "Asset Uploaded",
|
||||||
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
|
||||||
"trigger_description": "An event that kick off the workflow",
|
"trigger_description": "An event that kicks off the workflow",
|
||||||
"trigger_person_recognized": "Person Recognized",
|
"trigger_person_recognized": "Person Recognized",
|
||||||
"trigger_person_recognized_description": "Triggered when a person is detected",
|
"trigger_person_recognized_description": "Triggered when a person is detected",
|
||||||
"trigger_type": "Trigger type",
|
"trigger_type": "Trigger type",
|
||||||
@@ -2171,6 +2244,7 @@
|
|||||||
"unhide_person": "Unhide person",
|
"unhide_person": "Unhide person",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"unknown_country": "Unknown Country",
|
"unknown_country": "Unknown Country",
|
||||||
|
"unknown_date": "Unknown date",
|
||||||
"unknown_year": "Unknown Year",
|
"unknown_year": "Unknown Year",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"unlink_motion_video": "Unlink motion video",
|
"unlink_motion_video": "Unlink motion video",
|
||||||
@@ -2195,7 +2269,6 @@
|
|||||||
"updated_at": "Updated",
|
"updated_at": "Updated",
|
||||||
"updated_password": "Updated password",
|
"updated_password": "Updated password",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"upload_action_prompt": "{count} queued for upload",
|
|
||||||
"upload_concurrency": "Upload concurrency",
|
"upload_concurrency": "Upload concurrency",
|
||||||
"upload_details": "Upload Details",
|
"upload_details": "Upload Details",
|
||||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||||
@@ -2214,7 +2287,7 @@
|
|||||||
"url": "URL",
|
"url": "URL",
|
||||||
"usage": "Usage",
|
"usage": "Usage",
|
||||||
"use_biometric": "Use biometric",
|
"use_biometric": "Use biometric",
|
||||||
"use_current_connection": "use current connection",
|
"use_current_connection": "Use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"user_has_been_deleted": "This user has been deleted.",
|
"user_has_been_deleted": "This user has been deleted.",
|
||||||
@@ -2247,6 +2320,7 @@
|
|||||||
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
|
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
|
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
|
||||||
|
"videos_only": "Videos only",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"view_album": "View Album",
|
"view_album": "View Album",
|
||||||
"view_all": "View All",
|
"view_all": "View All",
|
||||||
@@ -2296,6 +2370,7 @@
|
|||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"you_dont_have_any_shared_links": "You don't have any shared links",
|
"you_dont_have_any_shared_links": "You don't have any shared links",
|
||||||
"your_wifi_name": "Your Wi-Fi name",
|
"your_wifi_name": "Your Wi-Fi name",
|
||||||
|
"zero_to_clear_rating": "press 0 to clear asset rating",
|
||||||
"zoom_image": "Zoom Image",
|
"zoom_image": "Zoom Image",
|
||||||
"zoom_to_bounds": "Zoom to bounds"
|
"zoom_to_bounds": "Zoom to bounds"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "immich-i18n",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"format": "prettier --check .",
|
||||||
|
"format:fix": "prettier --write ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prettier-plugin-sort-json": "^4.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-20
@@ -1,8 +1,8 @@
|
|||||||
ARG DEVICE=cpu
|
ARG DEVICE=cpu
|
||||||
|
|
||||||
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
|
FROM python:3.11-bookworm@sha256:667cf70698924920f29ebdb8d749ab665811503b87093d4f11826d114fd7255e AS builder-cpu
|
||||||
|
|
||||||
FROM builder-cpu AS builder-openvino
|
FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS builder-openvino
|
||||||
|
|
||||||
FROM builder-cpu AS builder-cuda
|
FROM builder-cpu AS builder-cuda
|
||||||
|
|
||||||
@@ -22,20 +22,18 @@ FROM builder-cpu AS builder-rknn
|
|||||||
|
|
||||||
# Warning: 25GiB+ disk space required to pull this image
|
# Warning: 25GiB+ disk space required to pull this image
|
||||||
# TODO: find a way to reduce the image size
|
# TODO: find a way to reduce the image size
|
||||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
|
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm
|
||||||
|
|
||||||
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
|
||||||
ARG ONNXRUNTIME_VERSION="v1.22.1"
|
ARG ONNXRUNTIME_VERSION="v1.22.1"
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
|
RUN apt-get update && apt-get install -y --no-install-recommends wget git
|
||||||
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.30.1/cmake-3.30.1-linux-x86_64.sh && \
|
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \
|
||||||
chmod +x cmake-3.30.1-linux-x86_64.sh && \
|
chmod +x cmake-3.31.9-linux-x86_64.sh && \
|
||||||
mkdir -p /code/cmake-3.30.1-linux-x86_64 && \
|
mkdir -p /code/cmake-3.31.9-linux-x86_64 && \
|
||||||
./cmake-3.30.1-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.30.1-linux-x86_64 && \
|
./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \
|
||||||
rm cmake-3.30.1-linux-x86_64.sh
|
rm cmake-3.31.9-linux-x86_64.sh
|
||||||
|
|
||||||
ENV PATH=/code/cmake-3.30.1-linux-x86_64/bin:${PATH}
|
|
||||||
|
|
||||||
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
|
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
|
||||||
WORKDIR /code/onnxruntime
|
WORKDIR /code/onnxruntime
|
||||||
@@ -45,9 +43,26 @@ COPY ./patches/* /tmp/
|
|||||||
RUN git apply /tmp/*.patch
|
RUN git apply /tmp/*.patch
|
||||||
|
|
||||||
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
|
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
|
||||||
|
|
||||||
|
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
|
||||||
|
ENV CCACHE_DIR="/ccache"
|
||||||
# Note: the `parallel` setting uses a substantial amount of RAM
|
# Note: the `parallel` setting uses a substantial amount of RAM
|
||||||
RUN ./build.sh --allow_running_as_root --config Release --build_wheel --update --build --parallel 17 --cmake_extra_defines\
|
RUN --mount=type=cache,target=/ccache \
|
||||||
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" --skip_tests --use_rocm --rocm_home=/opt/rocm
|
./build.sh \
|
||||||
|
--allow_running_as_root \
|
||||||
|
--config Release \
|
||||||
|
--build_wheel \
|
||||||
|
--update \
|
||||||
|
--build \
|
||||||
|
--parallel 17 \
|
||||||
|
--cmake_extra_defines \
|
||||||
|
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
|
||||||
|
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
|
||||||
|
--skip_tests \
|
||||||
|
--use_rocm \
|
||||||
|
--rocm_home=/opt/rocm \
|
||||||
|
--use_cache \
|
||||||
|
--compile_no_warning_as_error
|
||||||
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
|
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
|
||||||
|
|
||||||
FROM builder-${DEVICE} AS builder
|
FROM builder-${DEVICE} AS builder
|
||||||
@@ -68,20 +83,23 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
|
|||||||
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
uv pip install /opt/onnxruntime_rocm-*.whl; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
|
FROM python:3.11-slim-bookworm@sha256:917ec0e42cd6af87657a768449c2f604a6b67c7ab8e10ff917b8724799f816d3 AS prod-cpu
|
||||||
|
|
||||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||||
MACHINE_LEARNING_MODEL_ARENA=false
|
MACHINE_LEARNING_MODEL_ARENA=false
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
|
FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64051aac9806dabf840a61 AS prod-openvino
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
|
||||||
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||||
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||||
|
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||||
dpkg -i *.deb && \
|
dpkg -i *.deb && \
|
||||||
rm *.deb && \
|
rm *.deb && \
|
||||||
apt-get remove wget -yqq && \
|
apt-get remove wget -yqq && \
|
||||||
@@ -102,7 +120,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
|
|||||||
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
|
||||||
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
|
||||||
|
|
||||||
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
|
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm
|
||||||
|
|
||||||
FROM prod-cpu AS prod-armnn
|
FROM prod-cpu AS prod-armnn
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from .schemas import (
|
|||||||
T,
|
T,
|
||||||
)
|
)
|
||||||
|
|
||||||
MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger
|
MultiPartParser.spool_max_size = 2**26 # spools to disk if payload is 64 MiB or larger
|
||||||
|
|
||||||
model_cache = ModelCache(revalidate=settings.model_ttl > 0)
|
model_cache = ModelCache(revalidate=settings.model_ttl > 0)
|
||||||
thread_pool: ThreadPoolExecutor | None = None
|
thread_pool: ThreadPoolExecutor | None = None
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh
|
||||||
|
index bbb672a99e..0dc652fbda 100644
|
||||||
|
--- a/dockerfiles/scripts/install_common_deps.sh
|
||||||
|
+++ b/dockerfiles/scripts/install_common_deps.sh
|
||||||
|
@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
libssl-dev \
|
||||||
|
- python3-dev
|
||||||
|
+ python3-dev \
|
||||||
|
+ ccache
|
||||||
|
|
||||||
|
# Dependencies: conda
|
||||||
|
-wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
|
||||||
|
+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
|
||||||
|
rm ~/miniconda.sh
|
||||||
|
/opt/miniconda/bin/conda clean -ya
|
||||||
|
|
||||||
|
-pip install numpy
|
||||||
|
-pip install packaging
|
||||||
|
-pip install "wheel>=0.35.1"
|
||||||
|
+# Dependencies: venv and packages
|
||||||
|
+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv
|
||||||
|
+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip
|
||||||
|
+/opt/rocm-venv/bin/pip install --no-cache-dir \
|
||||||
|
+ "numpy==2.3.4" \
|
||||||
|
+ "packaging==25.0" \
|
||||||
|
+ "wheel==0.45.1" \
|
||||||
|
+ "setuptools==80.9.0"
|
||||||
|
+
|
||||||
|
rm -rf /opt/miniconda/pkgs
|
||||||
|
|
||||||
|
# Dependencies: cmake
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
|
|
||||||
index 2714e6f59..a69da76b4 100644
|
|
||||||
--- a/cmake/CMakeLists.txt
|
|
||||||
+++ b/cmake/CMakeLists.txt
|
|
||||||
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
|
|
||||||
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
|
|
||||||
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
|
|
||||||
else()
|
|
||||||
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
|
||||||
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ name = "immich-ml"
|
|||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||||
requires-python = ">=3.10,<4.0"
|
requires-python = ">=3.11,<4.0"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiocache>=0.12.1,<1.0",
|
"aiocache>=0.12.1,<1.0",
|
||||||
@@ -12,7 +12,7 @@ dependencies = [
|
|||||||
"gunicorn>=21.1.0",
|
"gunicorn>=21.1.0",
|
||||||
"huggingface-hub>=0.20.1,<1.0",
|
"huggingface-hub>=0.20.1,<1.0",
|
||||||
"insightface>=0.7.3,<1.0",
|
"insightface>=0.7.3,<1.0",
|
||||||
"numpy<2",
|
"numpy>=2.3.4",
|
||||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||||
"orjson>=3.9.5",
|
"orjson>=3.9.5",
|
||||||
"pillow>=9.5.0,<11.0",
|
"pillow>=9.5.0,<11.0",
|
||||||
@@ -49,24 +49,16 @@ lint = [
|
|||||||
dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
|
dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
cpu = ["onnxruntime>=1.15.0,<2"]
|
cpu = ["onnxruntime>=1.23.2,<2"]
|
||||||
cuda = ["onnxruntime-gpu>=1.17.0,<2"]
|
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
|
||||||
openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0"]
|
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
|
||||||
armnn = ["onnxruntime>=1.15.0,<2"]
|
armnn = ["onnxruntime>=1.23.2,<2"]
|
||||||
rknn = ["onnxruntime>=1.15.0,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
||||||
rocm = []
|
rocm = []
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
compile-bytecode = true
|
compile-bytecode = true
|
||||||
|
|
||||||
[[tool.uv.index]]
|
|
||||||
name = "cuda12"
|
|
||||||
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
|
|
||||||
explicit = true
|
|
||||||
|
|
||||||
[tool.uv.sources]
|
|
||||||
onnxruntime-gpu = { index = "cuda12" }
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
include = ["immich_ml"]
|
include = ["immich_ml"]
|
||||||
|
|
||||||
|
|||||||
Generated
+804
-1171
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
experimental_monorepo_root = true
|
experimental_monorepo_root = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
node = "24.11.1"
|
node = "24.13.0"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.24.0"
|
pnpm = "10.28.0"
|
||||||
terragrunt = "0.93.10"
|
terragrunt = "0.93.10"
|
||||||
opentofu = "1.10.7"
|
opentofu = "1.10.7"
|
||||||
java = "25.0.1"
|
java = "25.0.1"
|
||||||
@@ -34,4 +34,4 @@ run = { task = ":i18n:format-fix" }
|
|||||||
|
|
||||||
[tasks."i18n:format-fix"]
|
[tasks."i18n:format-fix"]
|
||||||
dir = "i18n"
|
dir = "i18n"
|
||||||
run = "pnpm dlx sort-json *.json"
|
run = "pnpm run format:fix"
|
||||||
|
|||||||
@@ -117,6 +117,9 @@
|
|||||||
<data
|
<data
|
||||||
android:host="my.immich.app"
|
android:host="my.immich.app"
|
||||||
android:pathPrefix="/memories/" />
|
android:pathPrefix="/memories/" />
|
||||||
|
<data
|
||||||
|
android:host="my.immich.app"
|
||||||
|
android:path="/memory" />
|
||||||
<data
|
<data
|
||||||
android:host="my.immich.app"
|
android:host="my.immich.app"
|
||||||
android:pathPrefix="/photos/" />
|
android:pathPrefix="/photos/" />
|
||||||
|
|||||||
@@ -252,6 +252,40 @@ data class HashResult (
|
|||||||
|
|
||||||
override fun hashCode(): Int = toList().hashCode()
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
|
data class CloudIdResult (
|
||||||
|
val assetId: String,
|
||||||
|
val error: String? = null,
|
||||||
|
val cloudId: String? = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object {
|
||||||
|
fun fromList(pigeonVar_list: List<Any?>): CloudIdResult {
|
||||||
|
val assetId = pigeonVar_list[0] as String
|
||||||
|
val error = pigeonVar_list[1] as String?
|
||||||
|
val cloudId = pigeonVar_list[2] as String?
|
||||||
|
return CloudIdResult(assetId, error, cloudId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun toList(): List<Any?> {
|
||||||
|
return listOf(
|
||||||
|
assetId,
|
||||||
|
error,
|
||||||
|
cloudId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is CloudIdResult) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return MessagesPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||||
|
|
||||||
|
override fun hashCode(): Int = toList().hashCode()
|
||||||
|
}
|
||||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
@@ -275,6 +309,11 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
HashResult.fromList(it)
|
HashResult.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
133.toByte() -> {
|
||||||
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
|
CloudIdResult.fromList(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> super.readValueOfType(type, buffer)
|
else -> super.readValueOfType(type, buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,6 +335,10 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
stream.write(132)
|
stream.write(132)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
|
is CloudIdResult -> {
|
||||||
|
stream.write(133)
|
||||||
|
writeValue(stream, value.toList())
|
||||||
|
}
|
||||||
else -> super.writeValue(stream, value)
|
else -> super.writeValue(stream, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +358,7 @@ interface NativeSyncApi {
|
|||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by NativeSyncApi. */
|
/** The codec used by NativeSyncApi. */
|
||||||
@@ -508,6 +552,23 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val assetIdsArg = args[0] as List<String>
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.getCloudIdForAssetIds(assetIdsArg))
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
MessagesPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
@@ -21,7 +22,6 @@ import kotlinx.coroutines.sync.withPermit
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
|
|
||||||
sealed class AssetResult {
|
sealed class AssetResult {
|
||||||
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
data class ValidAsset(val asset: PlatformAsset, val albumId: String) : AssetResult()
|
||||||
@@ -298,7 +298,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
var bytesRead: Int
|
var bytesRead: Int
|
||||||
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
val buffer = ByteArray(HASH_BUFFER_SIZE)
|
||||||
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
while (inputStream.read(buffer).also { bytesRead = it } > 0) {
|
||||||
coroutineContext.ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
digest.update(buffer, 0, bytesRead)
|
digest.update(buffer, 0, bytesRead)
|
||||||
}
|
}
|
||||||
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
|
} ?: return HashResult(assetId, "Cannot open input stream for asset", null)
|
||||||
@@ -316,4 +316,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
hashTask?.cancel()
|
hashTask?.cancel()
|
||||||
hashTask = null
|
hashTask = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||||
|
@Suppress("unused", "UNUSED_PARAMETER")
|
||||||
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -33,4 +33,5 @@ Runner/GeneratedPluginRegistrant.*
|
|||||||
!default.perspectivev3
|
!default.perspectivev3
|
||||||
|
|
||||||
fastlane/report.xml
|
fastlane/report.xml
|
||||||
Gemfile.lock
|
Gemfile.lock
|
||||||
|
certs/
|
||||||
@@ -55,6 +55,7 @@ import UIKit
|
|||||||
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||||
|
ConnectivityApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ConnectivityApiImpl())
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||||
|
|||||||
@@ -1,6 +1,60 @@
|
|||||||
|
import Network
|
||||||
|
|
||||||
class ConnectivityApiImpl: ConnectivityApi {
|
class ConnectivityApiImpl: ConnectivityApi {
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "ConnectivityMonitor")
|
||||||
|
private var currentPath: NWPath?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
self?.currentPath = path
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
// Get initial state synchronously
|
||||||
|
currentPath = monitor.currentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
func getCapabilities() throws -> [NetworkCapability] {
|
func getCapabilities() throws -> [NetworkCapability] {
|
||||||
[]
|
guard let path = currentPath else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
guard path.status == .satisfied else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var capabilities: [NetworkCapability] = []
|
||||||
|
|
||||||
|
if path.usesInterfaceType(.wifi) {
|
||||||
|
capabilities.append(.wifi)
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.usesInterfaceType(.cellular) {
|
||||||
|
capabilities.append(.cellular)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for VPN - iOS reports VPN as .other interface type in many cases
|
||||||
|
// or through the path's expensive property when on cellular with VPN
|
||||||
|
if path.usesInterfaceType(.other) {
|
||||||
|
capabilities.append(.vpn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if connection is unmetered:
|
||||||
|
// - Must be on WiFi (not cellular)
|
||||||
|
// - Must not be expensive (rules out personal hotspot)
|
||||||
|
// - Must not be constrained (Low Data Mode)
|
||||||
|
// Note: VPN over cellular should still be considered metered
|
||||||
|
let isOnCellular = path.usesInterfaceType(.cellular)
|
||||||
|
let isOnWifi = path.usesInterfaceType(.wifi)
|
||||||
|
|
||||||
|
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
|
||||||
|
capabilities.append(.unmetered)
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilities
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,39 @@ struct HashResult: Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
|
struct CloudIdResult: Hashable {
|
||||||
|
var assetId: String
|
||||||
|
var error: String? = nil
|
||||||
|
var cloudId: String? = nil
|
||||||
|
|
||||||
|
|
||||||
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
|
static func fromList(_ pigeonVar_list: [Any?]) -> CloudIdResult? {
|
||||||
|
let assetId = pigeonVar_list[0] as! String
|
||||||
|
let error: String? = nilOrValue(pigeonVar_list[1])
|
||||||
|
let cloudId: String? = nilOrValue(pigeonVar_list[2])
|
||||||
|
|
||||||
|
return CloudIdResult(
|
||||||
|
assetId: assetId,
|
||||||
|
error: error,
|
||||||
|
cloudId: cloudId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
func toList() -> [Any?] {
|
||||||
|
return [
|
||||||
|
assetId,
|
||||||
|
error,
|
||||||
|
cloudId,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
static func == (lhs: CloudIdResult, rhs: CloudIdResult) -> Bool {
|
||||||
|
return deepEqualsMessages(lhs.toList(), rhs.toList()) }
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
deepHashMessages(value: toList(), hasher: &hasher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||||
override func readValue(ofType type: UInt8) -> Any? {
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
switch type {
|
switch type {
|
||||||
@@ -323,6 +356,8 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||||
case 132:
|
case 132:
|
||||||
return HashResult.fromList(self.readValue() as! [Any?])
|
return HashResult.fromList(self.readValue() as! [Any?])
|
||||||
|
case 133:
|
||||||
|
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||||
default:
|
default:
|
||||||
return super.readValue(ofType: type)
|
return super.readValue(ofType: type)
|
||||||
}
|
}
|
||||||
@@ -343,6 +378,9 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
|||||||
} else if let value = value as? HashResult {
|
} else if let value = value as? HashResult {
|
||||||
super.writeByte(132)
|
super.writeByte(132)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
|
} else if let value = value as? CloudIdResult {
|
||||||
|
super.writeByte(133)
|
||||||
|
super.writeValue(value.toList())
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(value)
|
super.writeValue(value)
|
||||||
}
|
}
|
||||||
@@ -377,6 +415,7 @@ protocol NativeSyncApi {
|
|||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
|
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -560,5 +599,22 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||||
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
if let api = api {
|
||||||
|
getCloudIdForAssetIdsChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let assetIdsArg = args[0] as! [String]
|
||||||
|
do {
|
||||||
|
let result = try api.getCloudIdForAssetIds(assetIds: assetIdsArg)
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,31 +19,31 @@ struct AssetWrapper: Hashable, Equatable {
|
|||||||
|
|
||||||
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||||
static let name = "NativeSyncApi"
|
static let name = "NativeSyncApi"
|
||||||
|
|
||||||
static func register(with registrar: any FlutterPluginRegistrar) {
|
static func register(with registrar: any FlutterPluginRegistrar) {
|
||||||
let instance = NativeSyncApiImpl()
|
let instance = NativeSyncApiImpl()
|
||||||
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
||||||
registrar.publish(instance)
|
registrar.publish(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
|
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
|
||||||
super.detachFromEngine()
|
super.detachFromEngine()
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
private let recoveredAlbumSubType = 1000000219
|
private let recoveredAlbumSubType = 1000000219
|
||||||
|
|
||||||
private var hashTask: Task<Void?, Error>?
|
private var hashTask: Task<Void?, Error>?
|
||||||
private static let hashCancelledCode = "HASH_CANCELLED"
|
private static let hashCancelledCode = "HASH_CANCELLED"
|
||||||
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
||||||
|
|
||||||
|
|
||||||
init(with defaults: UserDefaults = .standard) {
|
init(with defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16, *)
|
@available(iOS 16, *)
|
||||||
private func getChangeToken() -> PHPersistentChangeToken? {
|
private func getChangeToken() -> PHPersistentChangeToken? {
|
||||||
guard let data = defaults.data(forKey: changeTokenKey) else {
|
guard let data = defaults.data(forKey: changeTokenKey) else {
|
||||||
@@ -51,7 +51,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
|
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: PHPersistentChangeToken.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 16, *)
|
@available(iOS 16, *)
|
||||||
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
|
private func saveChangeToken(token: PHPersistentChangeToken) -> Void {
|
||||||
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
|
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
|
||||||
@@ -59,18 +59,18 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
defaults.set(data, forKey: changeTokenKey)
|
defaults.set(data, forKey: changeTokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSyncCheckpoint() -> Void {
|
func clearSyncCheckpoint() -> Void {
|
||||||
defaults.removeObject(forKey: changeTokenKey)
|
defaults.removeObject(forKey: changeTokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkpointSync() {
|
func checkpointSync() {
|
||||||
guard #available(iOS 16, *) else {
|
guard #available(iOS 16, *) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
|
saveChangeToken(token: PHPhotoLibrary.shared().currentChangeToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldFullSync() -> Bool {
|
func shouldFullSync() -> Bool {
|
||||||
guard #available(iOS 16, *),
|
guard #available(iOS 16, *),
|
||||||
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
||||||
@@ -78,36 +78,36 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
|
// When we do not have access to photo library, older iOS version or No token available, fallback to full sync
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
|
guard let _ = try? PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken) else {
|
||||||
// Cannot fetch persistent changes
|
// Cannot fetch persistent changes
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlbums() throws -> [PlatformAlbum] {
|
func getAlbums() throws -> [PlatformAlbum] {
|
||||||
var albums: [PlatformAlbum] = []
|
var albums: [PlatformAlbum] = []
|
||||||
|
|
||||||
albumTypes.forEach { type in
|
albumTypes.forEach { type in
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||||
for i in 0..<collections.count {
|
for i in 0..<collections.count {
|
||||||
let album = collections.object(at: i)
|
let album = collections.object(at: i)
|
||||||
|
|
||||||
// Ignore recovered album
|
// Ignore recovered album
|
||||||
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
if(album.assetCollectionSubtype.rawValue == self.recoveredAlbumSubType) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
options.sortDescriptors = [NSSortDescriptor(key: "modificationDate", ascending: false)]
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
|
|
||||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||||
|
|
||||||
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
let isCloud = album.assetCollectionSubtype == .albumCloudShared || album.assetCollectionSubtype == .albumMyPhotoStream
|
||||||
|
|
||||||
var domainAlbum = PlatformAlbum(
|
var domainAlbum = PlatformAlbum(
|
||||||
id: album.localIdentifier,
|
id: album.localIdentifier,
|
||||||
name: album.localizedTitle!,
|
name: album.localizedTitle!,
|
||||||
@@ -115,57 +115,57 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
isCloud: isCloud,
|
isCloud: isCloud,
|
||||||
assetCount: Int64(assets.count)
|
assetCount: Int64(assets.count)
|
||||||
)
|
)
|
||||||
|
|
||||||
if let firstAsset = assets.firstObject {
|
if let firstAsset = assets.firstObject {
|
||||||
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
|
domainAlbum.updatedAt = firstAsset.modificationDate.map { Int64($0.timeIntervalSince1970) }
|
||||||
}
|
}
|
||||||
|
|
||||||
albums.append(domainAlbum)
|
albums.append(domainAlbum)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return albums.sorted { $0.id < $1.id }
|
return albums.sorted { $0.id < $1.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMediaChanges() throws -> SyncDelta {
|
func getMediaChanges() throws -> SyncDelta {
|
||||||
guard #available(iOS 16, *) else {
|
guard #available(iOS 16, *) else {
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
|
guard PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized else {
|
||||||
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
|
throw PigeonError(code: "NO_AUTH", message: "No photo library access", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let storedToken = getChangeToken() else {
|
guard let storedToken = getChangeToken() else {
|
||||||
// No token exists, definitely need a full sync
|
// No token exists, definitely need a full sync
|
||||||
print("MediaManager::getMediaChanges: No token found")
|
print("MediaManager::getMediaChanges: No token found")
|
||||||
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
|
throw PigeonError(code: "NO_TOKEN", message: "No stored change token", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentToken = PHPhotoLibrary.shared().currentChangeToken
|
let currentToken = PHPhotoLibrary.shared().currentChangeToken
|
||||||
if storedToken == currentToken {
|
if storedToken == currentToken {
|
||||||
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
return SyncDelta(hasChanges: false, updates: [], deletes: [], assetAlbums: [:])
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
|
let changes = try PHPhotoLibrary.shared().fetchPersistentChanges(since: storedToken)
|
||||||
|
|
||||||
var updatedAssets: Set<AssetWrapper> = []
|
var updatedAssets: Set<AssetWrapper> = []
|
||||||
var deletedAssets: Set<String> = []
|
var deletedAssets: Set<String> = []
|
||||||
|
|
||||||
for change in changes {
|
for change in changes {
|
||||||
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
|
guard let details = try? change.changeDetails(for: PHObjectType.asset) else { continue }
|
||||||
|
|
||||||
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
|
let updated = details.updatedLocalIdentifiers.union(details.insertedLocalIdentifiers)
|
||||||
deletedAssets.formUnion(details.deletedLocalIdentifiers)
|
deletedAssets.formUnion(details.deletedLocalIdentifiers)
|
||||||
|
|
||||||
if (updated.isEmpty) { continue }
|
if (updated.isEmpty) { continue }
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
let result = PHAsset.fetchAssets(withLocalIdentifiers: Array(updated), options: options)
|
||||||
for i in 0..<result.count {
|
for i in 0..<result.count {
|
||||||
let asset = result.object(at: i)
|
let asset = result.object(at: i)
|
||||||
|
|
||||||
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
// Asset wrapper only uses the id for comparison. Multiple change can contain the same asset, skip duplicate changes
|
||||||
let predicate = PlatformAsset(
|
let predicate = PlatformAsset(
|
||||||
id: asset.localIdentifier,
|
id: asset.localIdentifier,
|
||||||
@@ -178,25 +178,25 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
let domainAsset = AssetWrapper(with: asset.toPlatformAsset())
|
||||||
updatedAssets.insert(domainAsset)
|
updatedAssets.insert(domainAsset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let updates = Array(updatedAssets.map { $0.asset })
|
let updates = Array(updatedAssets.map { $0.asset })
|
||||||
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
|
return SyncDelta(hasChanges: true, updates: updates, deletes: Array(deletedAssets), assetAlbums: buildAssetAlbumsMap(assets: updates))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
|
private func buildAssetAlbumsMap(assets: Array<PlatformAsset>) -> [String: [String]] {
|
||||||
guard !assets.isEmpty else {
|
guard !assets.isEmpty else {
|
||||||
return [:]
|
return [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
var albumAssets: [String: [String]] = [:]
|
var albumAssets: [String: [String]] = [:]
|
||||||
|
|
||||||
for type in albumTypes {
|
for type in albumTypes {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil)
|
||||||
collections.enumerateObjects { (album, _, _) in
|
collections.enumerateObjects { (album, _, _) in
|
||||||
@@ -211,13 +211,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
return albumAssets
|
return albumAssets
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids: [String] = []
|
var ids: [String] = []
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
@@ -227,13 +227,13 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
let date = NSDate(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
@@ -241,32 +241,32 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
let assets = getAssetsFromAlbum(in: album, options: options)
|
let assets = getAssetsFromAlbum(in: album, options: options)
|
||||||
return Int64(assets.count)
|
return Int64(assets.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = PHFetchOptions()
|
let options = PHFetchOptions()
|
||||||
options.includeHiddenAssets = false
|
options.includeHiddenAssets = false
|
||||||
if(updatedTimeCond != nil) {
|
if(updatedTimeCond != nil) {
|
||||||
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!))
|
||||||
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = getAssetsFromAlbum(in: album, options: options)
|
let result = getAssetsFromAlbum(in: album, options: options)
|
||||||
if(result.count == 0) {
|
if(result.count == 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
var assets: [PlatformAsset] = []
|
var assets: [PlatformAsset] = []
|
||||||
result.enumerateObjects { (asset, _, _) in
|
result.enumerateObjects { (asset, _, _) in
|
||||||
assets.append(asset.toPlatformAsset())
|
assets.append(asset.toPlatformAsset())
|
||||||
}
|
}
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void) {
|
||||||
if let prevTask = hashTask {
|
if let prevTask = hashTask {
|
||||||
prevTask.cancel()
|
prevTask.cancel()
|
||||||
@@ -284,11 +284,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
missingAssetIds.remove(asset.localIdentifier)
|
missingAssetIds.remove(asset.localIdentifier)
|
||||||
assets.append(asset)
|
assets.append(asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||||
var results = [HashResult]()
|
var results = [HashResult]()
|
||||||
results.reserveCapacity(assets.count)
|
results.reserveCapacity(assets.count)
|
||||||
@@ -301,28 +301,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
return await self.hashAsset(asset, allowNetworkAccess: allowNetworkAccess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for await result in taskGroup {
|
for await result in taskGroup {
|
||||||
guard let result = result else {
|
guard let result = result else {
|
||||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||||
}
|
}
|
||||||
results.append(result)
|
results.append(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
for missing in missingAssetIds {
|
for missing in missingAssetIds {
|
||||||
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
return self?.completeWhenActive(for: completion, with: .success(results))
|
return self?.completeWhenActive(for: completion, with: .success(results))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelHashing() {
|
func cancelHashing() {
|
||||||
hashTask?.cancel()
|
hashTask?.cancel()
|
||||||
hashTask = nil
|
hashTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
private func hashAsset(_ asset: PHAsset, allowNetworkAccess: Bool) async -> HashResult? {
|
||||||
class RequestRef {
|
class RequestRef {
|
||||||
var id: PHAssetResourceDataRequestID?
|
var id: PHAssetResourceDataRequestID?
|
||||||
@@ -332,21 +332,21 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let resource = asset.getResource() else {
|
guard let resource = asset.getResource() else {
|
||||||
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
|
return HashResult(assetId: asset.localIdentifier, error: "Cannot get asset resource", hash: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let options = PHAssetResourceRequestOptions()
|
let options = PHAssetResourceRequestOptions()
|
||||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
var hasher = Insecure.SHA1()
|
var hasher = Insecure.SHA1()
|
||||||
|
|
||||||
requestRef.id = PHAssetResourceManager.default().requestData(
|
requestRef.id = PHAssetResourceManager.default().requestData(
|
||||||
for: resource,
|
for: resource,
|
||||||
options: options,
|
options: options,
|
||||||
@@ -377,11 +377,11 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
PHAssetResourceManager.default().cancelDataRequest(requestId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||||
// Ensure to actually getting all assets for the Recents album
|
// Ensure to actually getting all assets for the Recents album
|
||||||
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
if (album.assetCollectionSubtype == .smartAlbumUserLibrary) {
|
||||||
@@ -390,4 +390,28 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
return PHAsset.fetchAssets(in: album, options: options)
|
return PHAsset.fetchAssets(in: album, options: options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult] {
|
||||||
|
guard #available(iOS 16, *) else {
|
||||||
|
return assetIds.map { CloudIdResult(assetId: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappings: [CloudIdResult] = []
|
||||||
|
let result = PHPhotoLibrary.shared().cloudIdentifierMappings(forLocalIdentifiers: assetIds)
|
||||||
|
for (key, value) in result {
|
||||||
|
switch value {
|
||||||
|
case .success(let cloudIdentifier):
|
||||||
|
let cloudId = cloudIdentifier.stringValue
|
||||||
|
// Ignores invalid cloud ids of the format "GUID:ID:". Valid Ids are of the form "GUID:ID:HASH"
|
||||||
|
if !cloudId.hasSuffix(":") {
|
||||||
|
mappings.append(CloudIdResult(assetId: key, cloudId: cloudId))
|
||||||
|
} else {
|
||||||
|
mappings.append(CloudIdResult(assetId: key, error: "Incomplete Cloud Id: \(cloudId)"))
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
mappings.append(CloudIdResult(assetId: key, error: "Error getting Cloud Id: \(error.localizedDescription)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mappings;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ def get_version_from_pubspec
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Helper method to configure code signing for all targets
|
# Helper method to configure code signing for all targets
|
||||||
def configure_code_signing(bundle_id_suffix: "")
|
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
|
||||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||||
|
|
||||||
# Runner (main app)
|
# Runner (main app)
|
||||||
@@ -54,7 +54,7 @@ end
|
|||||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
|
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
|
||||||
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix} AppStore",
|
profile_name: profile_name_main,
|
||||||
targets: ["Runner"]
|
targets: ["Runner"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ end
|
|||||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
|
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
|
||||||
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension AppStore",
|
profile_name: profile_name_share,
|
||||||
targets: ["ShareExtension"]
|
targets: ["ShareExtension"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ end
|
|||||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
|
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
|
||||||
profile_name: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget AppStore",
|
profile_name: profile_name_widget,
|
||||||
targets: ["WidgetExtension"]
|
targets: ["WidgetExtension"]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -87,7 +87,10 @@ end
|
|||||||
bundle_id_suffix: "",
|
bundle_id_suffix: "",
|
||||||
configuration: "Release",
|
configuration: "Release",
|
||||||
distribute_external: true,
|
distribute_external: true,
|
||||||
version_number: nil
|
version_number: nil,
|
||||||
|
profile_name_main:,
|
||||||
|
profile_name_share:,
|
||||||
|
profile_name_widget:
|
||||||
)
|
)
|
||||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||||
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
|
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
|
||||||
@@ -115,9 +118,9 @@ end
|
|||||||
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||||
export_options: {
|
export_options: {
|
||||||
provisioningProfiles: {
|
provisioningProfiles: {
|
||||||
"#{app_identifier}" => "#{app_identifier} AppStore",
|
"#{app_identifier}" => profile_name_main,
|
||||||
"#{app_identifier}.ShareExtension" => "#{app_identifier}.ShareExtension AppStore",
|
"#{app_identifier}.ShareExtension" => profile_name_share,
|
||||||
"#{app_identifier}.Widget" => "#{app_identifier}.Widget AppStore"
|
"#{app_identifier}.Widget" => profile_name_widget
|
||||||
},
|
},
|
||||||
signingStyle: "manual",
|
signingStyle: "manual",
|
||||||
signingCertificate: CODE_SIGN_IDENTITY
|
signingCertificate: CODE_SIGN_IDENTITY
|
||||||
@@ -136,20 +139,35 @@ end
|
|||||||
lane :gha_testflight_dev do
|
lane :gha_testflight_dev do
|
||||||
api_key = get_api_key
|
api_key = get_api_key
|
||||||
|
|
||||||
# Install development provisioning profiles
|
# Download and install provisioning profiles from App Store Connect
|
||||||
install_provisioning_profile(path: "profile_dev.mobileprovision")
|
# Certificate is imported by GHA workflow into build.keychain
|
||||||
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
|
# Capture profile names after each sigh call
|
||||||
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
|
||||||
|
main_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
# Configure code signing for dev bundle IDs
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
|
||||||
configure_code_signing(bundle_id_suffix: "development")
|
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
|
||||||
|
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
|
# Configure code signing for dev bundle IDs using the downloaded profile names
|
||||||
|
configure_code_signing(
|
||||||
|
bundle_id_suffix: "development",
|
||||||
|
profile_name_main: main_profile_name,
|
||||||
|
profile_name_share: share_profile_name,
|
||||||
|
profile_name_widget: widget_profile_name
|
||||||
|
)
|
||||||
|
|
||||||
# Build and upload
|
# Build and upload
|
||||||
build_and_upload(
|
build_and_upload(
|
||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
bundle_id_suffix: "development",
|
bundle_id_suffix: "development",
|
||||||
configuration: "Profile",
|
configuration: "Profile",
|
||||||
distribute_external: false
|
distribute_external: false,
|
||||||
|
profile_name_main: main_profile_name,
|
||||||
|
profile_name_share: share_profile_name,
|
||||||
|
profile_name_widget: widget_profile_name
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -157,20 +175,33 @@ end
|
|||||||
lane :gha_release_prod do
|
lane :gha_release_prod do
|
||||||
api_key = get_api_key
|
api_key = get_api_key
|
||||||
|
|
||||||
# Install provisioning profiles
|
# Download and install provisioning profiles from App Store Connect
|
||||||
install_provisioning_profile(path: "profile.mobileprovision")
|
# Certificate is imported by GHA workflow into build.keychain
|
||||||
install_provisioning_profile(path: "profile_share.mobileprovision")
|
sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true)
|
||||||
install_provisioning_profile(path: "profile_widget.mobileprovision")
|
main_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.ShareExtension", force: true)
|
||||||
|
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.Widget", force: true)
|
||||||
|
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
|
|
||||||
# Configure code signing for production bundle IDs
|
# Configure code signing for production bundle IDs
|
||||||
configure_code_signing
|
configure_code_signing(
|
||||||
|
profile_name_main: main_profile_name,
|
||||||
|
profile_name_share: share_profile_name,
|
||||||
|
profile_name_widget: widget_profile_name
|
||||||
|
)
|
||||||
|
|
||||||
# Build and upload with version number
|
# Build and upload with version number
|
||||||
build_and_upload(
|
build_and_upload(
|
||||||
api_key: api_key,
|
api_key: api_key,
|
||||||
version_number: get_version_from_pubspec,
|
version_number: get_version_from_pubspec,
|
||||||
distribute_external: false,
|
distribute_external: false,
|
||||||
|
profile_name_main: main_profile_name,
|
||||||
|
profile_name_share: share_profile_name,
|
||||||
|
profile_name_widget: widget_profile_name
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -215,13 +246,26 @@ end
|
|||||||
# Use the same build process as production, just skip the upload
|
# Use the same build process as production, just skip the upload
|
||||||
# This ensures PR builds validate the same way as production builds
|
# This ensures PR builds validate the same way as production builds
|
||||||
|
|
||||||
# Install provisioning profiles (use development profiles for PR builds)
|
api_key = get_api_key
|
||||||
install_provisioning_profile(path: "profile_dev.mobileprovision")
|
|
||||||
install_provisioning_profile(path: "profile_dev_share.mobileprovision")
|
# Download and install provisioning profiles from App Store Connect
|
||||||
install_provisioning_profile(path: "profile_dev_widget.mobileprovision")
|
# Certificate is imported by GHA workflow into build.keychain
|
||||||
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
|
||||||
|
main_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
|
||||||
|
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
|
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
|
||||||
|
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||||
|
|
||||||
# Configure code signing for dev bundle IDs
|
# Configure code signing for dev bundle IDs
|
||||||
configure_code_signing(bundle_id_suffix: "development")
|
configure_code_signing(
|
||||||
|
bundle_id_suffix: "development",
|
||||||
|
profile_name_main: main_profile_name,
|
||||||
|
profile_name_share: share_profile_name,
|
||||||
|
profile_name_widget: widget_profile_name
|
||||||
|
)
|
||||||
|
|
||||||
# Build the app (same as gha_testflight_dev but without upload)
|
# Build the app (same as gha_testflight_dev but without upload)
|
||||||
build_app(
|
build_app(
|
||||||
@@ -233,9 +277,9 @@ end
|
|||||||
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||||
export_options: {
|
export_options: {
|
||||||
provisioningProfiles: {
|
provisioningProfiles: {
|
||||||
"#{BASE_BUNDLE_ID}.development" => "#{BASE_BUNDLE_ID}.development AppStore",
|
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
|
||||||
"#{BASE_BUNDLE_ID}.development.ShareExtension" => "#{BASE_BUNDLE_ID}.development.ShareExtension AppStore",
|
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
|
||||||
"#{BASE_BUNDLE_ID}.development.Widget" => "#{BASE_BUNDLE_ID}.development.Widget AppStore"
|
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
|
||||||
},
|
},
|
||||||
signingStyle: "manual",
|
signingStyle: "manual",
|
||||||
signingCertificate: CODE_SIGN_IDENTITY
|
signingCertificate: CODE_SIGN_IDENTITY
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const int noDbId = -9223372036854775808; // from Isar
|
|||||||
const double downloadCompleted = -1;
|
const double downloadCompleted = -1;
|
||||||
const double downloadFailed = -2;
|
const double downloadFailed = -2;
|
||||||
|
|
||||||
|
const String kMobileMetadataKey = "mobile-app";
|
||||||
|
|
||||||
// Number of log entries to retain on app start
|
// Number of log entries to retain on app start
|
||||||
const int kLogTruncateLimit = 2000;
|
const int kLogTruncateLimit = 2000;
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
|||||||
enum SortUserBy { id }
|
enum SortUserBy { id }
|
||||||
|
|
||||||
enum ActionSource { timeline, viewer }
|
enum ActionSource { timeline, viewer }
|
||||||
|
|
||||||
|
enum CleanupStep { selectDate, filterOptions, scan, delete }
|
||||||
|
|
||||||
|
enum AssetFilterType { all, photosOnly, videosOnly }
|
||||||
|
|||||||
@@ -51,4 +51,4 @@ const Map<String, Locale> locales = {
|
|||||||
|
|
||||||
const String translationsPath = 'assets/i18n';
|
const String translationsPath = 'assets/i18n';
|
||||||
|
|
||||||
const List<Locale> localesNotSupportedByOverpass = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];
|
const List<Locale> localesNotSupportedByAppFont = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
enum RemoteAssetMetadataKey {
|
||||||
|
mobileApp("mobile-app");
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
const RemoteAssetMetadataKey(this.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class RemoteAssetMetadataValue {
|
||||||
|
const RemoteAssetMetadataValue();
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteAssetMetadataItem {
|
||||||
|
final RemoteAssetMetadataKey key;
|
||||||
|
final RemoteAssetMetadataValue value;
|
||||||
|
|
||||||
|
const RemoteAssetMetadataItem({required this.key, required this.value});
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {'key': key.key, 'value': value};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteAssetMobileAppMetadata extends RemoteAssetMetadataValue {
|
||||||
|
final String? cloudId;
|
||||||
|
final String? createdAt;
|
||||||
|
final String? adjustmentTime;
|
||||||
|
final String? latitude;
|
||||||
|
final String? longitude;
|
||||||
|
|
||||||
|
const RemoteAssetMobileAppMetadata({
|
||||||
|
this.cloudId,
|
||||||
|
this.createdAt,
|
||||||
|
this.adjustmentTime,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final map = <String, Object?>{};
|
||||||
|
if (cloudId != null) {
|
||||||
|
map["iCloudId"] = cloudId;
|
||||||
|
}
|
||||||
|
if (createdAt != null) {
|
||||||
|
map["createdAt"] = createdAt;
|
||||||
|
}
|
||||||
|
if (adjustmentTime != null) {
|
||||||
|
map["adjustmentTime"] = adjustmentTime;
|
||||||
|
}
|
||||||
|
if (latitude != null) {
|
||||||
|
map["latitude"] = latitude;
|
||||||
|
}
|
||||||
|
if (longitude != null) {
|
||||||
|
map["longitude"] = longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ sealed class BaseAsset {
|
|||||||
final int? durationInSeconds;
|
final int? durationInSeconds;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final String? livePhotoVideoId;
|
final String? livePhotoVideoId;
|
||||||
|
final bool isEdited;
|
||||||
|
|
||||||
const BaseAsset({
|
const BaseAsset({
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -34,6 +35,7 @@ sealed class BaseAsset {
|
|||||||
this.durationInSeconds,
|
this.durationInSeconds,
|
||||||
this.isFavorite = false,
|
this.isFavorite = false,
|
||||||
this.livePhotoVideoId,
|
this.livePhotoVideoId,
|
||||||
|
required this.isEdited,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isImage => type == AssetType.image;
|
bool get isImage => type == AssetType.image;
|
||||||
@@ -71,6 +73,7 @@ sealed class BaseAsset {
|
|||||||
height: ${height ?? "<NA>"},
|
height: ${height ?? "<NA>"},
|
||||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||||
isFavorite: $isFavorite,
|
isFavorite: $isFavorite,
|
||||||
|
isEdited: $isEdited,
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +88,8 @@ sealed class BaseAsset {
|
|||||||
width == other.width &&
|
width == other.width &&
|
||||||
height == other.height &&
|
height == other.height &&
|
||||||
durationInSeconds == other.durationInSeconds &&
|
durationInSeconds == other.durationInSeconds &&
|
||||||
isFavorite == other.isFavorite;
|
isFavorite == other.isFavorite &&
|
||||||
|
isEdited == other.isEdited;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,7 @@ sealed class BaseAsset {
|
|||||||
width.hashCode ^
|
width.hashCode ^
|
||||||
height.hashCode ^
|
height.hashCode ^
|
||||||
durationInSeconds.hashCode ^
|
durationInSeconds.hashCode ^
|
||||||
isFavorite.hashCode;
|
isFavorite.hashCode ^
|
||||||
|
isEdited.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ part of 'base_asset.model.dart';
|
|||||||
class LocalAsset extends BaseAsset {
|
class LocalAsset extends BaseAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final String? remoteAssetId;
|
final String? remoteAssetId;
|
||||||
|
final String? cloudId;
|
||||||
final int orientation;
|
final int orientation;
|
||||||
|
|
||||||
final DateTime? adjustmentTime;
|
final DateTime? adjustmentTime;
|
||||||
@@ -12,6 +13,7 @@ class LocalAsset extends BaseAsset {
|
|||||||
const LocalAsset({
|
const LocalAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
String? remoteId,
|
String? remoteId,
|
||||||
|
this.cloudId,
|
||||||
required super.name,
|
required super.name,
|
||||||
super.checksum,
|
super.checksum,
|
||||||
required super.type,
|
required super.type,
|
||||||
@@ -26,6 +28,7 @@ class LocalAsset extends BaseAsset {
|
|||||||
this.adjustmentTime,
|
this.adjustmentTime,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
|
required super.isEdited,
|
||||||
}) : remoteAssetId = remoteId;
|
}) : remoteAssetId = remoteId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -53,12 +56,14 @@ class LocalAsset extends BaseAsset {
|
|||||||
width: ${width ?? "<NA>"},
|
width: ${width ?? "<NA>"},
|
||||||
height: ${height ?? "<NA>"},
|
height: ${height ?? "<NA>"},
|
||||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||||
remoteId: ${remoteId ?? "<NA>"}
|
remoteId: ${remoteId ?? "<NA>"},
|
||||||
|
cloudId: ${cloudId ?? "<NA>"},
|
||||||
|
checksum: ${checksum ?? "<NA>"},
|
||||||
isFavorite: $isFavorite,
|
isFavorite: $isFavorite,
|
||||||
orientation: $orientation,
|
orientation: $orientation,
|
||||||
adjustmentTime: $adjustmentTime,
|
adjustmentTime: $adjustmentTime,
|
||||||
latitude: ${latitude ?? "<NA>"},
|
latitude: ${latitude ?? "<NA>"},
|
||||||
longitude: ${longitude ?? "<NA>"},
|
longitude: ${longitude ?? "<NA>"},
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@ class LocalAsset extends BaseAsset {
|
|||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
return super == other &&
|
return super == other &&
|
||||||
id == other.id &&
|
id == other.id &&
|
||||||
|
cloudId == other.cloudId &&
|
||||||
orientation == other.orientation &&
|
orientation == other.orientation &&
|
||||||
adjustmentTime == other.adjustmentTime &&
|
adjustmentTime == other.adjustmentTime &&
|
||||||
latitude == other.latitude &&
|
latitude == other.latitude &&
|
||||||
@@ -88,6 +94,7 @@ class LocalAsset extends BaseAsset {
|
|||||||
LocalAsset copyWith({
|
LocalAsset copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? remoteId,
|
String? remoteId,
|
||||||
|
String? cloudId,
|
||||||
String? name,
|
String? name,
|
||||||
String? checksum,
|
String? checksum,
|
||||||
AssetType? type,
|
AssetType? type,
|
||||||
@@ -101,10 +108,12 @@ class LocalAsset extends BaseAsset {
|
|||||||
DateTime? adjustmentTime,
|
DateTime? adjustmentTime,
|
||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
|
bool? isEdited,
|
||||||
}) {
|
}) {
|
||||||
return LocalAsset(
|
return LocalAsset(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
remoteId: remoteId ?? this.remoteId,
|
remoteId: remoteId ?? this.remoteId,
|
||||||
|
cloudId: cloudId ?? this.cloudId,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
checksum: checksum ?? this.checksum,
|
checksum: checksum ?? this.checksum,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
@@ -118,6 +127,7 @@ class LocalAsset extends BaseAsset {
|
|||||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
|
isEdited: isEdited ?? this.isEdited,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class RemoteAsset extends BaseAsset {
|
|||||||
this.visibility = AssetVisibility.timeline,
|
this.visibility = AssetVisibility.timeline,
|
||||||
super.livePhotoVideoId,
|
super.livePhotoVideoId,
|
||||||
this.stackId,
|
this.stackId,
|
||||||
|
required super.isEdited,
|
||||||
}) : localAssetId = localId;
|
}) : localAssetId = localId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -104,6 +105,7 @@ class RemoteAsset extends BaseAsset {
|
|||||||
AssetVisibility? visibility,
|
AssetVisibility? visibility,
|
||||||
String? livePhotoVideoId,
|
String? livePhotoVideoId,
|
||||||
String? stackId,
|
String? stackId,
|
||||||
|
bool? isEdited,
|
||||||
}) {
|
}) {
|
||||||
return RemoteAsset(
|
return RemoteAsset(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -122,6 +124,7 @@ class RemoteAsset extends BaseAsset {
|
|||||||
visibility: visibility ?? this.visibility,
|
visibility: visibility ?? this.visibility,
|
||||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||||
stackId: stackId ?? this.stackId,
|
stackId: stackId ?? this.stackId,
|
||||||
|
isEdited: isEdited ?? this.isEdited,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user