mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab379592f | |||
| 478d9f1a15 | |||
| fe8cc89e44 | |||
| b24f1b31fb | |||
| fc6f55b46f |
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.0
|
24.12.0
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
APP_STORE_CONNECT_API_KEY:
|
APP_STORE_CONNECT_API_KEY:
|
||||||
required: true
|
required: true
|
||||||
IOS_CERTIFICATE_P12:
|
MATCH_PASSWORD:
|
||||||
required: true
|
|
||||||
IOS_CERTIFICATE_PASSWORD:
|
|
||||||
required: true
|
required: true
|
||||||
FASTLANE_TEAM_ID:
|
FASTLANE_TEAM_ID:
|
||||||
required: true
|
required: true
|
||||||
@@ -84,7 +82,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -103,7 +101,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Restore Gradle Cache
|
- name: Restore Gradle Cache
|
||||||
id: cache-gradle-restore
|
id: cache-gradle-restore
|
||||||
uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -153,14 +151,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -181,8 +179,25 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Generate token for ios-certs repo
|
||||||
|
id: token
|
||||||
|
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
owner: immich-app
|
||||||
|
repositories: immich,ios-certs
|
||||||
|
|
||||||
|
- name: Set up match authorization
|
||||||
|
id: match-auth
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ steps.token.outputs.token }}
|
||||||
|
run: |
|
||||||
|
# Create base64-encoded authorization for match
|
||||||
|
echo "base64_token=$(echo -n "x-access-token:${TOKEN}" | base64)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.sha }}
|
ref: ${{ inputs.ref || github.sha }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -228,43 +243,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
|
|
||||||
env:
|
|
||||||
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
|
|
||||||
working-directory: ./mobile/ios
|
|
||||||
run: |
|
|
||||||
# Decode certificate
|
|
||||||
echo "$IOS_CERTIFICATE_P12" | base64 --decode > certificate.p12
|
|
||||||
|
|
||||||
- name: Create keychain and import certificate
|
|
||||||
env:
|
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
|
||||||
CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
|
||||||
working-directory: ./mobile/ios
|
|
||||||
run: |
|
|
||||||
# Create keychain
|
|
||||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
|
||||||
security default-keychain -s build.keychain
|
|
||||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
|
||||||
security set-keychain-settings -t 3600 -u build.keychain
|
|
||||||
|
|
||||||
# Import certificate
|
|
||||||
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
|
|
||||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
|
||||||
|
|
||||||
# Verify certificate was imported
|
|
||||||
security find-identity -v -p codesigning build.keychain
|
|
||||||
|
|
||||||
- name: Build and deploy to TestFlight
|
- name: Build and deploy to TestFlight
|
||||||
env:
|
env:
|
||||||
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
|
||||||
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||||
KEYCHAIN_NAME: build.keychain
|
MATCH_GIT_BASIC_AUTHORIZATION: ${{ steps.match-auth.outputs.base64_token }}
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
|
|
||||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||||
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
|
|
||||||
GITHUB_REF: ${{ github.ref }}
|
GITHUB_REF: ${{ github.ref }}
|
||||||
working-directory: ./mobile/ios
|
working-directory: ./mobile/ios
|
||||||
run: |
|
run: |
|
||||||
@@ -280,13 +266,8 @@ jobs:
|
|||||||
bundle exec fastlane gha_build_only
|
bundle exec fastlane gha_build_only
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Clean up keychain
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
security delete-keychain build.keychain || true
|
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -60,11 +60,10 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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
|
||||||
@@ -86,7 +85,7 @@ jobs:
|
|||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Upload build output
|
- name: Upload build output
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: docs-build-output
|
name: docs-build-output
|
||||||
path: docs/build/
|
path: docs/build/
|
||||||
|
|||||||
@@ -125,7 +125,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -23,7 +23,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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,7 +56,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
@@ -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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.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,7 +23,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate-token.outputs.token }}
|
token: ${{ steps.generate-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create PR
|
- name: Create PR
|
||||||
id: create-pr
|
id: create-pr
|
||||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
+24
-32
@@ -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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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-i18n install --frozen-lockfile
|
run: pnpm --filter=immich-web install --frozen-lockfile
|
||||||
- name: Format
|
- name: Format
|
||||||
run: pnpm --filter=immich-i18n format:fix
|
run: pnpm --filter=immich-web format:i18n
|
||||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
@@ -502,25 +502,14 @@ jobs:
|
|||||||
- name: Run e2e tests (web)
|
- name: Run e2e tests (web)
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
run: npx playwright test --project=chromium
|
run: npx playwright test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive web results
|
- name: Archive test results
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-web-test-results-${{ matrix.runner }}
|
name: e2e-web-test-results-${{ matrix.runner }}
|
||||||
path: e2e/playwright-report/
|
path: e2e/playwright-report/
|
||||||
- name: Run ui tests (web)
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: npx playwright test --project=ui
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
- name: Archive ui results
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
||||||
if: success() || failure()
|
|
||||||
with:
|
|
||||||
name: e2e-ui-test-results-${{ matrix.runner }}
|
|
||||||
path: e2e/playwright-report/
|
|
||||||
success-check-e2e:
|
success-check-e2e:
|
||||||
name: End-to-End Tests Success
|
name: End-to-End Tests Success
|
||||||
needs: [e2e-tests-server-cli, e2e-tests-web]
|
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||||
@@ -545,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -577,14 +566,17 @@ 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.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@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
|
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
|
#cache: 'uv'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra cpu
|
uv sync --extra cpu
|
||||||
@@ -618,7 +610,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -647,7 +639,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -669,7 +661,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
@@ -731,7 +723,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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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.13.0
|
24.12.0
|
||||||
|
|||||||
+2
-2
@@ -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.8",
|
"@types/node": "^24.10.4",
|
||||||
"@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",
|
||||||
@@ -69,6 +69,6 @@
|
|||||||
"micromatch": "^4.0.8"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.0"
|
"node": "24.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
image: prom/prometheus@sha256:2b6f734e372c1b4717008f7d0a0152316aedd4d13ae17ef1e3268dbfaf68041b
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1 +1 @@
|
|||||||
24.13.0
|
24.12.0
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ 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:
|
||||||
|
|
||||||
|
|||||||
@@ -68,56 +68,6 @@ Now make sure that the local album is selected in the backup screen (steps 1-2 a
|
|||||||
title="Upload button after photos selection"
|
title="Upload button after photos selection"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
## Free Up Space
|
|
||||||
|
|
||||||
The **Free Up Space** tool allows you to remove local media files from your device that have already been successfully backed up to your Immich server (and are not in the Immich trash). This helps reclaim storage on your mobile device without losing your memories.
|
|
||||||
|
|
||||||
### How it works
|
|
||||||
|
|
||||||
1. **Configuration:**
|
|
||||||
- **Cutoff Date:** You can select a cutoff date. The tool will only look for photos and videos **on or before** this date.
|
|
||||||
- **Filter Options:** You can choose to remove **All** assets, or restrict removal to **Photos only** or **Videos only**.
|
|
||||||
- **Keep Favorites:** By default, local assets marked as favorites are preserved on your device, even if they match the cutoff date.
|
|
||||||
2. **Scan & Review:** Before any files are removed, you are presented with a review screen to verify which items will be deleted.
|
|
||||||
3. **Deletion:** Confirmed items are moved to your device's native Trash/Recycle Bin. They will be permanently removed by the OS based on your system settings (usually after 30 days).
|
|
||||||
|
|
||||||
:::info Android Permissions
|
|
||||||
For the smoothest experience on Android, you should grant Immich special delete privileges. Without this, you may be prompted to confirm deletion for every single image.
|
|
||||||
|
|
||||||
Go to **Immich Settings > Advanced** and enable **"Media Management Access"**.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### iCloud Photos (iOS Users)
|
|
||||||
|
|
||||||
If you use **iCloud Photos** alongside Immich, it is vital to understand how deletion affects your data. iCloud utilizes a two-way sync; this means deleting a photo from your iPhone to free up space will **also delete it from iCloud**.
|
|
||||||
|
|
||||||
:::warning iCloud & Backups
|
|
||||||
If you rely on iCloud as a secondary backup (part of a 3-2-1 backup strategy), using the Free Up Space feature in Immich will remove the file from both your phone and iCloud.
|
|
||||||
|
|
||||||
Once deleted, the photo will exist **only** on your Immich server (and your phone's "Recently Deleted" folder for 30 days).
|
|
||||||
|
|
||||||
When you use iCloud Photos and delete a photo or video on one device, it's also deleted on all other devices where you're signed in with the same Apple Account.
|
|
||||||
|
|
||||||
More information on the [Apple Support](https://support.apple.com/en-us/108922#iCloud_photo_library) website
|
|
||||||
|
|
||||||
**Shared Albums**
|
|
||||||
Assets that are part of an **iCloud Shared Album** are automatically excluded from the cleanup scan to ensure they remain viewable to others in the shared album.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### External App Dependencies (WhatsApp, etc.)
|
|
||||||
|
|
||||||
:::danger WhatsApp & Local Files
|
|
||||||
Android applications like **WhatsApp** rely on local files to display media in chat history.
|
|
||||||
|
|
||||||
If Immich backs up your WhatsApp folder and you run **Free Up Space**, the local copies of these images will be deleted. Consequently, **media in your WhatsApp chats will appear blurry or missing.** You will only be able to view these photos inside the Immich app; they will no longer be visible within the WhatsApp interface.
|
|
||||||
|
|
||||||
**Recommendation:** If keeping chat history intact is important, please ensure you review the deletion list carefully or consider excluding WhatsApp folders from the backup if you intend to use this feature frequently.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::info reclaim storage
|
|
||||||
You must empty the system/gallery trash manually to reclaim storage.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Album Sync
|
## Album Sync
|
||||||
|
|
||||||
You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically.
|
You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically.
|
||||||
@@ -145,3 +95,11 @@ 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.
|
||||||
|
:::
|
||||||
|
|||||||
@@ -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://my.immich.app/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
|
https://immich.yourdomain.com/share/JUckRMxlgpo7F9BpyqGk_cZEwDzaU_U5LU5_oNZp1ETIBa9dpQ0b5ghNm_22QVJfn3k
|
||||||
```
|
```
|
||||||
|
|
||||||
### Creating a public share link
|
### Creating a public share link
|
||||||
|
|||||||
@@ -17,17 +17,12 @@ 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 6GB, recommended 8GB.
|
- **RAM**: Minimum 4GB, recommended 6GB.
|
||||||
- **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.
|
||||||
|
|
||||||
:::note RAM requirements
|
:::tip
|
||||||
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,12 +26,6 @@ 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 {
|
||||||
|
|||||||
+1
-2
@@ -20,7 +20,6 @@
|
|||||||
"@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",
|
||||||
@@ -58,6 +57,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.0"
|
"node": "24.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-30
@@ -8,19 +8,19 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'GoogleSans';
|
font-family: 'Overpass';
|
||||||
src: url('/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
|
src: url('/fonts/overpass/Overpass.ttf') format('truetype-variations');
|
||||||
font-weight: 410 900;
|
font-weight: 1 999;
|
||||||
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: 'GoogleSansCode';
|
font-family: 'Overpass Mono';
|
||||||
src: url('/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
|
src: url('/fonts/overpass/OverpassMono.ttf') format('truetype-variations');
|
||||||
font-weight: 1 900;
|
font-weight: 1 999;
|
||||||
font-style: monospace;
|
font-style: normal;
|
||||||
ascent-override: 106.25%;
|
ascent-override: 106.25%;
|
||||||
size-adjust: 106.25%;
|
size-adjust: 106.25%;
|
||||||
}
|
}
|
||||||
@@ -37,8 +37,7 @@ img {
|
|||||||
|
|
||||||
/* You can override the default Infima variables here. */
|
/* You can override the default Infima variables here. */
|
||||||
:root {
|
:root {
|
||||||
font-family: 'GoogleSans', sans-serif;
|
font-family: 'Overpass', 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;
|
||||||
@@ -49,16 +48,6 @@ 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;
|
||||||
@@ -82,22 +71,15 @@ 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: 600;
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
.table-of-contents__link {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 450;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* workaround for version switcher PR 15894 */
|
/* workaround for version switcher PR 15894 */
|
||||||
@@ -106,14 +88,13 @@ div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
font-family: 'GoogleSansCode';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.buy-button {
|
.buy-button {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
font-family: 'GoogleSans', sans-serif;
|
font-family: 'Overpass', 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.
@@ -1,6 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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.13.0
|
24.12.0
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
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
|
||||||
@@ -33,6 +27,8 @@ 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
|
||||||
|
|||||||
+5
-3
@@ -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.8",
|
"@types/node": "^24.10.4",
|
||||||
|
"@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",
|
||||||
@@ -38,7 +38,9 @@
|
|||||||
"eslint-plugin-unicorn": "^62.0.0",
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
"exiftool-vendored": "^34.3.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",
|
||||||
@@ -52,6 +54,6 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "24.13.0"
|
"node": "24.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ dotenv.config({ path: resolve(import.meta.dirname, '.env') });
|
|||||||
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
|
||||||
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
|
||||||
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
|
||||||
export const playwriteSlowMo = Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
|
||||||
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
|
||||||
|
|
||||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
|
||||||
@@ -40,9 +40,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
workers: 1,
|
workers: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ui',
|
name: 'parallel tests',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
testMatch: /.*\.ui-spec\.ts/,
|
testMatch: /.*\.parallel-e2e-spec\.ts/,
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
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,7 +14,6 @@ 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
|
||||||
@@ -27,17 +26,6 @@ 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' });
|
||||||
@@ -51,7 +39,6 @@ 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);
|
||||||
@@ -82,7 +69,6 @@ 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];
|
||||||
@@ -93,13 +79,12 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { status, body } = await request(app).get('/server/config');
|
const { body } = await request(app).get('/server/config');
|
||||||
expect(status).toBe(200);
|
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 500,
|
interval: 5e2,
|
||||||
timeout: 10_000,
|
timeout: 1e4,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
@@ -117,17 +102,6 @@ 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({});
|
||||||
@@ -184,13 +158,12 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { status, body } = await request(app).get('/server/config');
|
const { body } = await request(app).get('/server/config');
|
||||||
expect(status).toBe(200);
|
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 500,
|
interval: 5e2,
|
||||||
timeout: 10_000,
|
timeout: 1e4,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeFalsy();
|
.toBeFalsy();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { OAuthClient, OAuthUser } from '@immich/e2e-auth-server';
|
|
||||||
import {
|
import {
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
SystemConfigOAuthDto,
|
SystemConfigOAuthDto,
|
||||||
@@ -9,12 +8,13 @@ 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://e2e-auth-server:2286',
|
internal: 'http://auth-server:2286',
|
||||||
external: 'http://127.0.0.1:2286',
|
external: 'http://127.0.0.1:2286',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,5 +26,6 @@ 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,9 +346,6 @@ 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,4 +1,3 @@
|
|||||||
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 {
|
||||||
@@ -64,33 +63,15 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/*', async (route, request) => {
|
await context.route('**/api/assets/*', async (route, request) => {
|
||||||
if (request.method() === 'GET') {
|
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
const assetId = basename(pathname);
|
const assetId = basename(pathname);
|
||||||
let asset = getAsset(timelineRestData, assetId);
|
const asset = getAsset(timelineRestData, assetId);
|
||||||
if (changes.assetDeletions.includes(asset!.id)) {
|
|
||||||
asset = {
|
|
||||||
...asset,
|
|
||||||
isTrashed: true,
|
|
||||||
} as AssetResponseDto;
|
|
||||||
}
|
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
json: asset,
|
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) => {
|
||||||
@@ -136,28 +117,17 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/albums/**', async (route, request) => {
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
|
||||||
if (albumsMatch) {
|
const match = request.url().match(pattern);
|
||||||
const album = getAlbum(timelineRestData, testContext.adminId, albumsMatch.groups?.albumId, changes);
|
if (!match) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
json: album,
|
json: album,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return route.fallback();
|
|
||||||
});
|
|
||||||
|
|
||||||
await context.route('**/api/albums**', async (route, request) => {
|
|
||||||
const allAlbums = request.url().match(/\/api\/albums\?assetId=(?<assetId>[^&]+)/);
|
|
||||||
if (allAlbums) {
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
json: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return route.fallback();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const setup = async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
const onStart = () => console.log(`[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();
|
||||||
};
|
};
|
||||||
+8
-93
@@ -6,9 +6,7 @@ import {
|
|||||||
CheckExistingAssetsDto,
|
CheckExistingAssetsDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
JobCreateDto,
|
|
||||||
MaintenanceAction,
|
MaintenanceAction,
|
||||||
ManualJobName,
|
|
||||||
MetadataSearchDto,
|
MetadataSearchDto,
|
||||||
Permission,
|
Permission,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
@@ -23,7 +21,6 @@ import {
|
|||||||
checkExistingAssets,
|
checkExistingAssets,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
createJob,
|
|
||||||
createLibrary,
|
createLibrary,
|
||||||
createPartner,
|
createPartner,
|
||||||
createPerson,
|
createPerson,
|
||||||
@@ -31,12 +28,10 @@ import {
|
|||||||
createStack,
|
createStack,
|
||||||
createUserAdmin,
|
createUserAdmin,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
deleteDatabaseBackup,
|
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
getQueuesLegacy,
|
getQueuesLegacy,
|
||||||
listDatabaseBackups,
|
|
||||||
login,
|
login,
|
||||||
runQueueCommandLegacy,
|
runQueueCommandLegacy,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
@@ -57,15 +52,11 @@ 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 { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
import { 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, join, resolve } from 'node:path';
|
import { dirname, 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';
|
||||||
@@ -93,9 +84,8 @@ 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 dockerExec = (args: string[]) =>
|
export const immichAdmin = (args: string[]) =>
|
||||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${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];
|
||||||
|
|
||||||
@@ -159,26 +149,12 @@ 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 {
|
||||||
client = await utils.connectDatabase();
|
if (!client) {
|
||||||
|
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
|
||||||
@@ -505,9 +481,6 @@ 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) }),
|
||||||
|
|
||||||
@@ -586,45 +559,6 @@ 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) });
|
||||||
@@ -667,25 +601,6 @@ 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();
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
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 } 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(() => {
|
|
||||||
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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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/maintenance');
|
await page.goto('/admin/system-settings?isOpen=maintenance');
|
||||||
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
await page.getByRole('button', { name: 'Start 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/maintenance*', { timeout: 10_000 });
|
await page.waitForURL('**/admin/system-settings*', { 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').locator('visible=true');
|
return page.getByAltText('Image taken on').locator('visible=true');
|
||||||
}
|
}
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
|
|||||||
+3
-1
@@ -18,6 +18,7 @@ import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } fro
|
|||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
import {
|
import {
|
||||||
assetViewerUtils,
|
assetViewerUtils,
|
||||||
|
cancelAllPollers,
|
||||||
padYearMonth,
|
padYearMonth,
|
||||||
pageUtils,
|
pageUtils,
|
||||||
poll,
|
poll,
|
||||||
@@ -63,6 +64,7 @@ test.describe('Timeline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(() => {
|
test.afterEach(() => {
|
||||||
|
cancelAllPollers();
|
||||||
testContext.slowBucket = false;
|
testContext.slowBucket = false;
|
||||||
changes.albumAdditions = [];
|
changes.albumAdditions = [];
|
||||||
changes.assetDeletions = [];
|
changes.assetDeletions = [];
|
||||||
@@ -461,7 +463,7 @@ test.describe('Timeline', () => {
|
|||||||
});
|
});
|
||||||
changes.albumAdditions.push(...requestJson.ids);
|
changes.albumAdditions.push(...requestJson.ids);
|
||||||
});
|
});
|
||||||
await page.getByText('Add assets').click();
|
await page.getByText('Done').click();
|
||||||
await expect(put).resolves.toEqual({
|
await expect(put).resolves.toEqual({
|
||||||
ids: [
|
ids: [
|
||||||
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
|
||||||
@@ -23,6 +23,13 @@ export async function throttlePage(context: BrowserContext, page: Page) {
|
|||||||
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activePollsAbortController = new AbortController();
|
||||||
|
|
||||||
|
export const cancelAllPollers = () => {
|
||||||
|
activePollsAbortController.abort();
|
||||||
|
activePollsAbortController = new AbortController();
|
||||||
|
};
|
||||||
|
|
||||||
export const poll = async <T>(
|
export const poll = async <T>(
|
||||||
page: Page,
|
page: Page,
|
||||||
query: () => Promise<T>,
|
query: () => Promise<T>,
|
||||||
@@ -30,14 +37,21 @@ export const poll = async <T>(
|
|||||||
) => {
|
) => {
|
||||||
let result;
|
let result;
|
||||||
const timeout = Date.now() + 10_000;
|
const timeout = Date.now() + 10_000;
|
||||||
|
const signal = activePollsAbortController.signal;
|
||||||
|
|
||||||
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
|
||||||
while (!terminate(result) && Date.now() < timeout) {
|
while (!terminate(result) && Date.now() < timeout) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
result = await query();
|
result = await query();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (page.isClosed()) {
|
if (page.isClosed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -167,12 +181,8 @@ export const assetViewerUtils = {
|
|||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
await page
|
await page
|
||||||
.locator(
|
.locator(`img[draggable="false"][src="/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}"]`))
|
||||||
)
|
|
||||||
.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: 'Save' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).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: 'Save' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).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[] = [];
|
const globalSetup: string[] = ['src/setup/auth-server.ts'];
|
||||||
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 {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"jsonRecursiveSort": true,
|
|
||||||
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
|
|
||||||
"plugins": ["prettier-plugin-sort-json"]
|
|
||||||
}
|
|
||||||
+9
-103
@@ -18,7 +18,6 @@
|
|||||||
"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",
|
||||||
@@ -104,8 +103,6 @@
|
|||||||
"image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
|
"image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
|
||||||
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
|
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
|
||||||
"image_preview_title": "Preview Settings",
|
"image_preview_title": "Preview Settings",
|
||||||
"image_progressive": "Progressive",
|
|
||||||
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
|
|
||||||
"image_quality": "Quality",
|
"image_quality": "Quality",
|
||||||
"image_resolution": "Resolution",
|
"image_resolution": "Resolution",
|
||||||
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
|
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
|
||||||
@@ -190,21 +187,10 @@
|
|||||||
"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": "Switch to maintenance mode",
|
"maintenance_start": "Start 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",
|
||||||
@@ -492,7 +478,6 @@
|
|||||||
"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?",
|
||||||
@@ -514,7 +499,6 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"all_albums": "All albums",
|
"all_albums": "All albums",
|
||||||
"all_people": "All people",
|
"all_people": "All people",
|
||||||
"all_photos": "All photos",
|
|
||||||
"all_videos": "All videos",
|
"all_videos": "All videos",
|
||||||
"allow_dark_mode": "Allow dark mode",
|
"allow_dark_mode": "Allow dark mode",
|
||||||
"allow_edits": "Allow edits",
|
"allow_edits": "Allow edits",
|
||||||
@@ -522,9 +506,6 @@
|
|||||||
"allow_public_user_to_upload": "Allow public user to upload",
|
"allow_public_user_to_upload": "Allow public user to upload",
|
||||||
"allowed": "Allowed",
|
"allowed": "Allowed",
|
||||||
"alt_text_qr_code": "QR code image",
|
"alt_text_qr_code": "QR code image",
|
||||||
"always_keep": "Always keep",
|
|
||||||
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
|
|
||||||
"always_keep_videos_hint": "Free Up Space will keep all videos on this device.",
|
|
||||||
"anti_clockwise": "Anti-clockwise",
|
"anti_clockwise": "Anti-clockwise",
|
||||||
"api_key": "API Key",
|
"api_key": "API Key",
|
||||||
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
|
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
|
||||||
@@ -620,7 +601,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",
|
||||||
@@ -753,18 +734,6 @@
|
|||||||
"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_found_assets": "Found {count} backed up assets",
|
|
||||||
"cleanup_found_assets_with_size": "Found {count} backed up assets ({size})",
|
|
||||||
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
|
|
||||||
"cleanup_no_assets_found": "No assets found matching the criteria above. Free Up Space can only remove assets that have been backed up to the server",
|
|
||||||
"cleanup_preview_title": "Assets to remove ({count})",
|
|
||||||
"cleanup_step3_description": "Scan for backed up assets matching your date and keep settings.",
|
|
||||||
"cleanup_step4_summary": "{count} assets (created before {date}) to remove from your local device. Photos will remain accessible from the Immich app.",
|
|
||||||
"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",
|
||||||
@@ -850,20 +819,13 @@
|
|||||||
"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": "Keep photos from the last…",
|
|
||||||
"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",
|
||||||
@@ -945,7 +907,6 @@
|
|||||||
"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",
|
||||||
@@ -955,7 +916,6 @@
|
|||||||
"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",
|
||||||
@@ -988,13 +948,9 @@
|
|||||||
"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_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
|
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
|
||||||
"editor_flip_horizontal": "Flip horizontal",
|
"editor_crop_tool_h2_rotation": "Rotation",
|
||||||
"editor_flip_vertical": "Flip vertical",
|
"editor_mode": "Editor mode",
|
||||||
"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",
|
||||||
@@ -1013,14 +969,11 @@
|
|||||||
"error_change_sort_album": "Failed to change album sort order",
|
"error_change_sort_album": "Failed to change album sort order",
|
||||||
"error_delete_face": "Error deleting face from asset",
|
"error_delete_face": "Error deleting face from asset",
|
||||||
"error_getting_places": "Error getting places",
|
"error_getting_places": "Error getting places",
|
||||||
"error_loading_albums": "Error loading albums",
|
|
||||||
"error_loading_image": "Error loading image",
|
"error_loading_image": "Error loading image",
|
||||||
"error_loading_partners": "Error loading partners: {error}",
|
"error_loading_partners": "Error loading partners: {error}",
|
||||||
"error_retrieving_asset_information": "Error retrieving asset information",
|
|
||||||
"error_saving_image": "Error: {error}",
|
"error_saving_image": "Error: {error}",
|
||||||
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
|
"error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates",
|
||||||
"error_title": "Error - Something went wrong",
|
"error_title": "Error - Something went wrong",
|
||||||
"error_while_navigating": "Error while navigating to asset",
|
|
||||||
"errors": {
|
"errors": {
|
||||||
"cannot_navigate_next_asset": "Cannot navigate to the next asset",
|
"cannot_navigate_next_asset": "Cannot navigate to the next asset",
|
||||||
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
"cannot_navigate_previous_asset": "Cannot navigate to previous asset",
|
||||||
@@ -1129,7 +1082,6 @@
|
|||||||
"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",
|
||||||
@@ -1144,7 +1096,6 @@
|
|||||||
"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...",
|
||||||
@@ -1189,7 +1140,7 @@
|
|||||||
"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",
|
||||||
@@ -1208,9 +1159,6 @@
|
|||||||
"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.",
|
||||||
@@ -1326,15 +1274,8 @@
|
|||||||
"json_editor": "JSON editor",
|
"json_editor": "JSON editor",
|
||||||
"json_error": "JSON error",
|
"json_error": "JSON error",
|
||||||
"keep": "Keep",
|
"keep": "Keep",
|
||||||
"keep_albums": "Keep albums",
|
|
||||||
"keep_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
|
|
||||||
"keep_all": "Keep All",
|
"keep_all": "Keep All",
|
||||||
"keep_description": "Choose what stays on your device when freeing up space.",
|
|
||||||
"keep_favorites": "Keep favorites",
|
|
||||||
"keep_on_device": "Keep on device",
|
|
||||||
"keep_on_device_hint": "Select items to keep on this device",
|
|
||||||
"keep_this_delete_others": "Keep this, delete others",
|
"keep_this_delete_others": "Keep this, delete others",
|
||||||
"keeping": "Keeping: {items}",
|
|
||||||
"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",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -1428,28 +1369,10 @@
|
|||||||
"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",
|
||||||
@@ -1511,8 +1434,6 @@
|
|||||||
"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",
|
||||||
@@ -1524,7 +1445,6 @@
|
|||||||
"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",
|
||||||
@@ -1564,12 +1484,11 @@
|
|||||||
"next_memory": "Next memory",
|
"next_memory": "Next memory",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"no_actions_added": "No actions added yet",
|
"no_actions_added": "No actions added yet",
|
||||||
"no_albums_found": "No albums found",
|
|
||||||
"no_albums_message": "Create an album to organize your photos and videos",
|
"no_albums_message": "Create an album to organize your photos and videos",
|
||||||
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
|
||||||
"no_albums_yet": "It looks like you do not have any albums yet.",
|
"no_albums_yet": "It looks like you do not have any albums yet.",
|
||||||
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
|
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
|
||||||
"no_assets_message": "Click to upload your first photo",
|
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
|
||||||
"no_assets_to_show": "No assets to show",
|
"no_assets_to_show": "No assets to show",
|
||||||
"no_cast_devices_found": "No cast devices found",
|
"no_cast_devices_found": "No cast devices found",
|
||||||
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
"no_checksum_local": "No checksum available - cannot fetch local assets",
|
||||||
@@ -1594,7 +1513,6 @@
|
|||||||
"no_results_description": "Try a synonym or more general keyword",
|
"no_results_description": "Try a synonym or more general keyword",
|
||||||
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
|
||||||
"no_uploads_in_progress": "No uploads in progress",
|
"no_uploads_in_progress": "No uploads in progress",
|
||||||
"none": "None",
|
|
||||||
"not_allowed": "Not allowed",
|
"not_allowed": "Not allowed",
|
||||||
"not_available": "N/A",
|
"not_available": "N/A",
|
||||||
"not_in_any_album": "Not in any album",
|
"not_in_any_album": "Not in any album",
|
||||||
@@ -1709,7 +1627,6 @@
|
|||||||
"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",
|
||||||
@@ -1785,12 +1702,10 @@
|
|||||||
"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",
|
||||||
@@ -1890,11 +1805,9 @@
|
|||||||
"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",
|
||||||
@@ -1924,7 +1837,6 @@
|
|||||||
"search_filter_media_type_title": "Select media type",
|
"search_filter_media_type_title": "Select media type",
|
||||||
"search_filter_ocr": "Search by OCR",
|
"search_filter_ocr": "Search by OCR",
|
||||||
"search_filter_people_title": "Select people",
|
"search_filter_people_title": "Select people",
|
||||||
"search_filter_star_rating": "Star Rating",
|
|
||||||
"search_for": "Search for",
|
"search_for": "Search for",
|
||||||
"search_for_existing_person": "Search for existing person",
|
"search_for_existing_person": "Search for existing person",
|
||||||
"search_no_more_result": "No more results",
|
"search_no_more_result": "No more results",
|
||||||
@@ -1967,7 +1879,6 @@
|
|||||||
"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",
|
||||||
@@ -2129,8 +2040,6 @@
|
|||||||
"skip_to_folders": "Skip to folders",
|
"skip_to_folders": "Skip to folders",
|
||||||
"skip_to_tags": "Skip to tags",
|
"skip_to_tags": "Skip to tags",
|
||||||
"slideshow": "Slideshow",
|
"slideshow": "Slideshow",
|
||||||
"slideshow_repeat": "Repeat slideshow",
|
|
||||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
|
||||||
"slideshow_settings": "Slideshow settings",
|
"slideshow_settings": "Slideshow settings",
|
||||||
"sort_albums_by": "Sort albums by...",
|
"sort_albums_by": "Sort albums by...",
|
||||||
"sort_created": "Date created",
|
"sort_created": "Date created",
|
||||||
@@ -2207,7 +2116,6 @@
|
|||||||
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
||||||
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||||
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
"theme_setting_three_stage_loading_title": "Enable three-stage loading",
|
||||||
"then": "Then",
|
|
||||||
"they_will_be_merged_together": "They will be merged together",
|
"they_will_be_merged_together": "They will be merged together",
|
||||||
"third_party_resources": "Third-Party Resources",
|
"third_party_resources": "Third-Party Resources",
|
||||||
"time": "Time",
|
"time": "Time",
|
||||||
@@ -2263,7 +2171,6 @@
|
|||||||
"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",
|
||||||
@@ -2288,6 +2195,7 @@
|
|||||||
"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?",
|
||||||
@@ -2306,7 +2214,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.",
|
||||||
@@ -2339,7 +2247,6 @@
|
|||||||
"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",
|
||||||
@@ -2389,7 +2296,6 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,14 +92,14 @@ FROM python:3.13-slim-trixie@sha256:0222b795db95bf7412cede36ab46a266cfb31f632e64
|
|||||||
|
|
||||||
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/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-core-2_2.24.8+20344_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/intel-graphics-compiler/releases/download/v2.24.8/intel-igc-opencl-2_2.24.8+20344_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/compute-runtime/releases/download/25.48.36300.8/intel-opencl-icd_25.48.36300.8-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-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/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 && \
|
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/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/25.48.36300.8/libigdgmm12_22.8.2_amd64.deb && \
|
||||||
dpkg -i *.deb && \
|
dpkg -i *.deb && \
|
||||||
rm *.deb && \
|
rm *.deb && \
|
||||||
apt-get remove wget -yqq && \
|
apt-get remove wget -yqq && \
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
experimental_monorepo_root = true
|
experimental_monorepo_root = true
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
node = "24.13.0"
|
node = "24.12.0"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.28.0"
|
pnpm = "10.27.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 run format:fix"
|
run = "pnpm dlx sort-json *.json"
|
||||||
|
|||||||
@@ -117,9 +117,6 @@
|
|||||||
<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,40 +252,6 @@ 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) {
|
||||||
@@ -309,11 +275,6 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,10 +296,6 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +315,6 @@ 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. */
|
||||||
@@ -552,23 +508,6 @@ 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,7 +14,6 @@ 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
|
||||||
@@ -22,6 +21,7 @@ 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) {
|
||||||
currentCoroutineContext().ensureActive()
|
coroutineContext.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,10 +316,4 @@ 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.
@@ -34,4 +34,3 @@ Runner/GeneratedPluginRegistrant.*
|
|||||||
|
|
||||||
fastlane/report.xml
|
fastlane/report.xml
|
||||||
Gemfile.lock
|
Gemfile.lock
|
||||||
certs/
|
|
||||||
@@ -55,7 +55,6 @@ 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,60 +1,6 @@
|
|||||||
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,39 +312,6 @@ 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 {
|
||||||
@@ -356,8 +323,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -378,9 +343,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -415,7 +377,6 @@ 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`.
|
||||||
@@ -599,22 +560,5 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,28 +390,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ platform :ios do
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
|
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
|
||||||
BASE_BUNDLE_ID = "app.alextran.immich"
|
BASE_BUNDLE_ID = "app.alextran.immich"
|
||||||
|
|
||||||
|
# App identifiers for production
|
||||||
|
PROD_APP_IDENTIFIERS = [
|
||||||
|
"app.alextran.immich",
|
||||||
|
"app.alextran.immich.ShareExtension",
|
||||||
|
"app.alextran.immich.Widget"
|
||||||
|
]
|
||||||
|
|
||||||
|
# App identifiers for development
|
||||||
|
DEV_APP_IDENTIFIERS = [
|
||||||
|
"app.alextran.immich.development",
|
||||||
|
"app.alextran.immich.development.ShareExtension",
|
||||||
|
"app.alextran.immich.development.Widget"
|
||||||
|
]
|
||||||
|
|
||||||
# Helper method to get App Store Connect API key
|
# Helper method to get App Store Connect API key
|
||||||
def get_api_key
|
def get_api_key
|
||||||
app_store_connect_api_key(
|
app_store_connect_api_key(
|
||||||
@@ -32,6 +46,20 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper method to sync certificates and profiles using match
|
||||||
|
def sync_code_signing(app_identifiers:, readonly: true)
|
||||||
|
# Use fastlane's setup_ci which creates a temporary keychain and handles everything
|
||||||
|
if ENV["CI"]
|
||||||
|
setup_ci
|
||||||
|
end
|
||||||
|
|
||||||
|
match(
|
||||||
|
type: "appstore",
|
||||||
|
app_identifier: app_identifiers,
|
||||||
|
readonly: readonly
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# Helper method to get version from pubspec.yaml
|
# Helper method to get version from pubspec.yaml
|
||||||
def get_version_from_pubspec
|
def get_version_from_pubspec
|
||||||
require 'yaml'
|
require 'yaml'
|
||||||
@@ -44,7 +72,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: "", profile_name_main:, profile_name_share:, profile_name_widget:)
|
def configure_code_signing(bundle_id_suffix: "")
|
||||||
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 +82,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: profile_name_main,
|
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}",
|
||||||
targets: ["Runner"]
|
targets: ["Runner"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +93,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: profile_name_share,
|
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
|
||||||
targets: ["ShareExtension"]
|
targets: ["ShareExtension"]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,7 +104,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: profile_name_widget,
|
profile_name: "match AppStore #{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
|
||||||
targets: ["WidgetExtension"]
|
targets: ["WidgetExtension"]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -87,10 +115,7 @@ 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}"
|
||||||
@@ -118,9 +143,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}" => profile_name_main,
|
"#{app_identifier}" => "match AppStore #{app_identifier}",
|
||||||
"#{app_identifier}.ShareExtension" => profile_name_share,
|
"#{app_identifier}.ShareExtension" => "match AppStore #{app_identifier}.ShareExtension",
|
||||||
"#{app_identifier}.Widget" => profile_name_widget
|
"#{app_identifier}.Widget" => "match AppStore #{app_identifier}.Widget"
|
||||||
},
|
},
|
||||||
signingStyle: "manual",
|
signingStyle: "manual",
|
||||||
signingCertificate: CODE_SIGN_IDENTITY
|
signingCertificate: CODE_SIGN_IDENTITY
|
||||||
@@ -139,35 +164,18 @@ end
|
|||||||
lane :gha_testflight_dev do
|
lane :gha_testflight_dev do
|
||||||
api_key = get_api_key
|
api_key = get_api_key
|
||||||
|
|
||||||
# Download and install provisioning profiles from App Store Connect
|
# Sync certificates and profiles using match
|
||||||
# Certificate is imported by GHA workflow into build.keychain
|
sync_code_signing(app_identifiers: DEV_APP_IDENTIFIERS)
|
||||||
# Capture profile names after each sigh call
|
|
||||||
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)
|
# Configure code signing for dev bundle IDs
|
||||||
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
configure_code_signing(bundle_id_suffix: "development")
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -175,33 +183,17 @@ end
|
|||||||
lane :gha_release_prod do
|
lane :gha_release_prod do
|
||||||
api_key = get_api_key
|
api_key = get_api_key
|
||||||
|
|
||||||
# Download and install provisioning profiles from App Store Connect
|
# Sync certificates and profiles using match
|
||||||
# Certificate is imported by GHA workflow into build.keychain
|
sync_code_signing(app_identifiers: PROD_APP_IDENTIFIERS)
|
||||||
sigh(api_key: api_key, app_identifier: BASE_BUNDLE_ID, force: true)
|
|
||||||
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
|
||||||
|
|
||||||
@@ -246,28 +238,13 @@ 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
|
||||||
|
|
||||||
api_key = get_api_key
|
# Sync certificates and profiles using match (use production profiles)
|
||||||
|
sync_code_signing(app_identifiers: PROD_APP_IDENTIFIERS)
|
||||||
|
|
||||||
# Download and install provisioning profiles from App Store Connect
|
# Configure code signing for production bundle IDs
|
||||||
# Certificate is imported by GHA workflow into build.keychain
|
configure_code_signing
|
||||||
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)
|
# Build the app (same as production but without upload)
|
||||||
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(
|
|
||||||
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_app(
|
build_app(
|
||||||
scheme: "Runner",
|
scheme: "Runner",
|
||||||
workspace: "Runner.xcworkspace",
|
workspace: "Runner.xcworkspace",
|
||||||
@@ -277,9 +254,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" => main_profile_name,
|
"#{BASE_BUNDLE_ID}" => "match AppStore #{BASE_BUNDLE_ID}",
|
||||||
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
|
"#{BASE_BUNDLE_ID}.ShareExtension" => "match AppStore #{BASE_BUNDLE_ID}.ShareExtension",
|
||||||
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
|
"#{BASE_BUNDLE_ID}.Widget" => "match AppStore #{BASE_BUNDLE_ID}.Widget"
|
||||||
},
|
},
|
||||||
signingStyle: "manual",
|
signingStyle: "manual",
|
||||||
signingCertificate: CODE_SIGN_IDENTITY
|
signingCertificate: CODE_SIGN_IDENTITY
|
||||||
@@ -287,4 +264,30 @@ end
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc "Sync all certificates and profiles (run locally to update match repo)"
|
||||||
|
lane :sync_certificates do
|
||||||
|
# Sync production certificates and profiles
|
||||||
|
match(
|
||||||
|
type: "appstore",
|
||||||
|
app_identifier: PROD_APP_IDENTIFIERS,
|
||||||
|
readonly: false
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sync development certificates and profiles
|
||||||
|
match(
|
||||||
|
type: "appstore",
|
||||||
|
app_identifier: DEV_APP_IDENTIFIERS,
|
||||||
|
readonly: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Regenerate all certificates and profiles (use when expired)"
|
||||||
|
lane :regenerate_certificates do
|
||||||
|
# Nuke existing certificates
|
||||||
|
match_nuke(type: "appstore")
|
||||||
|
|
||||||
|
# Generate new ones
|
||||||
|
sync_certificates
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
git_url(ENV["MATCH_GIT_URL"] || "https://github.com/immich-app/ios-certs")
|
||||||
|
|
||||||
|
storage_mode("git")
|
||||||
|
|
||||||
|
type("appstore")
|
||||||
|
|
||||||
|
team_id("2F67MQ8R79")
|
||||||
|
|
||||||
|
app_identifier([
|
||||||
|
"app.alextran.immich",
|
||||||
|
"app.alextran.immich.ShareExtension",
|
||||||
|
"app.alextran.immich.Widget",
|
||||||
|
"app.alextran.immich.development",
|
||||||
|
"app.alextran.immich.development.ShareExtension",
|
||||||
|
"app.alextran.immich.development.Widget"
|
||||||
|
])
|
||||||
|
|
||||||
|
# For all available options run `fastlane match --help`
|
||||||
|
# The docs are available on https://docs.fastlane.tools/actions/match
|
||||||
@@ -39,6 +39,30 @@ iOS Release to TestFlight
|
|||||||
|
|
||||||
iOS Manual Release
|
iOS Manual Release
|
||||||
|
|
||||||
|
### ios gha_build_only
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios gha_build_only
|
||||||
|
```
|
||||||
|
|
||||||
|
iOS Build Only (no TestFlight upload)
|
||||||
|
|
||||||
|
### ios sync_certificates
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios sync_certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Sync all certificates and profiles (run locally to update match repo)
|
||||||
|
|
||||||
|
### ios regenerate_certificates
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios regenerate_certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate all certificates and profiles (use when expired)
|
||||||
|
|
||||||
----
|
----
|
||||||
|
|
||||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ 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,7 +7,3 @@ enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
|||||||
enum SortUserBy { id }
|
enum SortUserBy { id }
|
||||||
|
|
||||||
enum ActionSource { timeline, viewer }
|
enum ActionSource { timeline, viewer }
|
||||||
|
|
||||||
enum CleanupStep { selectDate, scan, delete }
|
|
||||||
|
|
||||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
|
||||||
|
|||||||
@@ -51,4 +51,4 @@ const Map<String, Locale> locales = {
|
|||||||
|
|
||||||
const String translationsPath = 'assets/i18n';
|
const String translationsPath = 'assets/i18n';
|
||||||
|
|
||||||
const List<Locale> localesNotSupportedByAppFont = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];
|
const List<Locale> localesNotSupportedByOverpass = [Locale('el', 'GR'), Locale('sr', 'Cyrl')];
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
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,7 +22,6 @@ 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,
|
||||||
@@ -35,7 +34,6 @@ 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;
|
||||||
@@ -73,7 +71,6 @@ sealed class BaseAsset {
|
|||||||
height: ${height ?? "<NA>"},
|
height: ${height ?? "<NA>"},
|
||||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||||
isFavorite: $isFavorite,
|
isFavorite: $isFavorite,
|
||||||
isEdited: $isEdited,
|
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +85,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -103,7 +99,6 @@ sealed class BaseAsset {
|
|||||||
width.hashCode ^
|
width.hashCode ^
|
||||||
height.hashCode ^
|
height.hashCode ^
|
||||||
durationInSeconds.hashCode ^
|
durationInSeconds.hashCode ^
|
||||||
isFavorite.hashCode ^
|
isFavorite.hashCode;
|
||||||
isEdited.hashCode;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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;
|
||||||
@@ -13,7 +12,6 @@ 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,
|
||||||
@@ -28,7 +26,6 @@ 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
|
||||||
@@ -56,9 +53,7 @@ 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,
|
||||||
@@ -74,7 +69,6 @@ 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 &&
|
||||||
@@ -94,7 +88,6 @@ 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,
|
||||||
@@ -108,12 +101,10 @@ 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,
|
||||||
@@ -127,7 +118,6 @@ 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,7 +28,6 @@ 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
|
||||||
@@ -105,7 +104,6 @@ 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,
|
||||||
@@ -124,7 +122,6 @@ 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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,3 @@ class MultiSelectToggleEvent extends Event {
|
|||||||
final bool isEnabled;
|
final bool isEnabled;
|
||||||
const MultiSelectToggleEvent(this.isEnabled);
|
const MultiSelectToggleEvent(this.isEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map Events
|
|
||||||
class MapMarkerReloadEvent extends Event {
|
|
||||||
const MapMarkerReloadEvent();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ class ExifInfo {
|
|||||||
final String? orientation;
|
final String? orientation;
|
||||||
final String? timeZone;
|
final String? timeZone;
|
||||||
final DateTime? dateTimeOriginal;
|
final DateTime? dateTimeOriginal;
|
||||||
final int? rating;
|
|
||||||
|
|
||||||
// GPS
|
// GPS
|
||||||
final double? latitude;
|
final double? latitude;
|
||||||
@@ -47,7 +46,6 @@ class ExifInfo {
|
|||||||
this.orientation,
|
this.orientation,
|
||||||
this.timeZone,
|
this.timeZone,
|
||||||
this.dateTimeOriginal,
|
this.dateTimeOriginal,
|
||||||
this.rating,
|
|
||||||
this.isFlipped = false,
|
this.isFlipped = false,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
@@ -73,7 +71,6 @@ class ExifInfo {
|
|||||||
other.orientation == orientation &&
|
other.orientation == orientation &&
|
||||||
other.timeZone == timeZone &&
|
other.timeZone == timeZone &&
|
||||||
other.dateTimeOriginal == dateTimeOriginal &&
|
other.dateTimeOriginal == dateTimeOriginal &&
|
||||||
other.rating == rating &&
|
|
||||||
other.latitude == latitude &&
|
other.latitude == latitude &&
|
||||||
other.longitude == longitude &&
|
other.longitude == longitude &&
|
||||||
other.city == city &&
|
other.city == city &&
|
||||||
@@ -97,7 +94,6 @@ class ExifInfo {
|
|||||||
isFlipped.hashCode ^
|
isFlipped.hashCode ^
|
||||||
timeZone.hashCode ^
|
timeZone.hashCode ^
|
||||||
dateTimeOriginal.hashCode ^
|
dateTimeOriginal.hashCode ^
|
||||||
rating.hashCode ^
|
|
||||||
latitude.hashCode ^
|
latitude.hashCode ^
|
||||||
longitude.hashCode ^
|
longitude.hashCode ^
|
||||||
city.hashCode ^
|
city.hashCode ^
|
||||||
@@ -122,7 +118,6 @@ orientation: ${orientation ?? 'NA'},
|
|||||||
isFlipped: $isFlipped,
|
isFlipped: $isFlipped,
|
||||||
timeZone: ${timeZone ?? 'NA'},
|
timeZone: ${timeZone ?? 'NA'},
|
||||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||||
rating: ${rating ?? 'NA'},
|
|
||||||
latitude: ${latitude ?? 'NA'},
|
latitude: ${latitude ?? 'NA'},
|
||||||
longitude: ${longitude ?? 'NA'},
|
longitude: ${longitude ?? 'NA'},
|
||||||
city: ${city ?? 'NA'},
|
city: ${city ?? 'NA'},
|
||||||
@@ -145,7 +140,6 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
|||||||
String? orientation,
|
String? orientation,
|
||||||
String? timeZone,
|
String? timeZone,
|
||||||
DateTime? dateTimeOriginal,
|
DateTime? dateTimeOriginal,
|
||||||
int? rating,
|
|
||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
String? city,
|
String? city,
|
||||||
@@ -167,7 +161,6 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
|||||||
orientation: orientation ?? this.orientation,
|
orientation: orientation ?? this.orientation,
|
||||||
timeZone: timeZone ?? this.timeZone,
|
timeZone: timeZone ?? this.timeZone,
|
||||||
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
||||||
rating: rating ?? this.rating,
|
|
||||||
isFlipped: isFlipped ?? this.isFlipped,
|
isFlipped: isFlipped ?? this.isFlipped,
|
||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
|
|||||||
@@ -82,14 +82,7 @@ enum StoreKey<T> {
|
|||||||
useWifiForUploadPhotos<bool>._(1005),
|
useWifiForUploadPhotos<bool>._(1005),
|
||||||
needBetaMigration<bool>._(1006),
|
needBetaMigration<bool>._(1006),
|
||||||
// TODO: Remove this after patching open-api
|
// TODO: Remove this after patching open-api
|
||||||
shouldResetSync<bool>._(1007),
|
shouldResetSync<bool>._(1007);
|
||||||
|
|
||||||
// Free up space
|
|
||||||
cleanupKeepFavorites<bool>._(1008),
|
|
||||||
cleanupKeepMediaType<int>._(1009),
|
|
||||||
cleanupKeepAlbumIds<String>._(1010),
|
|
||||||
cleanupCutoffDaysAgo<int>._(1011),
|
|
||||||
cleanupDefaultsInitialized<bool>._(1012);
|
|
||||||
|
|
||||||
const StoreKey._(this.id);
|
const StoreKey._(this.id);
|
||||||
final int id;
|
final int id;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||||
|
|
||||||
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
|
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
|
||||||
|
|
||||||
@@ -98,7 +99,9 @@ class AssetService {
|
|||||||
height = fetched?.height?.toDouble();
|
height = fetched?.height?.toDouble();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (width: width, height: height, isFlipped: false);
|
final exif = await getExif(asset);
|
||||||
|
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
||||||
|
return (width: width, height: height, isFlipped: isFlipped);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<(String, String)>> getPlaces(String userId) {
|
Future<List<(String, String)>> getPlaces(String userId) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/constants.dart';
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart';
|
||||||
@@ -19,13 +20,13 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
import 'package:immich_mobile/services/localization.service.dart';
|
import 'package:immich_mobile/services/localization.service.dart';
|
||||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:immich_mobile/utils/bootstrap.dart';
|
import 'package:immich_mobile/utils/bootstrap.dart';
|
||||||
import 'package:immich_mobile/utils/debug_print.dart';
|
import 'package:immich_mobile/utils/debug_print.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
import 'package:immich_mobile/utils/http_ssl_options.dart';
|
||||||
@@ -242,12 +243,13 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id);
|
return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? [];
|
||||||
return _ref
|
return _ref
|
||||||
?.read(foregroundUploadServiceProvider)
|
?.read(uploadServiceProvider)
|
||||||
.uploadCandidates(currentUser.id, _cancellationToken, useSequentialUpload: true);
|
.startBackupWithHttpClient(currentUser.id, networkCapabilities.isUnmetered, _cancellationToken);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
(error, stack) {
|
||||||
dPrint(() => "Error in backup zone $error, $stack");
|
dPrint(() => "Error in backup zone $error, $stack");
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ class HashService {
|
|||||||
_log.info("Starting hashing of assets");
|
_log.info("Starting hashing of assets");
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
// Migrate hashes from cloud ID to local ID so we don't have to re-hash them
|
|
||||||
await _migrateHashes();
|
|
||||||
|
|
||||||
// Sorted by backupSelection followed by isCloud
|
// Sorted by backupSelection followed by isCloud
|
||||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||||
|
|
||||||
@@ -78,15 +75,6 @@ class HashService {
|
|||||||
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
_log.info("Hashing took - ${stopwatch.elapsedMilliseconds}ms");
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateHashes() async {
|
|
||||||
final hashMappings = await _localAssetRepository.getHashMappingFromCloudId();
|
|
||||||
if (hashMappings.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _localAssetRepository.updateHashes(hashMappings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
@@ -19,7 +18,6 @@ import 'package:logging/logging.dart';
|
|||||||
|
|
||||||
class LocalSyncService {
|
class LocalSyncService {
|
||||||
final DriftLocalAlbumRepository _localAlbumRepository;
|
final DriftLocalAlbumRepository _localAlbumRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final LocalFilesManagerRepository _localFilesManager;
|
final LocalFilesManagerRepository _localFilesManager;
|
||||||
@@ -28,13 +26,11 @@ class LocalSyncService {
|
|||||||
|
|
||||||
LocalSyncService({
|
LocalSyncService({
|
||||||
required DriftLocalAlbumRepository localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required LocalFilesManagerRepository localFilesManager,
|
required LocalFilesManagerRepository localFilesManager,
|
||||||
required StorageRepository storageRepository,
|
required StorageRepository storageRepository,
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
|
||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_localFilesManager = localFilesManager,
|
_localFilesManager = localFilesManager,
|
||||||
_storageRepository = storageRepository,
|
_storageRepository = storageRepository,
|
||||||
@@ -51,12 +47,6 @@ class LocalSyncService {
|
|||||||
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
_log.warning("syncTrashedAssets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CurrentPlatform.isIOS) {
|
|
||||||
final assets = await _localAssetRepository.getEmptyCloudIdAssets();
|
|
||||||
await _mapIosCloudIds(assets);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (full || await _nativeSyncApi.shouldFullSync()) {
|
if (full || await _nativeSyncApi.shouldFullSync()) {
|
||||||
_log.fine("Full sync request from ${full ? "user" : "native"}");
|
_log.fine("Full sync request from ${full ? "user" : "native"}");
|
||||||
return await fullSync();
|
return await fullSync();
|
||||||
@@ -73,9 +63,8 @@ class LocalSyncService {
|
|||||||
|
|
||||||
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
final deviceAlbums = await _nativeSyncApi.getAlbums();
|
||||||
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
|
await _localAlbumRepository.updateAll(deviceAlbums.toLocalAlbums());
|
||||||
final newAssets = delta.updates.toLocalAssets();
|
|
||||||
await _localAlbumRepository.processDelta(
|
await _localAlbumRepository.processDelta(
|
||||||
updates: newAssets,
|
updates: delta.updates.toLocalAssets(),
|
||||||
deletes: delta.deletes,
|
deletes: delta.deletes,
|
||||||
assetAlbums: delta.assetAlbums,
|
assetAlbums: delta.assetAlbums,
|
||||||
);
|
);
|
||||||
@@ -103,8 +92,6 @@ class LocalSyncService {
|
|||||||
}
|
}
|
||||||
await updateAlbum(dbAlbum, album);
|
await updateAlbum(dbAlbum, album);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _mapIosCloudIds(newAssets);
|
|
||||||
}
|
}
|
||||||
await _nativeSyncApi.checkpointSync();
|
await _nativeSyncApi.checkpointSync();
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
@@ -143,12 +130,9 @@ class LocalSyncService {
|
|||||||
try {
|
try {
|
||||||
_log.fine("Adding device album ${album.name}");
|
_log.fine("Adding device album ${album.name}");
|
||||||
|
|
||||||
final assets = album.assetCount > 0
|
final assets = album.assetCount > 0 ? await _nativeSyncApi.getAssetsForAlbum(album.id) : <PlatformAsset>[];
|
||||||
? await _nativeSyncApi.getAssetsForAlbum(album.id).then((a) => a.toLocalAssets())
|
|
||||||
: <LocalAsset>[];
|
|
||||||
|
|
||||||
await _localAlbumRepository.upsert(album, toUpsert: assets);
|
await _localAlbumRepository.upsert(album, toUpsert: assets.toLocalAssets());
|
||||||
await _mapIosCloudIds(assets);
|
|
||||||
_log.fine("Successfully added device album ${album.name}");
|
_log.fine("Successfully added device album ${album.name}");
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
_log.warning("Error while adding device album", e, s);
|
_log.warning("Error while adding device album", e, s);
|
||||||
@@ -218,16 +202,13 @@ class LocalSyncService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final newAssets = await _nativeSyncApi
|
final newAssets = await _nativeSyncApi.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime);
|
||||||
.getAssetsForAlbum(deviceAlbum.id, updatedTimeCond: updatedTime)
|
|
||||||
.then((a) => a.toLocalAssets());
|
|
||||||
|
|
||||||
await _localAlbumRepository.upsert(
|
await _localAlbumRepository.upsert(
|
||||||
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
|
deviceAlbum.copyWith(backupSelection: dbAlbum.backupSelection),
|
||||||
toUpsert: newAssets,
|
toUpsert: newAssets.toLocalAssets(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _mapIosCloudIds(newAssets);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
|
_log.warning("Error on fast syncing local album: ${dbAlbum.name}", e, s);
|
||||||
@@ -259,7 +240,6 @@ class LocalSyncService {
|
|||||||
if (dbAlbum.assetCount == 0) {
|
if (dbAlbum.assetCount == 0) {
|
||||||
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
|
_log.fine("Device album ${deviceAlbum.name} is empty. Adding assets to DB.");
|
||||||
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
|
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsInDevice);
|
||||||
await _mapIosCloudIds(assetsInDevice);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +277,6 @@ class LocalSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
|
await _localAlbumRepository.upsert(updatedDeviceAlbum, toUpsert: assetsToUpsert, toDelete: assetsToDelete);
|
||||||
await _mapIosCloudIds(assetsToUpsert);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
@@ -306,29 +285,6 @@ class LocalSyncService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _mapIosCloudIds(List<LocalAsset> assets) async {
|
|
||||||
if (!CurrentPlatform.isIOS || assets.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final assetIds = assets.map((a) => a.id).toList();
|
|
||||||
final cloudMapping = <String, String>{};
|
|
||||||
final cloudIds = await _nativeSyncApi.getCloudIdForAssetIds(assetIds);
|
|
||||||
for (int i = 0; i < cloudIds.length; i++) {
|
|
||||||
final cloudIdResult = cloudIds[i];
|
|
||||||
if (cloudIdResult.cloudId != null) {
|
|
||||||
cloudMapping[cloudIdResult.assetId] = cloudIdResult.cloudId!;
|
|
||||||
} else {
|
|
||||||
final asset = assets.firstWhereOrNull((a) => a.id == cloudIdResult.assetId);
|
|
||||||
_log.fine(
|
|
||||||
"Cannot fetch cloudId for asset with id: ${cloudIdResult.assetId}, name: ${asset?.name}, createdAt: ${asset?.createdAt}. Error: ${cloudIdResult.error ?? "unknown"}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _localAlbumRepository.updateCloudMapping(cloudMapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _assetsEqual(LocalAsset a, LocalAsset b) {
|
bool _assetsEqual(LocalAsset a, LocalAsset b) {
|
||||||
if (CurrentPlatform.isAndroid) {
|
if (CurrentPlatform.isAndroid) {
|
||||||
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
|
return a.updatedAt.isAtSameMomentAs(b.updatedAt) &&
|
||||||
@@ -404,7 +360,6 @@ extension on Iterable<PlatformAlbum> {
|
|||||||
name: e.name,
|
name: e.name,
|
||||||
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
updatedAt: tryFromSecondsSinceEpoch(e.updatedAt, isUtc: true) ?? DateTime.timestamp(),
|
||||||
assetCount: e.assetCount,
|
assetCount: e.assetCount,
|
||||||
isIosSharedAlbum: e.isCloud,
|
|
||||||
),
|
),
|
||||||
).toList();
|
).toList();
|
||||||
}
|
}
|
||||||
@@ -436,6 +391,5 @@ extension PlatformToLocalAsset on PlatformAsset {
|
|||||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
isEdited: false,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:immich_mobile/domain/models/map.model.dart';
|
import 'package:immich_mobile/domain/models/map.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
|
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
|
||||||
@@ -12,8 +11,7 @@ class MapFactory {
|
|||||||
|
|
||||||
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
|
||||||
|
|
||||||
MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
|
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId));
|
||||||
MapService(_mapRepository.remote(ownerIds, options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class MapService {
|
class MapService {
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ extension on AssetResponseDto {
|
|||||||
thumbHash: thumbhash,
|
thumbHash: thumbhash,
|
||||||
localId: null,
|
localId: null,
|
||||||
type: type.toAssetType(),
|
type: type.toAssetType(),
|
||||||
isEdited: isEdited,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,10 +118,6 @@ class SyncStreamService {
|
|||||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||||
case SyncEntityType.assetExifV1:
|
case SyncEntityType.assetExifV1:
|
||||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||||
case SyncEntityType.assetMetadataV1:
|
|
||||||
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
|
|
||||||
case SyncEntityType.assetMetadataDeleteV1:
|
|
||||||
return _syncStreamRepository.deleteAssetsMetadataV1(data.cast());
|
|
||||||
case SyncEntityType.partnerAssetV1:
|
case SyncEntityType.partnerAssetV1:
|
||||||
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
|
return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner');
|
||||||
case SyncEntityType.partnerAssetBackfillV1:
|
case SyncEntityType.partnerAssetBackfillV1:
|
||||||
@@ -247,42 +243,6 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleWsAssetEditReadyV1Batch(List<dynamic> batchData) async {
|
|
||||||
if (batchData.isEmpty) return;
|
|
||||||
|
|
||||||
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
|
|
||||||
|
|
||||||
final List<SyncAssetV1> assets = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (final data in batchData) {
|
|
||||||
if (data is! Map<String, dynamic>) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final payload = data;
|
|
||||||
final assetData = payload['asset'];
|
|
||||||
|
|
||||||
if (assetData == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final asset = SyncAssetV1.fromJson(assetData);
|
|
||||||
|
|
||||||
if (asset != null) {
|
|
||||||
assets.add(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assets.isNotEmpty) {
|
|
||||||
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
|
|
||||||
_logger.info('Successfully processed ${assets.length} edited assets');
|
|
||||||
}
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
_logger.severe("Error processing AssetEditReadyV1 websocket batch events", error, stackTrace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
|
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
|
||||||
if (checksums.isEmpty) {
|
if (checksums.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user