Compare commits

...

12 Commits

Author SHA1 Message Date
Jason Rasmussen 47d0abb456 feat: mise server checks 2025-09-05 09:48:33 -04:00
renovate[bot] 5fb858a865 chore(deps): update node.js to v22.19.0 (#21509)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 08:43:58 -04:00
Jason Rasmussen 18084a49ec chore: mise tasks (#21597) 2025-09-04 18:10:36 -04:00
github-actions f107cb044a chore: version v1.141.0 2025-09-04 19:42:02 +00:00
renovate[bot] f4e7ea47a6 chore(deps): update dependency @types/node to ^22.18.0 (#21506)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 21:26:06 +02:00
Riccardo Ruspoli 8747fc4935 fix(server): consider asset creation date when EXIF is missing (#21586)
* fix(server): fallback to asset.fileCreatedAt when EXIF is missing

* merge main

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-09-04 19:18:12 +00:00
Alex 287fa79d75 fix: remove unnecessary call to create remote album (#21599) 2025-09-04 14:11:51 -05:00
Alex bcfb5bee1f feat: album info sync (#21103)
* wip

* album creation

* fix: album api repository no invalidating after logging out

* add linkedRemoteAlbumId column and migration

* link/unlink remote album

* logic to find and add new assets to album

* pr feedback

* add toggle option to backup option page

* refactor: provider > service

* rename

* Handle page pop manually

* UI feedback for user creation and sync linked album

* uncomment migration

* remove unused method
2025-09-04 18:44:10 +00:00
Yaros 538263dc38 fix(mobile): location button map beta timeline (#21590)
fix(mobile): location button map
2025-09-04 13:26:16 -05:00
Maksim 51aec1e93d fix(mobile): Correction of image creation date by using mtime instead… (#21508)
* fix(mobile): Correction of image creation date by using mtime instead of ctime.

* use the timestamps from the asset for uploads

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-04 18:26:02 +00:00
Mert 53825cc3d6 chore(deps): bump sharp to 0.34.3 (#21596)
* bump sharp to 0.34.3

* set unlimited
2025-09-04 13:23:13 -05:00
Jason Rasmussen 6e7c2817a3 fix: asset upload metadata validation (#21594) 2025-09-04 13:22:01 -05:00
59 changed files with 9109 additions and 1503 deletions
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.19.0
+12 -33
View File
@@ -72,27 +72,12 @@ jobs:
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm # - name: Setup Mise
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 # uses: immich-app/devtools/actions/use-mise@697a75e2c3186d3c037c2c159855cf2d566542ba # use-mise-action-0.0.1
- name: Setup Node - name: Setup mise
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
with: - name: Run small checks
node-version-file: './server/.nvmrc' run: mise run server:checks-small
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() }} if: ${{ !cancelled() }}
cli-unit-tests: cli-unit-tests:
name: Unit Test CLI name: Unit Test CLI
@@ -332,18 +317,12 @@ jobs:
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with: with:
persist-credentials: false persist-credentials: false
- name: Setup pnpm # - name: Setup Mise
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 # uses: immich-app/devtools/actions/use-mise@697a75e2c3186d3c037c2c159855cf2d566542ba # use-mise-action-0.0.1
- name: Setup Node - name: Setup mise
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3.2.0
with: - name: Run medium checks
node-version-file: './server/.nvmrc' run: mise run server:checks-medium
cache: 'pnpm'
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
e2e-tests-server-cli: e2e-tests-server-cli:
name: End-to-End Tests (Server & CLI) name: End-to-End Tests (Server & CLI)
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.19.0
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.86", "version": "2.2.87",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -21,7 +21,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": "^22.17.1", "@types/node": "^22.18.0",
"@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": "22.18.0" "node": "22.19.0"
} }
} }
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.19.0
+1 -1
View File
@@ -60,6 +60,6 @@
"node": ">=20" "node": ">=20"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.19.0"
} }
} }
+4
View File
@@ -1,4 +1,8 @@
[ [
{
"label": "v1.141.0",
"url": "https://v1.141.0.archive.immich.app"
},
{ {
"label": "v1.140.1", "label": "v1.140.1",
"url": "https://v1.140.1.archive.immich.app" "url": "https://v1.140.1.archive.immich.app"
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.19.0
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.140.1", "version": "1.141.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -26,7 +26,7 @@
"@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": "^22.17.1", "@types/node": "^22.18.0",
"@types/oidc-provider": "^9.0.0", "@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",
@@ -45,7 +45,7 @@
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.0", "sharp": "^0.34.3",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
@@ -54,6 +54,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.19.0"
} }
} }
+2
View File
@@ -1417,6 +1417,8 @@
"open_the_search_filters": "Open the search filters", "open_the_search_filters": "Open the search filters",
"options": "Options", "options": "Options",
"or": "or", "or": "or",
"organize_into_albums": "Organize into albums",
"organize_into_albums_description": "Put existing photos into albums using current sync settings",
"organize_your_library": "Organize your library", "organize_your_library": "Organize your library",
"original": "original", "original": "original",
"other": "Other", "other": "Other",
+4 -4
View File
@@ -16,13 +16,13 @@ size = 8821083
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip" url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip"
[tools.node] [tools.node]
version = "22.18.0" version = "22.19.0"
backend = "core:node" backend = "core:node"
[tools.node.platforms.linux-x64] [tools.node.platforms.linux-x64]
checksum = "sha256:a2e703725d8683be86bb5da967bf8272f4518bdaf10f21389e2b2c9eaeae8c8a" checksum = "sha256:d36e56998220085782c0ca965f9d51b7726335aed2f5fc7321c6c0ad233aa96d"
size = 54824343 size = 54907188
url = "https://nodejs.org/dist/v22.18.0/node-v22.18.0-linux-x64.tar.gz" url = "https://nodejs.org/dist/v22.19.0/node-v22.19.0-linux-x64.tar.gz"
[tools.pnpm] [tools.pnpm]
version = "10.14.0" version = "10.14.0"
+309 -1
View File
@@ -1,5 +1,5 @@
[tools] [tools]
node = "22.18.0" node = "22.19.0"
flutter = "3.32.8" flutter = "3.32.8"
pnpm = "10.14.0" pnpm = "10.14.0"
dart = "3.8.2" dart = "3.8.2"
@@ -13,3 +13,311 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
experimental = true experimental = true
lockfile = true lockfile = true
pin = true pin = true
# .github
[tasks."github:install"]
run = "pnpm install --filter github --frozen-lockfile"
[tasks."github:format"]
env.PATH = "{{config_root}}/.github/node_modules/.bin:{{env.PATH}}"
dir = ".github"
run = "prettier --check ."
[tasks."github:format-fix"]
env.PATH = "{{config_root}}/.github/node_modules/.bin:{{env.PATH}}"
dir = ".github"
run = "prettier --write ."
# @immich/cli
[tasks."cli:install"]
run = "pnpm install --filter @immich/cli --frozen-lockfile"
[tasks."cli:build"]
env.PATH = "{{config_root}}/cli/node_modules/.bin:{{env.PATH}}"
dir = "cli"
run = "vite build"
[tasks."cli:test"]
env.PATH = "{{config_root}}/cli/node_modules/.bin:{{env.PATH}}"
dir = "cli"
run = "vite"
[tasks."cli:lint"]
env.PATH = "{{config_root}}/cli/node_modules/.bin:{{env.PATH}}"
dir = "cli"
run = "eslint \"src/**/*.ts\" --max-warnings 0"
[tasks."cli:lint-fix"]
run = "mise run cli:lint --fix"
[tasks."cli:format"]
env.PATH = "{{config_root}}/cli/node_modules/.bin:{{env.PATH}}"
dir = "cli"
run = "prettier --check ."
[tasks."cli:format-fix"]
env.PATH = "{{config_root}}/cli/node_modules/.bin:{{env.PATH}}"
dir = "cli"
run = "prettier --write ."
[tasks."cli:check"]
env.PATH = "{{config_root}}/cli/node_modules/.bin:{{env.PATH}}"
dir = "cli"
run = "tsc --noEmit"
# @immich/sdk
[tasks."sdk:install"]
run = "pnpm install --filter @immich/sdk --frozen-lockfile"
[tasks."sdk:build"]
env.PATH = "{{config_root}}/open-api/typescript-sdk/node_modules/.bin:{{env.PATH}}"
dir = "./open-api/typescript-sdk"
run = "tsc"
# docs
[tasks."docs:install"]
run = "pnpm install --filter documentation --frozen-lockfile"
[tasks."docs:start"]
env.PATH = "{{config_root}}/docs/node_modules/.bin:{{env.PATH}}"
dir = "docs"
run = "docusaurus --port 3005"
[tasks."docs:build"]
env.PATH = "{{config_root}}/docs/node_modules/.bin:{{env.PATH}}"
dir = "docs"
run = [
"jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"docusaurus build",
]
[tasks."docs:preview"]
env.PATH = "{{config_root}}/docs/node_modules/.bin:{{env.PATH}}"
dir = "docs"
run = "docusaurus serve"
[tasks."docs:format"]
env.PATH = "{{config_root}}/docs/node_modules/.bin:{{env.PATH}}"
dir = "docs"
run = "prettier --check ."
[tasks."docs:format-fix"]
env.PATH = "{{config_root}}/docs/node_modules/.bin:{{env.PATH}}"
dir = "docs"
run = "prettier --write ."
# e2e
[tasks."e2e:install"]
run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks."e2e:test"]
env.PATH = "{{config_root}}/e2e/node_modules/.bin:{{env.PATH}}"
dir = "e2e"
run = "vitest --run"
[tasks."e2e:test-web"]
env.PATH = "{{config_root}}/e2e/node_modules/.bin:{{env.PATH}}"
dir = "e2e"
run = "playwright test"
[tasks."e2e:format"]
env.PATH = "{{config_root}}/e2e/node_modules/.bin:{{env.PATH}}"
dir = "e2e"
run = "prettier --check ."
[tasks."e2e:format-fix"]
env.PATH = "{{config_root}}/e2e/node_modules/.bin:{{env.PATH}}"
dir = "e2e"
run = "prettier --write ."
[tasks."e2e:lint"]
env.PATH = "{{config_root}}/e2e/node_modules/.bin:{{env.PATH}}"
dir = "e2e"
run = "eslint \"src/**/*.ts\" --max-warnings 0"
[tasks."e2e:lint-fix"]
run = "mise run e2e:lint --fix"
[tasks."e2e:check"]
env.PATH = "{{config_root}}/e2e/node_modules/.bin:{{env.PATH}}"
dir = "e2e"
run = "tsc --noEmit"
# i18n
[tasks."i18n:format"]
run = "mise run i18n:format-fix"
[tasks."i18n:format-fix"]
run = "pnpm dlx sort-json ./i18n/*.json"
# server
[tasks."server:install"]
run = "pnpm install --filter immich --frozen-lockfile"
[tasks."server:build"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = "nest build"
[tasks."server:test"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = [
"pwd",
"ls -lh",
"echo $PATH",
"which vitest",
"vitest --config test/vitest.config.mjs",
]
[tasks."server:test-medium"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = "vitest --config test/vitest.config.medium.mjs"
[tasks."server:format"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = "prettier --check ."
[tasks."server:format-fix"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = "prettier --write ."
[tasks."server:lint"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0"
[tasks."server:lint-fix"]
run = "mise run server:lint --fix"
[tasks."server:check"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = "tsc --noEmit"
[tasks."server:sql"]
dir = "server"
run = "node ./dist/bin/sync-open-api.js"
[tasks."server:open-api"]
dir = "server"
run = "node ./dist/bin/sync-open-api.js"
[tasks."server:migrations"]
dir = "server"
run = "node ./dist/bin/migrations.js"
description = "Run database migration commands (create, generate, run, debug, or query)"
[tasks."server:schema-drop"]
run = "mise run server:migrations query 'DROP schema public cascade; CREATE schema public;'"
[tasks."server:schema-reset"]
run = "mise run server:schema-drop && mise run server:migrations run"
[tasks."server:email-dev"]
env.PATH = "{{config_root}}/server/node_modules/.bin:{{env.PATH}}"
dir = "server"
run = "email dev -p 3050 --dir src/emails"
[tasks."server:checks-small"]
depends = "server:install"
run = [
"mise run server:format",
"mise run server:lint",
"mise run server:check",
"mise run server:test --run",
]
[tasks."server:checks-medium"]
depends = "server:install"
run = ["mise run server:test-medium --run"]
[tasks."server:checks"]
run = ["mise run server:checks-small", "mise run server:checks-medium"]
# web
[tasks."web:install"]
run = "pnpm install --filter immich-web --frozen-lockfile"
[tasks."web:svelte-kit-sync"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "svelte-kit sync"
[tasks."web:build"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "vite build"
[tasks."web:build-stats"]
env.BUILD_STATS = "true"
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "vite build"
[tasks."web:preview"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "vite preview"
[tasks."web:start"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "vite dev --host 0.0.0.0 --port 3000"
[tasks."web:test"]
depends = "web:svelte-kit-sync"
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "vitest"
[tasks."web:format"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "prettier --check ."
[tasks."web:format-fix"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "prettier --write ."
[tasks."web:lint"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "eslint . --max-warnings 0"
[tasks."web:lint-p"]
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "eslint-p . --max-warnings 0 --concurrency=4"
[tasks."web:lint-fix"]
run = "mise run web:lint --fix"
[tasks."web:check"]
depends = "web:svelte-kit-sync"
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "tsc --noEmit"
[tasks."web:check-svelte"]
depends = "web:svelte-kit-sync"
env.PATH = "{{config_root}}/web/node_modules/.bin:{{env.PATH}}"
dir = "web"
run = "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte"
[tasks."web:checklist"]
run = [
"mise run web:install",
"mise run web:format",
"mise run web:check",
"mise run web:test --run",
"mise run web:lint",
]
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 3011, "android.injected.version.code" => 3012,
"android.injected.version.name" => "1.140.1", "android.injected.version.name" => "1.141.0",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
) )
increment_version_number( increment_version_number(
version_number: "1.140.1" version_number: "1.141.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
@@ -15,6 +15,7 @@ class LocalAlbum {
final int assetCount; final int assetCount;
final BackupSelection backupSelection; final BackupSelection backupSelection;
final String? linkedRemoteAlbumId;
const LocalAlbum({ const LocalAlbum({
required this.id, required this.id,
@@ -23,6 +24,7 @@ class LocalAlbum {
this.assetCount = 0, this.assetCount = 0,
this.backupSelection = BackupSelection.none, this.backupSelection = BackupSelection.none,
this.isIosSharedAlbum = false, this.isIosSharedAlbum = false,
this.linkedRemoteAlbumId,
}); });
LocalAlbum copyWith({ LocalAlbum copyWith({
@@ -32,6 +34,7 @@ class LocalAlbum {
int? assetCount, int? assetCount,
BackupSelection? backupSelection, BackupSelection? backupSelection,
bool? isIosSharedAlbum, bool? isIosSharedAlbum,
String? linkedRemoteAlbumId,
}) { }) {
return LocalAlbum( return LocalAlbum(
id: id ?? this.id, id: id ?? this.id,
@@ -40,6 +43,7 @@ class LocalAlbum {
assetCount: assetCount ?? this.assetCount, assetCount: assetCount ?? this.assetCount,
backupSelection: backupSelection ?? this.backupSelection, backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
); );
} }
@@ -53,7 +57,8 @@ class LocalAlbum {
other.updatedAt == updatedAt && other.updatedAt == updatedAt &&
other.assetCount == assetCount && other.assetCount == assetCount &&
other.backupSelection == backupSelection && other.backupSelection == backupSelection &&
other.isIosSharedAlbum == isIosSharedAlbum; other.isIosSharedAlbum == isIosSharedAlbum &&
other.linkedRemoteAlbumId == linkedRemoteAlbumId;
} }
@override @override
@@ -63,7 +68,8 @@ class LocalAlbum {
updatedAt.hashCode ^ updatedAt.hashCode ^
assetCount.hashCode ^ assetCount.hashCode ^
backupSelection.hashCode ^ backupSelection.hashCode ^
isIosSharedAlbum.hashCode; isIosSharedAlbum.hashCode ^
linkedRemoteAlbumId.hashCode;
} }
@override @override
@@ -75,6 +81,7 @@ updatedAt: $updatedAt,
assetCount: $assetCount, assetCount: $assetCount,
backupSelection: $backupSelection, backupSelection: $backupSelection,
isIosSharedAlbum: $isIosSharedAlbum isIosSharedAlbum: $isIosSharedAlbum
linkedRemoteAlbumId: $linkedRemoteAlbumId,
}'''; }''';
} }
} }
@@ -22,4 +22,16 @@ class LocalAlbumService {
Future<int> getCount() { Future<int> getCount() {
return _repository.getCount(); return _repository.getCount();
} }
Future<void> unlinkRemoteAlbum(String id) async {
return _repository.unlinkRemoteAlbum(id);
}
Future<void> linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
return _repository.linkRemoteAlbum(localAlbumId, remoteAlbumId);
}
Future<List<LocalAlbum>> getBackupAlbums() {
return _repository.getBackupAlbums();
}
} }
@@ -26,6 +26,10 @@ class RemoteAlbumService {
return _repository.get(albumId); return _repository.get(albumId);
} }
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
return _repository.getByName(albumName, ownerId);
}
Future<List<RemoteAlbum>> sortAlbums( Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { RemoteAlbumSortMode sortMode, {
@@ -80,7 +84,6 @@ class RemoteAlbumService {
Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async { Future<RemoteAlbum> createAlbum({required String title, required List<String> assetIds, String? description}) async {
final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds); final album = await _albumApiRepository.createDriftAlbum(title, description: description, assetIds: assetIds);
await _repository.create(album, assetIds); await _repository.create(album, assetIds);
return album; return album;
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
final syncLinkedAlbumServiceProvider = Provider(
(ref) => SyncLinkedAlbumService(
ref.watch(localAlbumRepository),
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
),
);
class SyncLinkedAlbumService {
final DriftLocalAlbumRepository _localAlbumRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftAlbumApiRepository _albumApiRepository;
const SyncLinkedAlbumService(this._localAlbumRepository, this._remoteAlbumRepository, this._albumApiRepository);
Future<void> syncLinkedAlbums(String userId) async {
final selectedAlbums = await _localAlbumRepository.getBackupAlbums();
await Future.wait(
selectedAlbums.map((localAlbum) async {
final linkedRemoteAlbumId = localAlbum.linkedRemoteAlbumId;
if (linkedRemoteAlbumId == null) {
return;
}
final remoteAlbum = await _remoteAlbumRepository.get(linkedRemoteAlbumId);
if (remoteAlbum == null) {
return;
}
// get assets that are uploaded but not in the remote album
final assetIds = await _remoteAlbumRepository.getLinkedAssetIds(userId, localAlbum.id, linkedRemoteAlbumId);
if (assetIds.isNotEmpty) {
final album = await _albumApiRepository.addAssets(remoteAlbum.id, assetIds);
await _remoteAlbumRepository.addAssets(remoteAlbum.id, album.added);
}
}),
);
}
Future<void> manageLinkedAlbums(List<LocalAlbum> localAlbums, String ownerId) async {
for (final album in localAlbums) {
await _processLocalAlbum(album, ownerId);
}
}
/// Processes a single local album to ensure proper linking with remote albums
Future<void> _processLocalAlbum(LocalAlbum localAlbum, String ownerId) {
final hasLinkedRemoteAlbum = localAlbum.linkedRemoteAlbumId != null;
if (hasLinkedRemoteAlbum) {
return _handleLinkedAlbum(localAlbum);
} else {
return _handleUnlinkedAlbum(localAlbum, ownerId);
}
}
/// Handles albums that are already linked to a remote album
Future<void> _handleLinkedAlbum(LocalAlbum localAlbum) async {
final remoteAlbumId = localAlbum.linkedRemoteAlbumId!;
final remoteAlbum = await _remoteAlbumRepository.get(remoteAlbumId);
final remoteAlbumExists = remoteAlbum != null;
if (!remoteAlbumExists) {
return _localAlbumRepository.unlinkRemoteAlbum(localAlbum.id);
}
}
/// Handles albums that are not linked to any remote album
Future<void> _handleUnlinkedAlbum(LocalAlbum localAlbum, String ownerId) async {
final existingRemoteAlbum = await _remoteAlbumRepository.getByName(localAlbum.name, ownerId);
if (existingRemoteAlbum != null) {
return _linkToExistingRemoteAlbum(localAlbum, existingRemoteAlbum);
} else {
return _createAndLinkNewRemoteAlbum(localAlbum);
}
}
/// Links a local album to an existing remote album
Future<void> _linkToExistingRemoteAlbum(LocalAlbum localAlbum, dynamic existingRemoteAlbum) {
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, existingRemoteAlbum.id);
}
/// Creates a new remote album and links it to the local album
Future<void> _createAndLinkNewRemoteAlbum(LocalAlbum localAlbum) async {
debugPrint("Creating new remote album for local album: ${localAlbum.name}");
final newRemoteAlbum = await _albumApiRepository.createDriftAlbum(localAlbum.name, assetIds: []);
await _remoteAlbumRepository.create(newRemoteAlbum, []);
return _localAlbumRepository.linkRemoteAlbum(localAlbum.id, newRemoteAlbum.id);
}
}
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:immich_mobile/domain/utils/sync_linked_album.dart';
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
import 'package:immich_mobile/utils/isolate.dart'; import 'package:immich_mobile/utils/isolate.dart';
import 'package:worker_manager/worker_manager.dart'; import 'package:worker_manager/worker_manager.dart';
@@ -155,6 +156,11 @@ class BackgroundSyncManager {
_syncWebsocketTask = null; _syncWebsocketTask = null;
}); });
} }
Future<void> syncLinkedAlbum() {
final task = runInIsolateGentle(computation: syncLinkedAlbumsIsolated);
return task.future;
}
} }
Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle( Cancelable<void> _handleWsAssetUploadReadyV1Batch(List<dynamic> batchData) => runInIsolateGentle(
@@ -0,0 +1,11 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
Future<void> syncLinkedAlbumsIsolated(ProviderContainer ref) {
final user = ref.read(currentUserProvider);
if (user == null) {
return Future.value();
}
return ref.read(syncLinkedAlbumServiceProvider).syncLinkedAlbums(user.id);
}
@@ -1,5 +1,7 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class LocalAlbumEntity extends Table with DriftDefaultsMixin { class LocalAlbumEntity extends Table with DriftDefaultsMixin {
@@ -11,9 +13,26 @@ class LocalAlbumEntity extends Table with DriftDefaultsMixin {
IntColumn get backupSelection => intEnum<BackupSelection>()(); IntColumn get backupSelection => intEnum<BackupSelection>()();
BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))(); BoolColumn get isIosSharedAlbum => boolean().withDefault(const Constant(false))();
// // Linked album for putting assets to the remote album after finished uploading
TextColumn get linkedRemoteAlbumId =>
text().references(RemoteAlbumEntity, #id, onDelete: KeyAction.setNull).nullable()();
// Used for mark & sweep // Used for mark & sweep
BoolColumn get marker_ => boolean().nullable()(); BoolColumn get marker_ => boolean().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
linkedRemoteAlbumId: linkedRemoteAlbumId,
);
}
}
+253 -20
View File
@@ -7,6 +7,9 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart' import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'
as i3; as i3;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4; import 'package:drift/src/runtime/query_builder/query_builder.dart' as i4;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
typedef $$LocalAlbumEntityTableCreateCompanionBuilder = typedef $$LocalAlbumEntityTableCreateCompanionBuilder =
i1.LocalAlbumEntityCompanion Function({ i1.LocalAlbumEntityCompanion Function({
@@ -15,6 +18,7 @@ typedef $$LocalAlbumEntityTableCreateCompanionBuilder =
i0.Value<DateTime> updatedAt, i0.Value<DateTime> updatedAt,
required i2.BackupSelection backupSelection, required i2.BackupSelection backupSelection,
i0.Value<bool> isIosSharedAlbum, i0.Value<bool> isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId,
i0.Value<bool?> marker_, i0.Value<bool?> marker_,
}); });
typedef $$LocalAlbumEntityTableUpdateCompanionBuilder = typedef $$LocalAlbumEntityTableUpdateCompanionBuilder =
@@ -24,9 +28,57 @@ typedef $$LocalAlbumEntityTableUpdateCompanionBuilder =
i0.Value<DateTime> updatedAt, i0.Value<DateTime> updatedAt,
i0.Value<i2.BackupSelection> backupSelection, i0.Value<i2.BackupSelection> backupSelection,
i0.Value<bool> isIosSharedAlbum, i0.Value<bool> isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId,
i0.Value<bool?> marker_, i0.Value<bool?> marker_,
}); });
final class $$LocalAlbumEntityTableReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
> {
$$LocalAlbumEntityTableReferences(
super.$_db,
super.$_table,
super.$_typedResult,
);
static i5.$RemoteAlbumEntityTable _linkedRemoteAlbumIdTable(
i0.GeneratedDatabase db,
) => i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumEntityTable>('local_album_entity')
.linkedRemoteAlbumId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
);
i5.$$RemoteAlbumEntityTableProcessedTableManager? get linkedRemoteAlbumId {
final $_column = $_itemColumn<String>('linked_remote_album_id');
if ($_column == null) return null;
final manager = i5
.$$RemoteAlbumEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_linkedRemoteAlbumIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $$LocalAlbumEntityTableFilterComposer class $$LocalAlbumEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> { extends i0.Composer<i0.GeneratedDatabase, i1.$LocalAlbumEntityTable> {
$$LocalAlbumEntityTableFilterComposer({ $$LocalAlbumEntityTableFilterComposer({
@@ -66,6 +118,33 @@ class $$LocalAlbumEntityTableFilterComposer
column: $table.marker_, column: $table.marker_,
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i5.$$RemoteAlbumEntityTableFilterComposer get linkedRemoteAlbumId {
final i5.$$RemoteAlbumEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.linkedRemoteAlbumId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAlbumEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$LocalAlbumEntityTableOrderingComposer class $$LocalAlbumEntityTableOrderingComposer
@@ -106,6 +185,34 @@ class $$LocalAlbumEntityTableOrderingComposer
column: $table.marker_, column: $table.marker_,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i5.$$RemoteAlbumEntityTableOrderingComposer get linkedRemoteAlbumId {
final i5.$$RemoteAlbumEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.linkedRemoteAlbumId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAlbumEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$LocalAlbumEntityTableAnnotationComposer class $$LocalAlbumEntityTableAnnotationComposer
@@ -139,6 +246,34 @@ class $$LocalAlbumEntityTableAnnotationComposer
i0.GeneratedColumn<bool> get marker_ => i0.GeneratedColumn<bool> get marker_ =>
$composableBuilder(column: $table.marker_, builder: (column) => column); $composableBuilder(column: $table.marker_, builder: (column) => column);
i5.$$RemoteAlbumEntityTableAnnotationComposer get linkedRemoteAlbumId {
final i5.$$RemoteAlbumEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.linkedRemoteAlbumId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAlbumEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
} }
class $$LocalAlbumEntityTableTableManager class $$LocalAlbumEntityTableTableManager
@@ -152,16 +287,9 @@ class $$LocalAlbumEntityTableTableManager
i1.$$LocalAlbumEntityTableAnnotationComposer, i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder, $$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder, $$LocalAlbumEntityTableUpdateCompanionBuilder,
( (i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences),
i1.LocalAlbumEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
>,
),
i1.LocalAlbumEntityData, i1.LocalAlbumEntityData,
i0.PrefetchHooks Function() i0.PrefetchHooks Function({bool linkedRemoteAlbumId})
> { > {
$$LocalAlbumEntityTableTableManager( $$LocalAlbumEntityTableTableManager(
i0.GeneratedDatabase db, i0.GeneratedDatabase db,
@@ -187,6 +315,7 @@ class $$LocalAlbumEntityTableTableManager
i0.Value<i2.BackupSelection> backupSelection = i0.Value<i2.BackupSelection> backupSelection =
const i0.Value.absent(), const i0.Value.absent(),
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(), i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(), i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityCompanion( }) => i1.LocalAlbumEntityCompanion(
id: id, id: id,
@@ -194,6 +323,7 @@ class $$LocalAlbumEntityTableTableManager
updatedAt: updatedAt, updatedAt: updatedAt,
backupSelection: backupSelection, backupSelection: backupSelection,
isIosSharedAlbum: isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId,
marker_: marker_, marker_: marker_,
), ),
createCompanionCallback: createCompanionCallback:
@@ -203,6 +333,7 @@ class $$LocalAlbumEntityTableTableManager
i0.Value<DateTime> updatedAt = const i0.Value.absent(), i0.Value<DateTime> updatedAt = const i0.Value.absent(),
required i2.BackupSelection backupSelection, required i2.BackupSelection backupSelection,
i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(), i0.Value<bool> isIosSharedAlbum = const i0.Value.absent(),
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(), i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityCompanion.insert( }) => i1.LocalAlbumEntityCompanion.insert(
id: id, id: id,
@@ -210,12 +341,60 @@ class $$LocalAlbumEntityTableTableManager
updatedAt: updatedAt, updatedAt: updatedAt,
backupSelection: backupSelection, backupSelection: backupSelection,
isIosSharedAlbum: isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId,
marker_: marker_, marker_: marker_,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) .map(
(e) => (
e.readTable(table),
i1.$$LocalAlbumEntityTableReferences(db, table, e),
),
)
.toList(), .toList(),
prefetchHooksCallback: null, prefetchHooksCallback: ({linkedRemoteAlbumId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (linkedRemoteAlbumId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.linkedRemoteAlbumId,
referencedTable: i1
.$$LocalAlbumEntityTableReferences
._linkedRemoteAlbumIdTable(db),
referencedColumn: i1
.$$LocalAlbumEntityTableReferences
._linkedRemoteAlbumIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
), ),
); );
} }
@@ -230,16 +409,9 @@ typedef $$LocalAlbumEntityTableProcessedTableManager =
i1.$$LocalAlbumEntityTableAnnotationComposer, i1.$$LocalAlbumEntityTableAnnotationComposer,
$$LocalAlbumEntityTableCreateCompanionBuilder, $$LocalAlbumEntityTableCreateCompanionBuilder,
$$LocalAlbumEntityTableUpdateCompanionBuilder, $$LocalAlbumEntityTableUpdateCompanionBuilder,
( (i1.LocalAlbumEntityData, i1.$$LocalAlbumEntityTableReferences),
i1.LocalAlbumEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$LocalAlbumEntityTable,
i1.LocalAlbumEntityData
>,
),
i1.LocalAlbumEntityData, i1.LocalAlbumEntityData,
i0.PrefetchHooks Function() i0.PrefetchHooks Function({bool linkedRemoteAlbumId})
>; >;
class $LocalAlbumEntityTable extends i3.LocalAlbumEntity class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
@@ -308,6 +480,20 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
), ),
defaultValue: const i4.Constant(false), defaultValue: const i4.Constant(false),
); );
static const i0.VerificationMeta _linkedRemoteAlbumIdMeta =
const i0.VerificationMeta('linkedRemoteAlbumId');
@override
late final i0.GeneratedColumn<String> linkedRemoteAlbumId =
i0.GeneratedColumn<String>(
'linked_remote_album_id',
aliasedName,
true,
type: i0.DriftSqlType.string,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_album_entity (id) ON DELETE SET NULL',
),
);
static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta( static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
'marker_', 'marker_',
); );
@@ -329,6 +515,7 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
updatedAt, updatedAt,
backupSelection, backupSelection,
isIosSharedAlbum, isIosSharedAlbum,
linkedRemoteAlbumId,
marker_, marker_,
]; ];
@override @override
@@ -371,6 +558,15 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
), ),
); );
} }
if (data.containsKey('linked_remote_album_id')) {
context.handle(
_linkedRemoteAlbumIdMeta,
linkedRemoteAlbumId.isAcceptableOrUnknown(
data['linked_remote_album_id']!,
_linkedRemoteAlbumIdMeta,
),
);
}
if (data.containsKey('marker')) { if (data.containsKey('marker')) {
context.handle( context.handle(
_marker_Meta, _marker_Meta,
@@ -412,6 +608,10 @@ class $LocalAlbumEntityTable extends i3.LocalAlbumEntity
i0.DriftSqlType.bool, i0.DriftSqlType.bool,
data['${effectivePrefix}is_ios_shared_album'], data['${effectivePrefix}is_ios_shared_album'],
)!, )!,
linkedRemoteAlbumId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}linked_remote_album_id'],
),
marker_: attachedDatabase.typeMapping.read( marker_: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool, i0.DriftSqlType.bool,
data['${effectivePrefix}marker'], data['${effectivePrefix}marker'],
@@ -441,6 +641,7 @@ class LocalAlbumEntityData extends i0.DataClass
final DateTime updatedAt; final DateTime updatedAt;
final i2.BackupSelection backupSelection; final i2.BackupSelection backupSelection;
final bool isIosSharedAlbum; final bool isIosSharedAlbum;
final String? linkedRemoteAlbumId;
final bool? marker_; final bool? marker_;
const LocalAlbumEntityData({ const LocalAlbumEntityData({
required this.id, required this.id,
@@ -448,6 +649,7 @@ class LocalAlbumEntityData extends i0.DataClass
required this.updatedAt, required this.updatedAt,
required this.backupSelection, required this.backupSelection,
required this.isIosSharedAlbum, required this.isIosSharedAlbum,
this.linkedRemoteAlbumId,
this.marker_, this.marker_,
}); });
@override @override
@@ -464,6 +666,9 @@ class LocalAlbumEntityData extends i0.DataClass
); );
} }
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum); map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum);
if (!nullToAbsent || linkedRemoteAlbumId != null) {
map['linked_remote_album_id'] = i0.Variable<String>(linkedRemoteAlbumId);
}
if (!nullToAbsent || marker_ != null) { if (!nullToAbsent || marker_ != null) {
map['marker'] = i0.Variable<bool>(marker_); map['marker'] = i0.Variable<bool>(marker_);
} }
@@ -482,6 +687,9 @@ class LocalAlbumEntityData extends i0.DataClass
backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection backupSelection: i1.$LocalAlbumEntityTable.$converterbackupSelection
.fromJson(serializer.fromJson<int>(json['backupSelection'])), .fromJson(serializer.fromJson<int>(json['backupSelection'])),
isIosSharedAlbum: serializer.fromJson<bool>(json['isIosSharedAlbum']), isIosSharedAlbum: serializer.fromJson<bool>(json['isIosSharedAlbum']),
linkedRemoteAlbumId: serializer.fromJson<String?>(
json['linkedRemoteAlbumId'],
),
marker_: serializer.fromJson<bool?>(json['marker_']), marker_: serializer.fromJson<bool?>(json['marker_']),
); );
} }
@@ -498,6 +706,7 @@ class LocalAlbumEntityData extends i0.DataClass
), ),
), ),
'isIosSharedAlbum': serializer.toJson<bool>(isIosSharedAlbum), 'isIosSharedAlbum': serializer.toJson<bool>(isIosSharedAlbum),
'linkedRemoteAlbumId': serializer.toJson<String?>(linkedRemoteAlbumId),
'marker_': serializer.toJson<bool?>(marker_), 'marker_': serializer.toJson<bool?>(marker_),
}; };
} }
@@ -508,6 +717,7 @@ class LocalAlbumEntityData extends i0.DataClass
DateTime? updatedAt, DateTime? updatedAt,
i2.BackupSelection? backupSelection, i2.BackupSelection? backupSelection,
bool? isIosSharedAlbum, bool? isIosSharedAlbum,
i0.Value<String?> linkedRemoteAlbumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(), i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumEntityData( }) => i1.LocalAlbumEntityData(
id: id ?? this.id, id: id ?? this.id,
@@ -515,6 +725,9 @@ class LocalAlbumEntityData extends i0.DataClass
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
backupSelection: backupSelection ?? this.backupSelection, backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId.present
? linkedRemoteAlbumId.value
: this.linkedRemoteAlbumId,
marker_: marker_.present ? marker_.value : this.marker_, marker_: marker_.present ? marker_.value : this.marker_,
); );
LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) { LocalAlbumEntityData copyWithCompanion(i1.LocalAlbumEntityCompanion data) {
@@ -528,6 +741,9 @@ class LocalAlbumEntityData extends i0.DataClass
isIosSharedAlbum: data.isIosSharedAlbum.present isIosSharedAlbum: data.isIosSharedAlbum.present
? data.isIosSharedAlbum.value ? data.isIosSharedAlbum.value
: this.isIosSharedAlbum, : this.isIosSharedAlbum,
linkedRemoteAlbumId: data.linkedRemoteAlbumId.present
? data.linkedRemoteAlbumId.value
: this.linkedRemoteAlbumId,
marker_: data.marker_.present ? data.marker_.value : this.marker_, marker_: data.marker_.present ? data.marker_.value : this.marker_,
); );
} }
@@ -540,6 +756,7 @@ class LocalAlbumEntityData extends i0.DataClass
..write('updatedAt: $updatedAt, ') ..write('updatedAt: $updatedAt, ')
..write('backupSelection: $backupSelection, ') ..write('backupSelection: $backupSelection, ')
..write('isIosSharedAlbum: $isIosSharedAlbum, ') ..write('isIosSharedAlbum: $isIosSharedAlbum, ')
..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ')
..write('marker_: $marker_') ..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -552,6 +769,7 @@ class LocalAlbumEntityData extends i0.DataClass
updatedAt, updatedAt,
backupSelection, backupSelection,
isIosSharedAlbum, isIosSharedAlbum,
linkedRemoteAlbumId,
marker_, marker_,
); );
@override @override
@@ -563,6 +781,7 @@ class LocalAlbumEntityData extends i0.DataClass
other.updatedAt == this.updatedAt && other.updatedAt == this.updatedAt &&
other.backupSelection == this.backupSelection && other.backupSelection == this.backupSelection &&
other.isIosSharedAlbum == this.isIosSharedAlbum && other.isIosSharedAlbum == this.isIosSharedAlbum &&
other.linkedRemoteAlbumId == this.linkedRemoteAlbumId &&
other.marker_ == this.marker_); other.marker_ == this.marker_);
} }
@@ -573,6 +792,7 @@ class LocalAlbumEntityCompanion
final i0.Value<DateTime> updatedAt; final i0.Value<DateTime> updatedAt;
final i0.Value<i2.BackupSelection> backupSelection; final i0.Value<i2.BackupSelection> backupSelection;
final i0.Value<bool> isIosSharedAlbum; final i0.Value<bool> isIosSharedAlbum;
final i0.Value<String?> linkedRemoteAlbumId;
final i0.Value<bool?> marker_; final i0.Value<bool?> marker_;
const LocalAlbumEntityCompanion({ const LocalAlbumEntityCompanion({
this.id = const i0.Value.absent(), this.id = const i0.Value.absent(),
@@ -580,6 +800,7 @@ class LocalAlbumEntityCompanion
this.updatedAt = const i0.Value.absent(), this.updatedAt = const i0.Value.absent(),
this.backupSelection = const i0.Value.absent(), this.backupSelection = const i0.Value.absent(),
this.isIosSharedAlbum = const i0.Value.absent(), this.isIosSharedAlbum = const i0.Value.absent(),
this.linkedRemoteAlbumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(), this.marker_ = const i0.Value.absent(),
}); });
LocalAlbumEntityCompanion.insert({ LocalAlbumEntityCompanion.insert({
@@ -588,6 +809,7 @@ class LocalAlbumEntityCompanion
this.updatedAt = const i0.Value.absent(), this.updatedAt = const i0.Value.absent(),
required i2.BackupSelection backupSelection, required i2.BackupSelection backupSelection,
this.isIosSharedAlbum = const i0.Value.absent(), this.isIosSharedAlbum = const i0.Value.absent(),
this.linkedRemoteAlbumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(), this.marker_ = const i0.Value.absent(),
}) : id = i0.Value(id), }) : id = i0.Value(id),
name = i0.Value(name), name = i0.Value(name),
@@ -598,6 +820,7 @@ class LocalAlbumEntityCompanion
i0.Expression<DateTime>? updatedAt, i0.Expression<DateTime>? updatedAt,
i0.Expression<int>? backupSelection, i0.Expression<int>? backupSelection,
i0.Expression<bool>? isIosSharedAlbum, i0.Expression<bool>? isIosSharedAlbum,
i0.Expression<String>? linkedRemoteAlbumId,
i0.Expression<bool>? marker_, i0.Expression<bool>? marker_,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
@@ -606,6 +829,8 @@ class LocalAlbumEntityCompanion
if (updatedAt != null) 'updated_at': updatedAt, if (updatedAt != null) 'updated_at': updatedAt,
if (backupSelection != null) 'backup_selection': backupSelection, if (backupSelection != null) 'backup_selection': backupSelection,
if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum,
if (linkedRemoteAlbumId != null)
'linked_remote_album_id': linkedRemoteAlbumId,
if (marker_ != null) 'marker': marker_, if (marker_ != null) 'marker': marker_,
}); });
} }
@@ -616,6 +841,7 @@ class LocalAlbumEntityCompanion
i0.Value<DateTime>? updatedAt, i0.Value<DateTime>? updatedAt,
i0.Value<i2.BackupSelection>? backupSelection, i0.Value<i2.BackupSelection>? backupSelection,
i0.Value<bool>? isIosSharedAlbum, i0.Value<bool>? isIosSharedAlbum,
i0.Value<String?>? linkedRemoteAlbumId,
i0.Value<bool?>? marker_, i0.Value<bool?>? marker_,
}) { }) {
return i1.LocalAlbumEntityCompanion( return i1.LocalAlbumEntityCompanion(
@@ -624,6 +850,7 @@ class LocalAlbumEntityCompanion
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
backupSelection: backupSelection ?? this.backupSelection, backupSelection: backupSelection ?? this.backupSelection,
isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum,
linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId,
marker_: marker_ ?? this.marker_, marker_: marker_ ?? this.marker_,
); );
} }
@@ -650,6 +877,11 @@ class LocalAlbumEntityCompanion
if (isIosSharedAlbum.present) { if (isIosSharedAlbum.present) {
map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum.value); map['is_ios_shared_album'] = i0.Variable<bool>(isIosSharedAlbum.value);
} }
if (linkedRemoteAlbumId.present) {
map['linked_remote_album_id'] = i0.Variable<String>(
linkedRemoteAlbumId.value,
);
}
if (marker_.present) { if (marker_.present) {
map['marker'] = i0.Variable<bool>(marker_.value); map['marker'] = i0.Variable<bool>(marker_.value);
} }
@@ -664,6 +896,7 @@ class LocalAlbumEntityCompanion
..write('updatedAt: $updatedAt, ') ..write('updatedAt: $updatedAt, ')
..write('backupSelection: $backupSelection, ') ..write('backupSelection: $backupSelection, ')
..write('isIosSharedAlbum: $isIosSharedAlbum, ') ..write('isIosSharedAlbum: $isIosSharedAlbum, ')
..write('linkedRemoteAlbumId: $linkedRemoteAlbumId, ')
..write('marker_: $marker_') ..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
@@ -20,7 +20,7 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
LocalAsset toDto() => LocalAsset( LocalAsset toDto() => LocalAsset(
id: id, id: id,
name: name, name: name,
@@ -4,9 +4,10 @@ import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import "package:immich_mobile/utils/database.utils.dart";
final backupRepositoryProvider = Provider<DriftBackupRepository>( final backupRepositoryProvider = Provider<DriftBackupRepository>(
(ref) => DriftBackupRepository(ref.watch(driftProvider)), (ref) => DriftBackupRepository(ref.watch(driftProvider)),
@@ -68,7 +68,7 @@ class Drift extends $Drift implements IDatabaseRepository {
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true))); : super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
@override @override
int get schemaVersion => 8; int get schemaVersion => 9;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -123,6 +123,9 @@ class Drift extends $Drift implements IDatabaseRepository {
from7To8: (m, v8) async { from7To8: (m, v8) async {
await m.create(v8.storeEntity); await m.create(v8.storeEntity);
}, },
from8To9: (m, v9) async {
await m.addColumn(v9.localAlbumEntity, v9.localAlbumEntity.linkedRemoteAlbumId);
},
), ),
); );
@@ -9,17 +9,17 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i3; as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4; as i4;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i10; as i10;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart' import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i11; as i11;
@@ -48,19 +48,19 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this); late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this);
late final i4.$LocalAssetEntityTable localAssetEntity = i4 late final i4.$LocalAssetEntityTable localAssetEntity = i4
.$LocalAssetEntityTable(this); .$LocalAssetEntityTable(this);
late final i5.$LocalAlbumEntityTable localAlbumEntity = i5 late final i5.$RemoteAlbumEntityTable remoteAlbumEntity = i5
.$RemoteAlbumEntityTable(this);
late final i6.$LocalAlbumEntityTable localAlbumEntity = i6
.$LocalAlbumEntityTable(this); .$LocalAlbumEntityTable(this);
late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i6 late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i7
.$LocalAlbumAssetEntityTable(this); .$LocalAlbumAssetEntityTable(this);
late final i7.$UserMetadataEntityTable userMetadataEntity = i7 late final i8.$UserMetadataEntityTable userMetadataEntity = i8
.$UserMetadataEntityTable(this); .$UserMetadataEntityTable(this);
late final i8.$PartnerEntityTable partnerEntity = i8.$PartnerEntityTable( late final i9.$PartnerEntityTable partnerEntity = i9.$PartnerEntityTable(
this, this,
); );
late final i9.$RemoteExifEntityTable remoteExifEntity = i9 late final i10.$RemoteExifEntityTable remoteExifEntity = i10
.$RemoteExifEntityTable(this); .$RemoteExifEntityTable(this);
late final i10.$RemoteAlbumEntityTable remoteAlbumEntity = i10
.$RemoteAlbumEntityTable(this);
late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i11 late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i11
.$RemoteAlbumAssetEntityTable(this); .$RemoteAlbumAssetEntityTable(this);
late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i12 late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i12
@@ -84,6 +84,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAssetEntity, remoteAssetEntity,
stackEntity, stackEntity,
localAssetEntity, localAssetEntity,
remoteAlbumEntity,
localAlbumEntity, localAlbumEntity,
localAlbumAssetEntity, localAlbumAssetEntity,
i4.idxLocalAssetChecksum, i4.idxLocalAssetChecksum,
@@ -94,7 +95,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
userMetadataEntity, userMetadataEntity,
partnerEntity, partnerEntity,
remoteExifEntity, remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity, remoteAlbumAssetEntity,
remoteAlbumUserEntity, remoteAlbumUserEntity,
memoryEntity, memoryEntity,
@@ -102,7 +102,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
personEntity, personEntity,
assetFaceEntity, assetFaceEntity,
storeEntity, storeEntity,
i9.idxLatLng, i10.idxLatLng,
]; ];
@override @override
i0.StreamQueryUpdateRules i0.StreamQueryUpdateRules
@@ -123,6 +123,33 @@ abstract class $Drift extends i0.GeneratedDatabase {
), ),
result: [i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete)], result: [i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete)],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_album_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('local_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation( i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName( on: i0.TableUpdateQuery.onTableName(
'local_asset_entity', 'local_asset_entity',
@@ -173,24 +200,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('remote_exif_entity', kind: i0.UpdateKind.delete), i0.TableUpdate('remote_exif_entity', kind: i0.UpdateKind.delete),
], ],
), ),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'user_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [
i0.TableUpdate('remote_album_entity', kind: i0.UpdateKind.update),
],
),
i0.WritePropagation( i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName( on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity', 'remote_asset_entity',
@@ -290,18 +299,18 @@ class $DriftManager {
i3.$$StackEntityTableTableManager(_db, _db.stackEntity); i3.$$StackEntityTableTableManager(_db, _db.stackEntity);
i4.$$LocalAssetEntityTableTableManager get localAssetEntity => i4.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i5.$$LocalAlbumEntityTableTableManager get localAlbumEntity => i5.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i5.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); i5.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6 i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity); .$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i7.$$UserMetadataEntityTableTableManager get userMetadataEntity => i8.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i7.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); i8.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i8.$$PartnerEntityTableTableManager get partnerEntity => i9.$$PartnerEntityTableTableManager get partnerEntity =>
i8.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); i9.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i9.$$RemoteExifEntityTableTableManager get remoteExifEntity => i10.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i9.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity); i10.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i10.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i10.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity => i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i11.$$RemoteAlbumAssetEntityTableTableManager( i11.$$RemoteAlbumAssetEntityTableTableManager(
_db, _db,
@@ -3435,6 +3435,391 @@ i1.GeneratedColumn<int> _column_89(String aliasedName) =>
true, true,
type: i1.DriftSqlType.int, type: i1.DriftSqlType.int,
); );
final class Schema9 extends i0.VersionedSchema {
Schema9({required super.database}) : super(version: 9);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
idxLatLng,
];
late final Shape16 userEntity = Shape16(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_84,
_column_85,
_column_5,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 localAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
}
class Shape19 extends i0.VersionedTable {
Shape19({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get backupSelection =>
columnsByName['backup_selection']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<bool> get isIosSharedAlbum =>
columnsByName['is_ios_shared_album']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get linkedRemoteAlbumId =>
columnsByName['linked_remote_album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get marker_ =>
columnsByName['marker']! as i1.GeneratedColumn<bool>;
}
i1.GeneratedColumn<String> _column_90(String aliasedName) =>
i1.GeneratedColumn<String>(
'linked_remote_album_id',
aliasedName,
true,
type: i1.DriftSqlType.string,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_album_entity (id) ON DELETE SET NULL',
),
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -3443,6 +3828,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -3481,6 +3867,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from7To8(migrator, schema); await from7To8(migrator, schema);
return 8; return 8;
case 8:
final schema = Schema9(database: database);
final migrator = i1.Migrator(database, schema);
await from8To9(migrator, schema);
return 9;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -3495,6 +3886,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6, required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7, required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -3504,5 +3896,6 @@ i1.OnUpgrade stepByStep({
from5To6: from5To6, from5To6: from5To6,
from6To7: from6To7, from6To7: from6To7,
from7To8: from7To8, from7To8: from7To8,
from8To9: from8To9,
), ),
); );
@@ -1,11 +1,12 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/database.utils.dart';
import 'package:platform/platform.dart'; import 'package:platform/platform.dart';
enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset }
@@ -49,6 +50,13 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get(); return query.map((row) => row.readTable(_db.localAlbumEntity).toDto(assetCount: row.read(assetCount) ?? 0)).get();
} }
Future<List<LocalAlbum>> getBackupAlbums() async {
final query = _db.localAlbumEntity.select()
..where((row) => row.backupSelection.equalsValue(BackupSelection.selected));
return query.map((row) => row.toDto()).get();
}
Future<void> delete(String albumId) => transaction(() async { Future<void> delete(String albumId) => transaction(() async {
// Remove all assets that are only in this particular album // Remove all assets that are only in this particular album
// We cannot remove all assets in the album because they might be in other albums in iOS // We cannot remove all assets in the album because they might be in other albums in iOS
@@ -335,4 +343,16 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() { Future<int> getCount() {
return _db.managers.localAlbumEntity.count(); return _db.managers.localAlbumEntity.count();
} }
Future unlinkRemoteAlbum(String id) async {
return _db.localAlbumEntity.update()
..where((row) => row.id.equals(id))
..write(const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)));
}
Future linkRemoteAlbum(String localAlbumId, String remoteAlbumId) async {
return _db.localAlbumEntity.update()
..where((row) => row.id.equals(localAlbumId))
..write(LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(remoteAlbumId)));
}
} }
@@ -113,6 +113,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.getSingleOrNull(); .getSingleOrNull();
} }
Future<RemoteAlbum?> getByName(String albumName, String ownerId) {
final query = _db.remoteAlbumEntity.select()
..where((row) => row.name.equals(albumName) & row.ownerId.equals(ownerId))
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
..limit(1);
return query.map((row) => row.toDto(ownerName: '', isShared: false)).getSingleOrNull();
}
Future<void> create(RemoteAlbum album, List<String> assetIds) async { Future<void> create(RemoteAlbum album, List<String> assetIds) async {
await _db.transaction(() async { await _db.transaction(() async {
final entity = RemoteAlbumEntityCompanion( final entity = RemoteAlbumEntityCompanion(
@@ -321,6 +330,42 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
Future<int> getCount() { Future<int> getCount() {
return _db.managers.remoteAlbumEntity.count(); return _db.managers.remoteAlbumEntity.count();
} }
Future<List<String>> getLinkedAssetIds(String userId, String localAlbumId, String remoteAlbumId) async {
// Find remote asset ids that:
// 1. Belong to the provided local album (via local_album_asset_entity)
// 2. Have been uploaded (i.e. a matching remote asset exists for the same checksum & owner)
// 3. Are NOT already in the remote album (remote_album_asset_entity)
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id])
..join([
innerJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
innerJoin(
_db.localAlbumAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
// Left join remote album assets to exclude those already in the remote album
leftOuterJoin(
_db.remoteAlbumAssetEntity,
_db.remoteAlbumAssetEntity.assetId.equalsExp(_db.remoteAssetEntity.id) &
_db.remoteAlbumAssetEntity.albumId.equals(remoteAlbumId),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.deletedAt.isNull() &
_db.localAlbumAssetEntity.albumId.equals(localAlbumId) &
_db.remoteAlbumAssetEntity.assetId.isNull(), // only those not yet linked
);
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).get();
}
} }
extension on RemoteAlbumEntityData { extension on RemoteAlbumEntityData {
@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.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/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@@ -26,10 +27,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
String _searchQuery = ''; String _searchQuery = '';
bool _isSearchMode = false; bool _isSearchMode = false;
int _initialTotalAssetCount = 0; int _initialTotalAssetCount = 0;
bool _hasPopped = false;
late ValueNotifier<bool> _enableSyncUploadAlbum; late ValueNotifier<bool> _enableSyncUploadAlbum;
late TextEditingController _searchController; late TextEditingController _searchController;
late FocusNode _searchFocusNode; late FocusNode _searchFocusNode;
Future? _handleLinkedAlbumFuture;
@override @override
void initState() { void initState() {
@@ -44,6 +45,36 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
_initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount)); _initialTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
} }
Future<void> _handlePagePopped() async {
final user = ref.read(currentUserProvider);
if (user == null) {
return;
}
final enableSyncUploadAlbum = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final selectedAlbums = ref
.read(backupAlbumProvider)
.where((a) => a.backupSelection == BackupSelection.selected)
.toList();
if (enableSyncUploadAlbum && selectedAlbums.isNotEmpty) {
setState(() {
_handleLinkedAlbumFuture = ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedAlbums, user.id);
});
await _handleLinkedAlbumFuture;
}
// Restart backup if total count changed and backup is enabled
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (totalChanged && isBackupEnabled) {
await ref.read(driftBackupProvider.notifier).cancel();
await ref.read(driftBackupProvider.notifier).startBackup(user.id);
}
}
@override @override
void dispose() { void dispose() {
_enableSyncUploadAlbum.dispose(); _enableSyncUploadAlbum.dispose();
@@ -65,42 +96,12 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList(); final selectedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.selected).toList();
final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); final excludedBackupAlbums = albums.where((album) => album.backupSelection == BackupSelection.excluded).toList();
// handleSyncAlbumToggle(bool isEnable) async {
// if (isEnable) {
// await ref.read(albumProvider.notifier).refreshRemoteAlbums();
// for (final album in selectedBackupAlbums) {
// await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
// }
// }
// }
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, result) async { canPop: false,
// There is an issue with Flutter where the pop event onPopInvokedWithResult: (didPop, _) async {
// can be triggered multiple times, so we guard it with _hasPopped if (!didPop) {
if (didPop && !_hasPopped) { await _handlePagePopped();
_hasPopped = true; Navigator.of(context).pop();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
if (currentTotalAssetCount != _initialTotalAssetCount) {
final isBackupEnabled = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
if (!isBackupEnabled) {
return;
}
final backupNotifier = ref.read(driftBackupProvider.notifier);
backupNotifier.cancel().then((_) {
backupNotifier.startBackup(currentUser.id);
});
}
} }
}, },
child: Scaffold( child: Scaffold(
@@ -139,103 +140,123 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
], ],
elevation: 0, elevation: 0,
), ),
body: CustomScrollView( body: Stack(
physics: const ClampingScrollPhysics(), children: [
slivers: [ CustomScrollView(
SliverToBoxAdapter( physics: const ClampingScrollPhysics(),
child: Column( slivers: [
crossAxisAlignment: CrossAxisAlignment.start, SliverToBoxAdapter(
children: [ child: Column(
Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), children: [
child: Text( Padding(
"backup_album_selection_page_selection_info", padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
style: context.textTheme.titleSmall, child: Text(
).t(context: context), "backup_album_selection_page_selection_info",
), style: context.textTheme.titleSmall,
).t(context: context),
),
// Selected Album Chips // Selected Album Chips
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap( child: Wrap(
children: [ children: [
_SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums), _SelectedAlbumNameChips(selectedBackupAlbums: selectedBackupAlbums),
_ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums), _ExcludedAlbumNameChips(excludedBackupAlbums: excludedBackupAlbums),
], ],
), ),
), ),
ListTile(
// SettingsSwitchListTile( title: Text(
// valueNotifier: _enableSyncUploadAlbum, "albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}),
// title: "sync_albums".t(context: context), style: context.textTheme.titleSmall,
// subtitle: "sync_upload_album_setting_subtitle".t(context: context), ),
// contentPadding: const EdgeInsets.symmetric(horizontal: 16), subtitle: Padding(
// titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold), padding: const EdgeInsets.symmetric(vertical: 8.0),
// subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary), child: Text(
// onChanged: handleSyncAlbumToggle, "backup_album_selection_page_albums_tap",
// ), style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
ListTile( ).t(context: context),
title: Text( ),
"albums_on_device_count".t(context: context, args: {'count': albumCount.toString()}), trailing: IconButton(
style: context.textTheme.titleSmall, splashRadius: 16,
), icon: Icon(Icons.info, size: 20, color: context.primaryColor),
subtitle: Padding( onPressed: () {
padding: const EdgeInsets.symmetric(vertical: 8.0), showDialog(
child: Text( context: context,
"backup_album_selection_page_albums_tap", builder: (BuildContext context) {
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor), return AlertDialog(
).t(context: context), shape: const RoundedRectangleBorder(
), borderRadius: BorderRadius.all(Radius.circular(10)),
trailing: IconButton( ),
splashRadius: 16, elevation: 5,
icon: Icon(Icons.info, size: 20, color: context.primaryColor), title: Text(
onPressed: () { 'backup_album_selection_page_selection_info',
// show the dialog style: TextStyle(
showDialog( fontSize: 16,
context: context, fontWeight: FontWeight.bold,
builder: (BuildContext context) { color: context.primaryColor,
return AlertDialog( ),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), ).t(context: context),
elevation: 5, content: SingleChildScrollView(
title: Text( child: ListBody(
'backup_album_selection_page_selection_info', children: [
style: TextStyle( const Text(
fontSize: 16, 'backup_album_selection_page_assets_scatter',
fontWeight: FontWeight.bold, style: TextStyle(fontSize: 14),
color: context.primaryColor, ).t(context: context),
), ],
).t(context: context), ),
content: SingleChildScrollView( ),
child: ListBody( );
children: [ },
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(fontSize: 14),
).t(context: context),
],
),
),
); );
}, },
); ),
}, ),
),
),
if (Platform.isAndroid) if (Platform.isAndroid)
_SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums), _SelectAllButton(filteredAlbums: filteredAlbums, selectedBackupAlbums: selectedBackupAlbums),
], ],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
],
),
if (_handleLinkedAlbumFuture != null)
FutureBuilder(
future: _handleLinkedAlbumFuture,
builder: (context, snapshot) {
return SizedBox(
height: double.infinity,
width: double.infinity,
child: Container(
color: context.scaffoldBackgroundColor.withValues(alpha: 0.8),
child: Center(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
const CircularProgressIndicator(strokeWidth: 4),
Text("Creating linked albums...", style: context.textTheme.labelLarge),
],
),
),
),
);
},
), ),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return _AlbumSelectionGrid(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
} else {
return _AlbumSelectionList(filteredAlbums: filteredAlbums, searchQuery: _searchQuery);
}
},
),
], ],
), ),
), ),
@@ -47,10 +47,12 @@ class _DriftMapState extends ConsumerState<DriftMap> {
MapLibreMapController? mapController; MapLibreMapController? mapController;
final _reloadMutex = AsyncMutex(); final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2)); final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
@override @override
void dispose() { void dispose() {
_debouncer.dispose(); _debouncer.dispose();
bottomSheetOffset.dispose();
super.dispose(); super.dispose();
} }
@@ -157,8 +159,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
return Stack( return Stack(
children: [ children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady), _Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
_MyLocationButton(onZoomToLocation: onZoomToLocation), _DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
const MapBottomSheet(), _DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
], ],
); );
} }
@@ -191,21 +193,53 @@ class _Map extends StatelessWidget {
} }
} }
class _MyLocationButton extends StatelessWidget { class _DynamicBottomSheet extends StatefulWidget {
const _MyLocationButton({required this.onZoomToLocation}); final ValueNotifier<double> bottomSheetOffset;
final VoidCallback onZoomToLocation; const _DynamicBottomSheet({required this.bottomSheetOffset});
@override
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
}
class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Positioned( return NotificationListener<DraggableScrollableNotification>(
right: 0, onNotification: (notification) {
bottom: context.padding.bottom + 16, widget.bottomSheetOffset.value = notification.extent;
child: ElevatedButton( return true;
onPressed: onZoomToLocation, },
style: ElevatedButton.styleFrom(shape: const CircleBorder()), child: const MapBottomSheet(),
child: const Icon(Icons.my_location), );
), }
}
class _DynamicMyLocationButton extends StatelessWidget {
const _DynamicMyLocationButton({required this.onZoomToLocation, required this.bottomSheetOffset});
final VoidCallback onZoomToLocation;
final ValueNotifier<double> bottomSheetOffset;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<double>(
valueListenable: bottomSheetOffset,
builder: (context, offset, child) {
return Positioned(
right: 16,
bottom: context.height * (offset - 0.02) + context.padding.bottom,
child: AnimatedOpacity(
opacity: offset < 0.8 ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.my_location),
),
),
);
},
); );
} }
} }
@@ -105,6 +105,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
]).then((_) async { ]).then((_) async {
final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); final isEnableBackup = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup);
final isAlbumLinkedSyncEnable = _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
if (isEnableBackup) { if (isEnableBackup) {
final currentUser = _ref.read(currentUserProvider); final currentUser = _ref.read(currentUserProvider);
if (currentUser == null) { if (currentUser == null) {
@@ -113,6 +115,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); await _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id);
} }
if (isAlbumLinkedSyncEnable) {
await backgroundManager.syncLinkedAlbum();
}
}); });
} catch (e, stackTrace) { } catch (e, stackTrace) {
Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace); Logger("AppLifeCycleNotifier").severe("Error during background sync", e, stackTrace);
+5 -2
View File
@@ -12,7 +12,6 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
// import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
@@ -323,7 +322,11 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
} }
try { try {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList())); unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) {
return _ref.read(backgroundSyncProvider).syncLinkedAlbum();
}),
);
} catch (error) { } catch (error) {
_log.severe("Error processing batched AssetUploadReadyV1 events: $error"); _log.severe("Error processing batched AssetUploadReadyV1 events: $error");
} }
+8 -5
View File
@@ -282,6 +282,8 @@ class UploadService {
return buildUploadTask( return buildUploadTask(
file, file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: originalFileName, originalFileName: originalFileName,
deviceAssetId: asset.id, deviceAssetId: asset.id,
metadata: metadata, metadata: metadata,
@@ -309,6 +311,8 @@ class UploadService {
return buildUploadTask( return buildUploadTask(
file, file,
createdAt: asset.createdAt,
modifiedAt: asset.updatedAt,
originalFileName: asset.name, originalFileName: asset.name,
deviceAssetId: asset.id, deviceAssetId: asset.id,
fields: fields, fields: fields,
@@ -334,6 +338,8 @@ class UploadService {
Future<UploadTask> buildUploadTask( Future<UploadTask> buildUploadTask(
File file, { File file, {
required String group, required String group,
required DateTime createdAt,
required DateTime modifiedAt,
Map<String, String>? fields, Map<String, String>? fields,
String? originalFileName, String? originalFileName,
String? deviceAssetId, String? deviceAssetId,
@@ -347,15 +353,12 @@ class UploadService {
final headers = ApiService.getRequestHeaders(); final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId); final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); final (baseDirectory, directory, filename) = await Task.split(filePath: file.path);
final stats = await file.stat();
final fileCreatedAt = stats.changed;
final fileModifiedAt = stats.modified;
final fieldsMap = { final fieldsMap = {
'filename': originalFileName ?? filename, 'filename': originalFileName ?? filename,
'deviceAssetId': deviceAssetId ?? '', 'deviceAssetId': deviceAssetId ?? '',
'deviceId': deviceId, 'deviceId': deviceId,
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileCreatedAt': createdAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(),
'isFavorite': isFavorite?.toString() ?? 'false', 'isFavorite': isFavorite?.toString() ?? 'false',
'duration': '0', 'duration': '0',
if (fields != null) ...fields, if (fields != null) ...fields,
-31
View File
@@ -1,31 +0,0 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
extension LocalAlbumEntityDataHelper on LocalAlbumEntityData {
LocalAlbum toDto({int assetCount = 0}) {
return LocalAlbum(
id: id,
name: name,
updatedAt: updatedAt,
assetCount: assetCount,
backupSelection: backupSelection,
);
}
}
extension LocalAssetEntityDataHelper on LocalAssetEntityData {
LocalAsset toDto() {
return LocalAsset(
id: id,
name: name,
checksum: checksum,
type: type,
createdAt: createdAt,
updatedAt: updatedAt,
durationInSeconds: durationInSeconds,
isFavorite: isFavorite,
);
}
}
+9 -1
View File
@@ -23,8 +23,10 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -268,11 +270,17 @@ Future<List<void>> runNewSync(WidgetRef ref, {bool full = false}) {
ref.read(backupProvider.notifier).cancelBackup(); ref.read(backupProvider.notifier).cancelBackup();
final backgroundManager = ref.read(backgroundSyncProvider); final backgroundManager = ref.read(backgroundSyncProvider);
final isAlbumLinkedSyncEnable = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
return Future.wait([ return Future.wait([
backgroundManager.syncLocal(full: full).then((_) { backgroundManager.syncLocal(full: full).then((_) {
Logger("runNewSync").fine("Hashing assets after syncLocal"); Logger("runNewSync").fine("Hashing assets after syncLocal");
return backgroundManager.hashAssets(); return backgroundManager.hashAssets();
}), }),
backgroundManager.syncRemote(), backgroundManager.syncRemote().then((_) {
if (isAlbumLinkedSyncEnable) {
return backgroundManager.syncLinkedAlbum();
}
}),
]); ]);
} }
@@ -4,12 +4,9 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DriftAlbumInfoListTile extends HookConsumerWidget { class DriftAlbumInfoListTile extends HookConsumerWidget {
@@ -22,8 +19,6 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
final bool isSelected = album.backupSelection == BackupSelection.selected; final bool isSelected = album.backupSelection == BackupSelection.selected;
final bool isExcluded = album.backupSelection == BackupSelection.excluded; final bool isExcluded = album.backupSelection == BackupSelection.excluded;
final syncAlbum = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
buildTileColor() { buildTileColor() {
if (isSelected) { if (isSelected) {
return context.isDarkTheme ? context.primaryColor.withAlpha(100) : context.primaryColor.withAlpha(25); return context.isDarkTheme ? context.primaryColor.withAlpha(100) : context.primaryColor.withAlpha(25);
@@ -75,9 +70,6 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
ref.read(backupAlbumProvider.notifier).deselectAlbum(album); ref.read(backupAlbumProvider.notifier).deselectAlbum(album);
} else { } else {
ref.read(backupAlbumProvider.notifier).selectAlbum(album); ref.read(backupAlbumProvider.notifier).selectAlbum(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
} }
}, },
leading: buildIcon(), leading: buildIcon(),
@@ -1,19 +1,153 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; 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/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
class DriftBackupSettings extends StatelessWidget { class DriftBackupSettings extends ConsumerWidget {
const DriftBackupSettings({super.key}); const DriftBackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SettingsSubPageScaffold(
settings: [
_UseWifiForUploadVideosButton(),
_UseWifiForUploadPhotosButton(),
Divider(indent: 16, endIndent: 16),
_AlbumSyncActionButton(),
],
);
}
}
class _AlbumSyncActionButton extends ConsumerStatefulWidget {
const _AlbumSyncActionButton();
@override
ConsumerState<_AlbumSyncActionButton> createState() => _AlbumSyncActionButtonState();
}
class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> {
bool isAlbumSyncInProgress = false;
Future<void> _manualSyncAlbums() async {
setState(() {
isAlbumSyncInProgress = true;
});
try {
await ref.read(backgroundSyncProvider).syncLinkedAlbum();
await ref.read(backgroundSyncProvider).syncRemote();
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
setState(() {
isAlbumSyncInProgress = false;
});
});
}
}
Future<void> _manageLinkedAlbums() async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
final localAlbums = ref.read(backupAlbumProvider);
final selectedBackupAlbums = localAlbums
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
await ref.read(syncLinkedAlbumServiceProvider).manageLinkedAlbums(selectedBackupAlbums, currentUser.id);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const SettingsSubPageScaffold(settings: [_UseWifiForUploadVideosButton(), _UseWifiForUploadPhotosButton()]); return ListView(
shrinkWrap: true,
children: [
StreamBuilder(
stream: Store.watch(StoreKey.syncAlbums),
initialData: Store.tryGet(StoreKey.syncAlbums) ?? false,
builder: (context, snapshot) {
final albumSyncEnable = snapshot.data ?? false;
return Column(
children: [
ListTile(
title: Text(
"sync_albums".t(context: context),
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
),
subtitle: Text(
"sync_upload_album_setting_subtitle".t(context: context),
style: context.textTheme.labelLarge,
),
trailing: Switch(
value: albumSyncEnable,
onChanged: (bool newValue) async {
await ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.syncAlbums, newValue);
if (newValue == true) {
await _manageLinkedAlbums();
}
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: albumSyncEnable ? 1.0 : 0.0,
child: albumSyncEnable
? ListTile(
onTap: _manualSyncAlbums,
contentPadding: const EdgeInsets.only(left: 32, right: 16),
title: Text(
"organize_into_albums".t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.normal,
),
),
subtitle: Text(
"organize_into_albums_description".t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
trailing: isAlbumSyncInProgress
? const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
)
: IconButton(
onPressed: _manualSyncAlbums,
icon: const Icon(Icons.sync_rounded),
color: context.colorScheme.onSurface.withValues(alpha: 0.7),
iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
)
: const SizedBox.shrink(),
),
),
],
);
},
),
],
);
} }
} }
@@ -109,6 +109,37 @@ class BetaSyncSettings extends HookConsumerWidget {
await ref.read(storageRepositoryProvider).clearCache(); await ref.read(storageRepositoryProvider).clearCache();
} }
Future<void> resetSqliteDb(BuildContext context, Future<void> Function() resetDatabase) {
return showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(
onPressed: () async {
await resetDatabase();
context.pop();
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
);
},
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
);
},
);
}
return FutureBuilder<List<dynamic>>( return FutureBuilder<List<dynamic>>(
future: loadCounts(), future: loadCounts(),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -116,6 +147,33 @@ class BetaSyncSettings extends HookConsumerWidget {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
if (snapshot.hasError) {
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Text(
"Error occur, reset the local database by tapping the button below",
style: context.textTheme.bodyLarge,
),
),
),
ListTile(
title: Text(
"reset_sqlite".t(context: context),
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async {
await resetSqliteDb(context, resetDatabase);
},
),
],
);
}
final assetCounts = snapshot.data![0]! as (int, int); final assetCounts = snapshot.data![0]! as (int, int);
final localAssetCount = assetCounts.$1; final localAssetCount = assetCounts.$1;
final remoteAssetCount = assetCounts.$2; final remoteAssetCount = assetCounts.$2;
@@ -270,34 +328,7 @@ class BetaSyncSettings extends HookConsumerWidget {
), ),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async { onTap: () async {
showDialog( await resetSqliteDb(context, resetDatabase);
context: context,
builder: (context) {
return AlertDialog(
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(
onPressed: () async {
await resetDatabase();
context.pop();
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
);
},
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
);
},
);
}, },
), ),
], ],
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.140.1 - API version: 1.141.0
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.140.1+3011 version: 1.141.0+3012
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
+4 -1
View File
@@ -11,6 +11,7 @@ import 'schema_v5.dart' as v5;
import 'schema_v6.dart' as v6; import 'schema_v6.dart' as v6;
import 'schema_v7.dart' as v7; import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8; import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -32,10 +33,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v7.DatabaseAtV7(db); return v7.DatabaseAtV7(db);
case 8: case 8:
return v8.DatabaseAtV8(db); return v8.DatabaseAtV8(db);
case 9:
return v9.DatabaseAtV9(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8]; static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9];
} }
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9790,7 +9790,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.140.1", "version": "1.141.0",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.19.0
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.140.1", "version": "1.141.0",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.17.1", "@types/node": "^22.18.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk" "directory": "open-api/typescript-sdk"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.19.0"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.140.1 * 1.141.0
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+553 -1112
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,7 +27,7 @@ onlyBuiltDependencies:
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
overrides: overrides:
canvas: 2.11.2 canvas: 2.11.2
sharp: ^0.34.2 sharp: ^0.34.3
packageExtensions: packageExtensions:
nestjs-kysely: nestjs-kysely:
dependencies: dependencies:
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.19.0
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.140.1", "version": "1.141.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -103,7 +103,7 @@
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0", "sanitize-html": "^2.14.0",
"semver": "^7.6.2", "semver": "^7.6.2",
"sharp": "^0.34.2", "sharp": "^0.34.3",
"sirv": "^3.0.0", "sirv": "^3.0.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0", "tailwindcss-preset-email": "^1.4.0",
@@ -135,7 +135,7 @@
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.13.14", "@types/node": "^22.18.0",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^4.0.0", "@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
@@ -173,9 +173,9 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.19.0"
}, },
"overrides": { "overrides": {
"sharp": "^0.34.2" "sharp": "^0.34.3"
} }
} }
@@ -1,4 +1,6 @@
import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import request from 'supertest'; import request from 'supertest';
@@ -11,7 +13,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
deviceId: 'TEST', deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(), fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(), fileModifiedAt: new Date().toISOString(),
isFavorite: 'testing', isFavorite: 'false',
duration: '0:00:00.000000', duration: '0:00:00.000000',
}; };
@@ -27,16 +29,20 @@ describe(AssetMediaController.name, () => {
let ctx: ControllerContext; let ctx: ControllerContext;
const assetData = Buffer.from('123'); const assetData = Buffer.from('123');
const filename = 'example.png'; const filename = 'example.png';
const service = mockBaseService(AssetMediaService);
beforeAll(async () => { beforeAll(async () => {
ctx = await controllerSetup(AssetMediaController, [ ctx = await controllerSetup(AssetMediaController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: AssetMediaService, useValue: mockBaseService(AssetMediaService) }, { provide: AssetMediaService, useValue: service },
]); ]);
return () => ctx.close(); return () => ctx.close();
}); });
beforeEach(() => { beforeEach(() => {
service.resetAllMocks();
service.uploadAsset.mockResolvedValue({ status: AssetMediaStatus.DUPLICATE, id: factory.uuid() });
ctx.reset(); ctx.reset();
}); });
@@ -46,13 +52,61 @@ describe(AssetMediaController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled(); expect(ctx.authenticate).toHaveBeenCalled();
}); });
it('should accept metadata', async () => {
const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } };
const { status } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([mobileMetadata]),
});
expect(service.uploadAsset).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ metadata: [mobileMetadata] }),
expect.objectContaining({ originalName: 'example.png' }),
undefined,
);
expect(status).toBe(200);
});
it('should handle invalid metadata json', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: 'not-a-string-string',
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
});
it('should validate iCloudId is a string', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]),
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string']));
});
it('should require `deviceAssetId`', async () => { it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.post('/assets') .post('/assets')
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) }); .field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest()); expect(body).toEqual(
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
);
}); });
it('should require `deviceId`', async () => { it('should require `deviceId`', async () => {
@@ -61,7 +115,7 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceId' }) }); .field({ ...makeUploadDto({ omit: 'deviceId' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest()); expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty']));
}); });
it('should require `fileCreatedAt`', async () => { it('should require `fileCreatedAt`', async () => {
@@ -70,25 +124,20 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) }); .field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest()); expect(body).toEqual(
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
);
}); });
it('should require `fileModifiedAt`', async () => { it('should require `fileModifiedAt`', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.post('/assets') .post('/assets')
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileModifiedAt' }) }); .field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest()); expect(body).toEqual(
}); factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']),
);
it('should require `duration`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'duration' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
}); });
it('should throw if `isFavorite` is not a boolean', async () => { it('should throw if `isFavorite` is not a boolean', async () => {
@@ -97,16 +146,18 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' }); .field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest()); expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
}); });
it('should throw if `visibility` is not an enum', async () => { it('should throw if `visibility` is not an enum', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.post('/assets') .post('/assets')
.attach('assetData', assetData, filename) .attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), visibility: 'not-a-boolean' }); .field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest()); expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
);
}); });
// TODO figure out how to deal with `sendFile` // TODO figure out how to deal with `sendFile`
+12 -3
View File
@@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto'; import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
import { AssetVisibility } from 'src/enum'; import { AssetVisibility } from 'src/enum';
@@ -65,10 +66,18 @@ export class AssetMediaCreateDto extends AssetMediaBase {
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true })
livePhotoVideoId?: string; livePhotoVideoId?: string;
@Transform(({ value }) => {
try {
const json = JSON.parse(value);
const items = Array.isArray(json) ? json : [json];
return items.map((item) => plainToInstance(AssetMetadataUpsertItemDto, item));
} catch {
throw new BadRequestException(['metadata must be valid JSON']);
}
})
@Optional() @Optional()
@IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => AssetMetadataUpsertItemDto) @IsArray()
metadata!: AssetMetadataUpsertItemDto[]; metadata!: AssetMetadataUpsertItemDto[];
@ApiProperty({ type: 'string', format: 'binary', required: false }) @ApiProperty({ type: 'string', format: 'binary', required: false })
@@ -141,6 +141,7 @@ export class MediaRepository {
failOn: options.processInvalidImages ? 'none' : 'error', failOn: options.processInvalidImages ? 'none' : 'error',
limitInputPixels: false, limitInputPixels: false,
raw: options.raw, raw: options.raw,
unlimited: true,
}) })
.pipelineColorspace(options.colorspace === Colorspace.Srgb ? 'srgb' : 'rgb16') .pipelineColorspace(options.colorspace === Colorspace.Srgb ? 'srgb' : 'rgb16')
.withIccProfile(options.colorspace); .withIccProfile(options.colorspace);
+11 -2
View File
@@ -835,7 +835,11 @@ export class MetadataService extends BaseService {
} }
} }
private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) { private getDates(
asset: { id: string; originalPath: string; fileCreatedAt: Date },
exifTags: ImmichTags,
stats: Stats,
) {
const result = firstDateTime(exifTags); const result = firstDateTime(exifTags);
const tag = result?.tag; const tag = result?.tag;
const dateTime = result?.dateTime; const dateTime = result?.dateTime;
@@ -864,7 +868,12 @@ export class MetadataService extends BaseService {
if (!localDateTime || !dateTimeOriginal) { if (!localDateTime || !dateTimeOriginal) {
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet // FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet
// birthtime is not available in Docker on macOS, so it appears as 0 // birthtime is not available in Docker on macOS, so it appears as 0
const earliestDate = stats.birthtimeMs ? new Date(Math.min(stats.mtimeMs, stats.birthtimeMs)) : stats.mtime; const earliestDate = new Date(
Math.min(
asset.fileCreatedAt.getTime(),
stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(),
),
);
this.logger.debug( this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
); );
+24 -2
View File
@@ -1,12 +1,16 @@
import { CallHandler, Provider, ValidationPipe } from '@nestjs/common'; import { CallHandler, ExecutionContext, Provider, ValidationPipe } from '@nestjs/common';
import { APP_GUARD, APP_PIPE } from '@nestjs/core'; import { APP_GUARD, APP_PIPE } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { ClassConstructor } from 'class-transformer'; import { ClassConstructor } from 'class-transformer';
import { NextFunction } from 'express';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import multer from 'multer';
import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Readable, Writable } from 'node:stream'; import { Readable, Writable } from 'node:stream';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
import postgres from 'postgres'; import postgres from 'postgres';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { AuthGuard } from 'src/middleware/auth.guard'; import { AuthGuard } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
@@ -82,6 +86,24 @@ export type ControllerContext = {
export const controllerSetup = async (controller: ClassConstructor<unknown>, providers: Provider[]) => { export const controllerSetup = async (controller: ClassConstructor<unknown>, providers: Provider[]) => {
const noopInterceptor = { intercept: (ctx: never, next: CallHandler<unknown>) => next.handle() }; const noopInterceptor = { intercept: (ctx: never, next: CallHandler<unknown>) => next.handle() };
const upload = multer({ storage: multer.memoryStorage() });
const memoryFileInterceptor = {
intercept: async (ctx: ExecutionContext, next: CallHandler<unknown>) => {
const context = ctx.switchToHttp();
const handler = upload.fields([
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
]);
await new Promise<void>((resolve, reject) => {
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
const maybePromise = handler(context.getRequest(), context.getResponse(), next);
Promise.resolve(maybePromise).catch((error) => reject(error));
});
return next.handle();
},
};
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
controllers: [controller], controllers: [controller],
providers: [ providers: [
@@ -93,7 +115,7 @@ export const controllerSetup = async (controller: ClassConstructor<unknown>, pro
], ],
}) })
.overrideInterceptor(FileUploadInterceptor) .overrideInterceptor(FileUploadInterceptor)
.useValue(noopInterceptor) .useValue(memoryFileInterceptor)
.overrideInterceptor(AssetUploadInterceptor) .overrideInterceptor(AssetUploadInterceptor)
.useValue(noopInterceptor) .useValue(noopInterceptor)
.compile(); .compile();
+1 -1
View File
@@ -1 +1 @@
22.18.0 22.19.0
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.140.1", "version": "1.141.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -109,6 +109,6 @@
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.18.0" "node": "22.19.0"
} }
} }