Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Beckford 1cc0ca9935 fix(mobile): fallback to remote thumbnail when local fails 2026-05-08 13:19:45 -07:00
41 changed files with 417 additions and 547 deletions
View File
+5 -28
View File
@@ -51,7 +51,6 @@ jobs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: token
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
@@ -61,7 +60,7 @@ jobs:
id: check
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
with:
github-token: ${{ steps.token.outputs.token || github.token }}
github-token: ${{ steps.token.outputs.token }}
filters: |
mobile:
- 'mobile/**'
@@ -74,13 +73,12 @@ jobs:
needs: pre-job
permissions:
contents: read
pull-requests: write
if: ${{ github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: mich
steps:
- id: token
if: ${{ !github.event.pull_request.head.repo.fork }}
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
@@ -90,10 +88,9 @@ jobs:
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
token: ${{ steps.token.outputs.token || github.token }}
token: ${{ steps.token.outputs.token }}
- name: Create the Keystore
if: ${{ !github.event.pull_request.head.repo.fork }}
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
@@ -147,40 +144,20 @@ jobs:
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
flutter build apk --release
flutter build apk --debug --split-per-abi --target-platform android-arm64
fi
- name: Publish Android Artifact
id: upload-apk
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/*.apk
- name: Comment APK download link on PR
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }}
uses: mshick/add-pr-comment@8e4927817251f1ff60c001f04568532b38e0b4a0 # v3.11.0
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
APK_URL: ${{ steps.upload-apk.outputs.artifact-url }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'mobile-android-apk'
message: |
📱 **Android release APK (universal)** — `${{ env.HEAD_SHA }}`
Download: ${{ env.APK_URL }}
<img src="https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${{ env.APK_URL }}" alt="QR code" />
GitHub login required. Downloads as a zip containing a single `app-release.apk` — extract and install. Installs as a separate app (applicationId `app.alextran.immich.pr${{ github.event.pull_request.number }}`), so it coexists with the Play Store version and any other PR builds.
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+18 -8
View File
@@ -36,20 +36,30 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Publish
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm publish --provenance --no-git-checks
if: ${{ github.event_name == 'release' }}
run: mise run ci-publish
docker:
name: Docker
+9 -3
View File
@@ -64,11 +64,17 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
fetch-depth: 0
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
node-version-file: './docs/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run install
run: pnpm install
+13 -7
View File
@@ -14,23 +14,29 @@ jobs:
contents: write
pull-requests: write
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
- name: 'Checkout'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Fix formatting
run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix
+15 -10
View File
@@ -48,28 +48,33 @@ jobs:
version: ${{ steps.output.outputs.version }}
permissions: {} # No job-level permissions are needed because it uses the app-token
steps:
- id: token
uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.token.outputs.token }}
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with:
github_token: ${{ steps.token.outputs.token }}
# TODO move to mise
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Setup pnpm
uses: pnpm/action-setup@26f6d4f2c533a43e6b5da0b4a5dd983f98f7b49a # v6.0.4
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Bump version
env:
SERVER_BUMP: ${{ inputs.serverBump }}
+10 -8
View File
@@ -24,22 +24,24 @@ jobs:
client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Publish
run: pnpm publish --provenance --no-git-checks
+160 -127
View File
@@ -78,14 +78,28 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run ci-unit
run: mise run ci-unit
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run package manager install
run: pnpm install
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run small tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests:
name: Unit Test CLI
needs: pre-job
@@ -108,15 +122,31 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run ci-unit
run: mise run ci-unit
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk
run: pnpm install && pnpm run build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
cli-unit-tests-win:
name: Unit Test CLI (Windows)
needs: pre-job
@@ -139,33 +169,26 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Install deps
run: pnpm install --frozen-lockfile
# Skip linter & formatter in Windows test.
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
web-lint:
name: Lint Web
needs: pre-job
@@ -188,22 +211,28 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: mise run //:sdk:install && mise run //:sdk:build
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Run pnpm install
run: pnpm install --frozen-lockfile
run: pnpm rebuild && pnpm install --frozen-lockfile
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run svelte checks
run: pnpm check:svelte
if: ${{ !cancelled() }}
web-unit-tests:
name: Test Web
needs: pre-job
@@ -226,15 +255,25 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run ci-unit
run: mise run ci-unit
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
- name: Run npm install
run: pnpm install --frozen-lockfile
- name: Run tsc
run: pnpm check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: pnpm test
if: ${{ !cancelled() }}
i18n-tests:
name: Test i18n
needs: pre-job
@@ -254,25 +293,24 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
node-version-file: './web/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install dependencies
run: pnpm --filter=immich-i18n install --frozen-lockfile
- name: Format
run: pnpm --filter=immich-i18n format:fix
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
i18n/**
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
@@ -281,7 +319,6 @@ jobs:
echo "ERROR: i18n files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
exit 1
e2e-tests-lint:
name: End-to-End Lint
needs: pre-job
@@ -304,16 +341,30 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run ci-unit
run: mise run ci-unit
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Run linter
run: pnpm lint
if: ${{ !cancelled() }}
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
server-medium-tests:
name: Medium Tests (Server)
needs: pre-job
@@ -337,16 +388,23 @@ jobs:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with:
github_token: ${{ steps.token.outputs.token }}
- name: Run ci-medium
run: mise run ci-medium
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }}
e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI)
needs: pre-job
@@ -373,63 +431,52 @@ jobs:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Setup typescript-sdk
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Run setup web
run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync
working-directory: ./web
if: ${{ !cancelled() }}
- name: Run setup cli
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./cli
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Start Docker Compose
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run e2e tests (api & cli)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test
if: ${{ !cancelled() }}
- name: Run e2e tests (maintenance)
env:
VITEST_DISABLE_DOCKER_SETUP: true
run: pnpm test:maintenance
if: ${{ !cancelled() }}
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-server-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
e2e-tests-web:
name: End-to-End Tests (Web)
needs: pre-job
@@ -456,85 +503,70 @@ jobs:
persist-credentials: false
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run setup typescript-sdk
run: pnpm install --frozen-lockfile && pnpm build
working-directory: ./open-api/typescript-sdk
if: ${{ !cancelled() }}
- name: Install dependencies
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --only-shell
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web
if: ${{ !cancelled() }}
- name: Archive e2e test (web) results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run ui tests (web)
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:ui
if: ${{ !cancelled() }}
- name: Archive ui test (web) results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure()
with:
name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run maintenance tests
env:
PLAYWRIGHT_DISABLE_WEBSERVER: true
run: pnpm test:web:maintenance
if: ${{ !cancelled() }}
- name: Archive maintenance tests (web) results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: success() || failure()
with:
name: e2e-maintenance-isolated-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Capture Docker logs
if: always()
run: docker compose logs --no-color > docker-compose-logs.txt
working-directory: ./e2e
- name: Archive Docker logs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-web-docker-logs-${{ matrix.runner }}
path: e2e/docker-compose-logs.txt
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
@@ -581,6 +613,9 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
env:
MISE_CEILING_PATHS: ${{ github.workspace }}
MISE_TRUSTED_CONFIG_PATHS: ${{ github.workspace }}/machine-learning/mise.toml
defaults:
run:
working-directory: ./machine-learning
@@ -600,10 +635,18 @@ jobs:
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
with:
github_token: ${{ steps.token.outputs.token }}
working_directory: ./machine-learning
- name: Run ci-unit
run: mise run ci-unit
- name: Install dependencies
run: mise run install --extra cpu
- name: Lint code
run: mise run lint --output-format=github
- name: Format code
run: mise run format
- name: Run type checking
run: mise run check
- name: Run tests and coverage
run: mise run test
github-files-formatting:
name: .github Files Formatting
needs: pre-job
@@ -626,19 +669,19 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: pnpm install --frozen-lockfile
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
@@ -677,22 +720,21 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Build the app
run: pnpm --filter immich build
- name: Run API generation
run: ./bin/generate-open-api.sh
working-directory: open-api
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
@@ -701,7 +743,6 @@ jobs:
mobile/openapi
open-api/typescript-sdk
open-api/immich-openapi-specs.json
- name: Verify files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
@@ -710,7 +751,6 @@ jobs:
echo "ERROR: Generated files not up to date!"
echo "Changed files: ${CHANGED_FILES}"
exit 1
sql-schema-up-to-date:
name: SQL Schema Checks
runs-on: ubuntu-latest
@@ -742,35 +782,31 @@ jobs:
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@01a4d354b70f99a6baf4a1b72827f6d4922e4978 # use-mise-action-v2.0.0
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
github_token: ${{ steps.token.outputs.token }}
node-version-file: './server/.nvmrc'
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build the app
run: pnpm build
- name: Run existing migrations
run: pnpm migrations:run
- name: Test npm run schema:reset command works
run: pnpm schema:reset
- name: Generate new migrations
continue-on-error: true
run: pnpm migrations:generate src/TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-files
with:
files: |
server/src
- name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
env:
@@ -780,19 +816,16 @@ jobs:
echo "Changed files: ${CHANGED_FILES}"
cat ./src/*-TestMigration.ts
exit 1
- name: Run SQL generation
run: pnpm sync:sql
env:
DB_URL: postgres://postgres:postgres@localhost:5432/immich
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
id: verify-changed-sql-files
with:
files: |
server/src/queries
- name: Verify SQL files have not changed
if: steps.verify-changed-sql-files.outputs.files_changed == 'true'
env:
+1
View File
@@ -0,0 +1 @@
24.15.0
+1 -24
View File
@@ -7,7 +7,7 @@ run = "vite build"
[tasks.test]
env._.path = "./node_modules/.bin"
run = "vitest"
run = "vite"
[tasks.lint]
env._.path = "./node_modules/.bin"
@@ -27,26 +27,3 @@ run = "prettier --write ."
[tasks.check]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"
[tasks.ci-publish]
depends = ["//:sdk:install", "//:sdk:build"]
run = [
{ task = ":install" },
{ task = ":build" },
"pnpm publish --provenance --no-git-checks",
]
[tasks.ci-unit]
depends = ["//:sdk:install", "//:sdk:build"]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
{ task = ":test --run" },
]
[tasks.checklist]
run = [
{ task = ":ci-unit" },
]
+3
View File
@@ -66,5 +66,8 @@
"fastq": "^1.17.1",
"lodash-es": "^4.17.21",
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.15.0"
}
}
+1
View File
@@ -0,0 +1 @@
24.15.0
+1 -1
View File
@@ -264,4 +264,4 @@ volumes:
- ./configuration.yml:${IMMICH_CONFIG_FILE}
```
:::
::
+3
View File
@@ -56,5 +56,8 @@
},
"engines": {
"node": ">=20"
},
"volta": {
"node": "24.15.0"
}
}
+1
View File
@@ -0,0 +1 @@
24.15.0
-15
View File
@@ -27,18 +27,3 @@ run = { task = "lint --fix" }
[tasks.check]
env._.path = "./node_modules/.bin"
run = "tsc --noEmit"
[tasks.ci-setup]
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
run = { task = ":install" }
[tasks.ci-unit]
depends = ["//:sdk:install", "//:sdk:build"]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
]
+3
View File
@@ -56,5 +56,8 @@
"utimes": "^5.2.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.0"
},
"volta": {
"node": "24.15.0"
}
}
+1 -9
View File
@@ -17,15 +17,6 @@ run = "uv run ruff format immich_ml"
[tasks.check]
run = "uv run mypy --strict immich_ml/"
[tasks.ci-unit]
run = [
{ task = ":install --extra cpu" },
{ task = ":format" },
{ task = ":lint --output-format=github" },
{ task = ":check" },
{ task = ":test" },
]
[tasks.checklist]
run = [
{ task = ":install" },
@@ -34,3 +25,4 @@ run = [
{ task = ":check" },
{ task = ":test" },
]
+1 -8
View File
@@ -64,15 +64,8 @@ android {
}
release {
def hasKeystore = file("../key.jks").exists() && file("../key.jks").length() > 0
signingConfig hasKeystore ? signingConfigs.release : signingConfigs.debug
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
def prNumber = System.getenv("PR_NUMBER")
if (prNumber) {
applicationIdSuffix ".pr${prNumber}"
versionNameSuffix "-pr${prNumber}"
}
}
}
namespace 'app.alextran.immich'
@@ -56,8 +56,6 @@ class TimelineFactory {
TimelineService localAlbum({required String albumId}) =>
TimelineService(_timelineRepository.localAlbum(albumId, groupBy));
TimelineService allLocal() => TimelineService(_timelineRepository.allLocal(groupBy));
TimelineService remoteAlbum({required String albumId}) =>
TimelineService(_timelineRepository.remoteAlbum(albumId, groupBy));
@@ -118,40 +118,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: TimelineOrigin.localAlbum,
);
TimelineQuery allLocal(GroupAssetsBy groupBy) => (
bucketSource: () => _watchAllLocalBucket(groupBy: groupBy),
assetSource: (offset, count) => _getAllLocalAssets(offset: offset, count: count),
origin: TimelineOrigin.localAlbum,
);
Stream<List<Bucket>> _watchAllLocalBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
return _db.localAssetEntity.count().map(_generateBuckets).watchSingle();
}
final assetCountExp = _db.localAssetEntity.id.count();
final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy, toLocal: true);
final query = _db.localAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}
Future<List<BaseAsset>> _getAllLocalAssets({required int offset, required int count}) {
final query = _db.localAssetEntity.select()
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
}
Stream<List<Bucket>> _watchLocalAlbumBucket(String albumId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
return _db.localAlbumAssetEntity
@@ -278,21 +244,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final isAscending = albumData.order == AlbumAssetOrder.asc;
// Correlated subquery picks the first matching local asset by checksum,
// avoiding fan-out when the same photo exists in multiple device albums (#23273).
final localId = subqueryExpression<String>(
_db.localAssetEntity.selectOnly()
..addColumns([_db.localAssetEntity.id])
..where(_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum))
..limit(1),
);
final query = _db.remoteAssetEntity.select().addColumns([localId]).join([
final query = _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([
innerJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId));
if (isAscending) {
@@ -303,7 +265,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.limit(count, offset: offset);
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(localId))).get();
return query
.map((row) => row.readTable(_db.remoteAssetEntity).toDto(localId: row.read(_db.localAssetEntity.id)))
.get();
}
TimelineQuery fromAssets(List<BaseAsset> assets, TimelineOrigin origin) => (
@@ -458,22 +422,23 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
}
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
final idQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
..where(
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id.count()])
..join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.id.isInQuery(idQuery) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline),
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
return query.map((row) {
@@ -487,11 +452,20 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.id.isInQuery(idQuery) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.deletedAt.isNull(),
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
)
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
@@ -509,26 +483,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required int offset,
required int count,
}) {
final idQuery = _db.assetFaceEntity.selectOnly()
..addColumns([_db.assetFaceEntity.assetId])
..where(
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
);
final query =
_db.remoteAssetEntity.select().join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId) &
_db.assetFaceEntity.isVisible.equals(true) &
_db.assetFaceEntity.deletedAt.isNull(),
)
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset);
final query = _db.remoteAssetEntity.select()
..where(
(row) =>
row.id.isInQuery(idQuery) &
row.deletedAt.isNull() &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
@@ -355,9 +355,9 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
),
);
} else {
log.info('No stored credentials - showing local gallery in guest mode');
log.severe('Missing crucial offline login info - Logging out completely');
unawaited(ref.read(authProvider.notifier).logout());
unawaited(context.replaceRoute(const GuestGalleryRoute()));
unawaited(context.replaceRoute(const LoginRoute()));
return;
}
@@ -1,32 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class GuestGalleryPage extends StatelessWidget {
const GuestGalleryPage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [timelineServiceProvider.overrideWith((ref) => ref.watch(localOnlyTimelineServiceProvider))],
child: Timeline(
appBar: SliverAppBar(
title: const Text('On this device'),
floating: true,
actions: [
TextButton.icon(
onPressed: () => context.replaceRoute(const LoginRoute()),
icon: const Icon(Icons.login),
label: const Text('Sign in'),
),
],
),
showStorageIndicator: false,
),
);
}
}
@@ -65,37 +65,26 @@ class ViewerBottomBar extends ConsumerWidget {
labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white),
),
),
child: Stack(
children: [
const Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
),
),
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
],
),
],
),
),
),
);
@@ -75,41 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
child: AnimatedOpacity(
opacity: opacity,
duration: Durations.short2,
child: Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: showingDetails
? null
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: showingDetails
? null
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black45, Colors.black12, Colors.transparent],
stops: [0.0, 0.7, 1.0],
),
),
),
),
SafeArea(
bottom: false,
child: SizedBox.square(
child: Theme(
data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)),
child: Row(
children: [
const _AppBarBackButton(),
const Spacer(),
if (!showingDetails && !isReadonlyModeEnabled)
if (isInLockedView) ...lockedViewActions else ...actions,
],
),
),
),
),
],
),
child: AppBar(
backgroundColor: Colors.transparent,
leading: const _AppBarBackButton(),
iconTheme: const IconThemeData(size: 22, color: Colors.white),
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
shape: const Border(),
actions: showingDetails || isReadonlyModeEnabled
? null
: isInLockedView
? lockedViewActions
: actions,
),
),
),
);
@@ -125,17 +113,20 @@ class _AppBarBackButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
shape: const CircleBorder(),
iconSize: 22,
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
padding: const EdgeInsets.all(10.0),
elevation: showingDetails ? 4 : 0,
return Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
shape: const CircleBorder(),
iconSize: 22,
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
padding: EdgeInsets.zero,
elevation: showingDetails ? 4 : 0,
),
onPressed: context.maybePop,
child: const Icon(Icons.arrow_back_rounded),
),
onPressed: context.maybePop,
child: const Icon(Icons.arrow_back_rounded),
);
}
}
@@ -177,8 +177,13 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
ImageProvider? getThumbnailImageProvider(
BaseAsset asset, {
Size size = kThumbnailResolution,
bool edited = true,
bool localFailed = false,
}) {
if (_shouldUseLocalAsset(asset) && !localFailed) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
@@ -18,24 +19,34 @@ class Thumbnail extends StatefulWidget {
final ImageProvider? imageProvider;
final ImageProvider? thumbhashProvider;
final BoxFit fit;
final Size size;
final BaseAsset? asset;
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
const Thumbnail({
this.imageProvider,
this.fit = BoxFit.cover,
this.thumbhashProvider,
this.size = kThumbnailResolution,
this.asset,
super.key,
});
Thumbnail.remote({
required String remoteId,
required String thumbhash,
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
this.size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
thumbhashProvider = null,
asset = null;
Thumbnail.fromAsset({
required BaseAsset? asset,
required this.asset,
this.fit = BoxFit.cover,
/// The logical UI size of the thumbnail. This is only used to determine the ideal image resolution and does not affect the widget size.
Size size = kThumbnailResolution,
this.size = kThumbnailResolution,
super.key,
}) : thumbhashProvider = switch (asset) {
RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider(
@@ -105,9 +116,8 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
thumbhashStream.addListener(thumbhashStreamListener);
}
void _loadFromImageProvider() {
void _loadFromImageProvider(ImageProvider? imageProvider) {
_stopListeningToImageStream();
final imageProvider = widget.imageProvider;
if (imageProvider == null) return;
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
@@ -142,8 +152,18 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
});
},
onError: (exception, stackTrace) {
log.severe('Error loading image: $exception', exception, stackTrace);
_stopListeningToImageStream();
if (imageProvider is LocalThumbProvider && widget.asset != null) {
ImageProvider? nextProvider = getThumbnailImageProvider(widget.asset!, size: widget.size, localFailed: true);
if (nextProvider != null && nextProvider != imageProvider) {
log.fine('Error loading image, retrying with other provider: $exception', exception, stackTrace);
_loadFromImageProvider(nextProvider);
return;
}
}
log.severe('Error loading image: $exception', exception, stackTrace);
},
);
imageStream.addListener(imageStreamListener);
@@ -180,7 +200,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
_previousImage?.dispose();
_previousImage = null;
}
_loadFromImageProvider();
_loadFromImageProvider(widget.imageProvider);
}
if (_providerImage == null && oldWidget.thumbhashProvider != widget.thumbhashProvider) {
@@ -195,7 +215,7 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
}
void _loadImage() {
_loadFromImageProvider();
_loadFromImageProvider(widget.imageProvider);
_loadFromThumbhashProvider();
}
@@ -26,12 +26,6 @@ final timelineServiceProvider = Provider<TimelineService>(
dependencies: [],
);
final localOnlyTimelineServiceProvider = Provider<TimelineService>((ref) {
final timelineService = ref.watch(timelineFactoryProvider).allLocal();
ref.onDispose(timelineService.dispose);
return timelineService;
});
final timelineFactoryProvider = Provider<TimelineFactory>(
(ref) => TimelineFactory(
timelineRepository: ref.watch(timelineRepositoryProvider),
-2
View File
@@ -63,7 +63,6 @@ import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/edit/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/guest_gallery.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.page.dart';
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
@@ -115,7 +114,6 @@ class AppRouter extends RootStackRouter {
@override
late final List<AutoRoute> routes = [
AutoRoute(page: SplashScreenRoute.page, initial: true),
AutoRoute(page: GuestGalleryRoute.page),
AutoRoute(page: LoginRoute.page),
AutoRoute(page: ChangePasswordRoute.page),
AutoRoute(
-16
View File
@@ -1224,22 +1224,6 @@ class FolderRouteArgs {
int get hashCode => key.hashCode ^ folder.hashCode;
}
/// generated route for
/// [GuestGalleryPage]
class GuestGalleryRoute extends PageRouteInfo<void> {
const GuestGalleryRoute({List<PageRouteInfo>? children})
: super(GuestGalleryRoute.name, initialChildren: children);
static const String name = 'GuestGalleryRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const GuestGalleryPage();
},
);
}
/// generated route for
/// [HeaderSettingsPage]
class HeaderSettingsRoute extends PageRouteInfo<void> {
@@ -119,15 +119,13 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
onPressed: () => _toggle(isCasting),
),
const Spacer(),
IgnorePointer(
child: Text(
"${position.format()} / ${duration.format()}",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
shadows: VideoControls._controlShadows,
),
Text(
"${position.format()} / ${duration.format()}",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
shadows: VideoControls._controlShadows,
),
),
const SizedBox(width: 12),
@@ -74,7 +74,6 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
if (!mounted) return;
setState(() {
isAlbumSyncInProgress = false;
});
@@ -1,41 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftTimelineRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = DriftTimelineRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('remoteAlbum assets', () {
test('no duplicate assets when identical checksum appears in multiple local asset rows', () async {
// Regression check for #23273: a LEFT OUTER JOIN on checksum would fan out and create duplicates
// happens when same photo exists in multiple albums on device
final user = await ctx.newUser();
final checksum = 'yolo';
final album = await ctx.newRemoteAlbum(ownerId: user.id);
final remoteAsset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum);
await ctx.insertRemoteAlbumAsset(albumId: album.id, assetId: remoteAsset.id);
final localAsset1 = await ctx.newLocalAsset(checksum: checksum);
final localAsset2 = await ctx.newLocalAsset(checksum: checksum);
final assets = await sut.remoteAlbum(album.id, GroupAssetsBy.day).assetSource(0, 10);
expect(assets, hasLength(1));
expect((assets.first as RemoteAsset).id, remoteAsset.id);
expect([localAsset1.id, localAsset2.id], contains((assets.first as RemoteAsset).localId));
});
});
}
+1
View File
@@ -0,0 +1 @@
24.15.0
+8 -5
View File
@@ -2,11 +2,6 @@
"name": "@immich/sdk",
"version": "2.7.5",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
"url": "git+https://github.com/immich-app/immich.git",
"directory": "open-api/typescript-sdk"
},
"type": "module",
"main": "./build/index.js",
"types": "./build/index.d.ts",
@@ -26,5 +21,13 @@
"devDependencies": {
"@types/node": "^24.12.2",
"typescript": "^6.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/immich-app/immich.git",
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.15.0"
}
}
+1
View File
@@ -0,0 +1 @@
24.15.0
+2 -13
View File
@@ -55,23 +55,12 @@ run = [
env._.path = "./node_modules/.bin"
run = "email dev -p 3050 --dir src/emails"
[tasks.ci-unit]
[tasks.checklist]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":lint" },
{ task = ":check" },
{ task = ":test-medium --run" },
{ task = ":test --run" },
]
[tasks.ci-medium]
run = [
{ task = ":install" },
{ task = ":test-medium --run" },
]
[tasks.checklist]
run = [
{ task = ":ci-unit" },
{ task = ":ci-medium" },
]
+3
View File
@@ -167,6 +167,9 @@
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^3.0.0"
},
"volta": {
"node": "24.15.0"
},
"overrides": {
"sharp": "^0.34.5"
}
+1
View File
@@ -0,0 +1 @@
24.15.0
+1 -7
View File
@@ -42,17 +42,11 @@ run = "pnpm run check:svelte"
[tasks.check]
run = { tasks = [":check-typescript", ":check-svelte"] }
[tasks.ci-unit]
depends = ["//:sdk:install", "//:sdk:build"]
[tasks.checklist]
run = [
{ task = ":install" },
{ task = ":format" },
{ task = ":check" },
{ task = ":test --run" },
]
[tasks.checklist]
run = [
{ task = ":ci-unit" },
{ task = ":lint" },
]
+4 -1
View File
@@ -14,7 +14,7 @@
"check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
"check:all": "pnpm run check:code && pnpm run test:cov",
"lint": "eslint . --max-warnings 0 --concurrency 6",
"lint": "eslint . --max-warnings 0 --concurrency 4",
"lint:fix": "pnpm run lint --fix",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
@@ -111,5 +111,8 @@
"typescript-eslint": "^8.45.0",
"vite": "^8.0.0",
"vitest": "^4.0.0"
},
"volta": {
"node": "24.15.0"
}
}