diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 79126fd658..9563be6125 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,8 @@ "immich-server", "redis", "database", - "immich-machine-learning" + "immich-machine-learning", + "init" ], "dockerComposeFile": [ "../docker/docker-compose.dev.yml", diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 869b6c2624..abf34ad68c 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -11,8 +11,22 @@ services: - ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload - /etc/localtime:/etc/localtime:ro + - pnpm-store:/usr/src/app/.pnpm-store + - server-node_modules:/usr/src/app/server/node_modules + - web-node_modules:/usr/src/app/web/node_modules + - github-node_modules:/usr/src/app/.github/node_modules + - cli-node_modules:/usr/src/app/cli/node_modules + - docs-node_modules:/usr/src/app/docs/node_modules + - e2e-node_modules:/usr/src/app/e2e/node_modules + - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app-node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage immich-web: env_file: !reset [] + init: + env_file: !reset [] + command: sh -c 'find /data -maxdepth 1 ! -path "/data/postgres" -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' immich-machine-learning: env_file: !reset [] database: diff --git a/.devcontainer/server/container-start-backend.sh b/.devcontainer/server/container-start-backend.sh index d0176a7d66..35fa60f89b 100755 --- a/.devcontainer/server/container-start-backend.sh +++ b/.devcontainer/server/container-start-backend.sh @@ -11,7 +11,7 @@ run_cmd pnpm --filter immich install log "Starting Nest API Server" log "" cd "${IMMICH_WORKSPACE}/server" || ( - log "Immich workspace not found"jj + log "Immich workspace not found" exit 1 ) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39b4b12b1a..e3d2c9b0dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -569,7 +569,8 @@ jobs: - name: Build the app run: pnpm --filter immich build - name: Run API generation - run: make open-api + run: ./bin/generate-open-api.sh + working-directory: open-api - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 id: verify-changed-files diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index feda217821..0e76dabe66 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -4,34 +4,13 @@ module.exports = { if (!pkg.name) { return pkg; } - switch (pkg.name) { - case "exiftool-vendored": - if (pkg.optionalDependencies["exiftool-vendored.pl"]) { - // make exiftool-vendored.pl a regular dependency - pkg.dependencies["exiftool-vendored.pl"] = - pkg.optionalDependencies["exiftool-vendored.pl"]; - delete pkg.optionalDependencies["exiftool-vendored.pl"]; - } - break; - case "sharp": - const optionalDeps = Object.keys(pkg.optionalDependencies).filter( - (dep) => dep.startsWith("@img") - ); - for (const dep of optionalDeps) { - // remove all optionalDependencies from sharp (they will be compiled from source), except: - // include the precompiled musl version of sharp, for web - // include precompiled linux-x64 version of sharp, for server (stage: web-prod) - // include precompiled linux-arm64 version of sharp, for server (stage: web-prod) - if ( - dep.includes("musl") || - dep.includes("linux-x64") || - dep.includes("linux-arm64") - ) { - continue; - } - delete pkg.optionalDependencies[dep]; - } - break; + if (pkg.name === "exiftool-vendored") { + if (pkg.optionalDependencies["exiftool-vendored.pl"]) { + // make exiftool-vendored.pl a regular dependency + pkg.dependencies["exiftool-vendored.pl"] = + pkg.optionalDependencies["exiftool-vendored.pl"]; + delete pkg.optionalDependencies["exiftool-vendored.pl"]; + } } return pkg; }, diff --git a/Makefile b/Makefile index a9faceadf4..13da918683 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,29 @@ -dev: +dev: prepare-volumes @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans dev-down: docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans -dev-update: +dev-update: prepare-volumes @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans -dev-scale: +dev-scale: prepare-volumes @trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans -dev-docs: +dev-docs: prepare-volumes npm --prefix docs run start .PHONY: e2e -e2e: - @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans +e2e: prepare-volumes + @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans -e2e-update: +e2e-update: prepare-volumes @trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans e2e-down: docker compose -f ./e2e/docker-compose.yml down --remove-orphans -prod: +prod: @trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans prod-down: @@ -33,16 +33,16 @@ prod-scale: @trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans .PHONY: open-api -open-api: +open-api: prepare-volumes cd ./open-api && bash ./bin/generate-open-api.sh -open-api-dart: +open-api-dart: prepare-volumes cd ./open-api && bash ./bin/generate-open-api.sh dart -open-api-typescript: +open-api-typescript: prepare-volumes cd ./open-api && bash ./bin/generate-open-api.sh typescript -sql: +sql: prepare-volumes pnpm --filter immich run sync:sql attach-server: @@ -51,6 +51,47 @@ attach-server: renovate: LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset +# Directories that need to be created for volumes or build output +VOLUME_DIRS = \ + ./.pnpm-store \ + ./web/.svelte-kit \ + ./web/node_modules \ + ./web/coverage \ + ./e2e/node_modules \ + ./docs/node_modules \ + ./server/node_modules \ + ./open-api/typescript-sdk/node_modules \ + ./.github/node_modules \ + ./node_modules \ + ./cli/node_modules + +# Include .env file if it exists +-include docker/.env + +# Helper function to chown, on error suggest remediation and exit +define safe_chown + if chown $(2) $(or $(UID),1000):$(or $(GID),1000) "$(1)" 2>/dev/null; then \ + true; \ + else \ + echo "Permission denied when changing owner of volumes and upload location. Try running 'sudo make prepare-volumes' first."; \ + exit 1; \ + fi; +endef +# create empty directories and chown +prepare-volumes: + @$(foreach dir,$(VOLUME_DIRS),mkdir -p $(dir);) + @$(foreach dir,$(VOLUME_DIRS),$(call safe_chown,$(dir),-R)) +ifneq ($(UPLOAD_LOCATION),) +ifeq ($(filter /%,$(UPLOAD_LOCATION)),) + @mkdir -p "docker/$(UPLOAD_LOCATION)" + @$(call safe_chown,docker/$(UPLOAD_LOCATION),) +else + @mkdir -p "$(UPLOAD_LOCATION)" + @$(call safe_chown,$(UPLOAD_LOCATION),) +endif +endif + + MODULES = e2e server web cli sdk docs .github # directory to package name mapping function @@ -126,8 +167,9 @@ clean: find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' + find . -name "coverage" -type d -prune -exec rm -rf '{}' + find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' + - command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true - command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true + command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true + command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true + setup-server-dev: install-server setup-web-dev: install-sdk build-sdk install-web diff --git a/cli/package.json b/cli/package.json index bfcae8bb8a..b11cebccea 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.82", + "version": "2.2.85", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c6393fd132..372352d12a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -32,6 +32,17 @@ services: - ${UPLOAD_LOCATION}/photos:/data - ${UPLOAD_LOCATION}/photos/upload:/data/upload - /etc/localtime:/etc/localtime:ro + - pnpm-store:/usr/src/app/.pnpm-store + - server-node_modules:/usr/src/app/server/node_modules + - web-node_modules:/usr/src/app/web/node_modules + - github-node_modules:/usr/src/app/.github/node_modules + - cli-node_modules:/usr/src/app/cli/node_modules + - docs-node_modules:/usr/src/app/docs/node_modules + - e2e-node_modules:/usr/src/app/e2e/node_modules + - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app-node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage env_file: - .env environment: @@ -84,6 +95,17 @@ services: - 24678:24678 volumes: - ..:/usr/src/app + - pnpm-store:/usr/src/app/.pnpm-store + - server-node_modules:/usr/src/app/server/node_modules + - web-node_modules:/usr/src/app/web/node_modules + - github-node_modules:/usr/src/app/.github/node_modules + - cli-node_modules:/usr/src/app/cli/node_modules + - docs-node_modules:/usr/src/app/docs/node_modules + - e2e-node_modules:/usr/src/app/e2e/node_modules + - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app-node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage ulimits: nofile: soft: 1048576 @@ -163,13 +185,35 @@ services: init: container_name: init - image: busybox + image: busybox@sha256:ab33eacc8251e3807b85bb6dba570e4698c3998eca6f0fc2ccb60575a563ea74 env_file: - .env user: 0:0 - command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' - + command: sh -c 'find /data -maxdepth 1 -type d -exec chown ${UID:-1000}:${GID:-1000} {} + 2>/dev/null || true; for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done' + volumes: + - pnpm-store:/usr/src/app/.pnpm-store + - server-node_modules:/usr/src/app/server/node_modules + - web-node_modules:/usr/src/app/web/node_modules + - github-node_modules:/usr/src/app/.github/node_modules + - cli-node_modules:/usr/src/app/cli/node_modules + - docs-node_modules:/usr/src/app/docs/node_modules + - e2e-node_modules:/usr/src/app/e2e/node_modules + - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app-node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage volumes: model-cache: prometheus-data: grafana-data: + pnpm-store: + server-node_modules: + web-node_modules: + github-node_modules: + cli-node_modules: + docs-node_modules: + e2e-node_modules: + sdk-node_modules: + app-node_modules: + sveltekit: + coverage: diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index e57039a420..b2f2e85775 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -1,5 +1,31 @@ # FAQ +## Commercial Guidelines + +### Are you open to commercial partnerships and collaborations? + +We are working to commercialize Immich and we'd love for you to help us by making Immich better. FUTO is dedicated to developing sustainable models for developing open source software for our customers. We want our customers to be delighted by the products our engineers deliver, and we want our engineers to be paid when they succeed. + +If you wish to use Immich in a commercial product not owned by FUTO, we have the following requirements: + +- Plugin Integrations: Integrations for other platforms are typically approved, provided proper notification is given. + +- Reseller Partnerships: Must adhere to the guidelines outlined below regarding trademark usage, and proper representation. + +- Strategic Collaborations: We welcome discussions about mutually beneficial partnerships that enhance the value proposition for both organizations. + +### What are your guidelines for resellers and trademark usage? + +For organizations seeking to resell Immich, we have established the following guidelines to protect our brand integrity and ensure proper representation. + +- We request that resellers do not display our trademarks on their websites or marketing materials. If such usage is discovered, we will contact you to request removal. + +- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team. + +- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work. + +When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app + ## User ### How can I reset the admin password? diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 7450ae1b08..55a0ce9469 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -10,7 +10,7 @@ Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobil Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including: -- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) +- [Authentik](https://integrations.goauthentik.io/media/immich/) - [Authelia](https://www.authelia.com/integration/openid-connect/immich/) - [Okta](https://www.okta.com/openid-connect/) - [Google](https://developers.google.com/identity/openid-connect/openid-connect) @@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a ## Auto Launch When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`. -Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich. +Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich. ## Mobile Redirect URI diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index f274ca3c70..e68bcdc272 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -33,7 +33,7 @@ Sometimes, an external library will not scan correctly. This can happen if Immic - Are the permissions set correctly? - Make sure you are using forward slashes (`/`) and not backward slashes. -To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the same in any microservices containers. +To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_server bash` to a bash shell. If your import path is `/mnt/photos`, check it with `ls /mnt/photos`. If you are using a dedicated microservices container, make sure to add the same mount point and check for availability within the microservices container as well. ### Exclusion Patterns diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 5d51f454a1..8988cda910 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,16 @@ [ + { + "label": "v1.140.0", + "url": "https://v1.140.0.archive.immich.app" + }, + { + "label": "v1.139.4", + "url": "https://v1.139.4.archive.immich.app" + }, + { + "label": "v1.139.3", + "url": "https://v1.139.3.archive.immich.app" + }, { "label": "v1.139.2", "url": "https://v1.139.2.archive.immich.app" diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index b923eb0579..983125e4ad 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -38,7 +38,7 @@ services: image: redis:6.2-alpine@sha256:7fe72c486b910f6b1a9769c937dad5d63648ddee82e056f47417542dd40825bb database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:0e763a2383d56f90364fcd72767ac41400cd30d2627f407f7e7960c9f1923c21 + image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:7a4469b9484e37bf2630a60bc2f02f086dae898143b599ecc1c93f619849ef6b command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf environment: POSTGRES_PASSWORD: postgres diff --git a/e2e/package.json b/e2e/package.json index db1e7237a6..559ddf00ee 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.139.2", + "version": "1.140.0", "description": "", "main": "index.js", "type": "module", diff --git a/i18n/en.json b/i18n/en.json index e72607c4ba..c83aac618e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -396,6 +396,8 @@ "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen", + "advanced_settings_readonly_mode_title": "Read-only Mode", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", "advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web", @@ -461,6 +463,7 @@ "app_bar_signout_dialog_title": "Sign out", "app_settings": "App Settings", "appears_in": "Appears in", + "apply_count": "Apply ({count, number})", "archive": "Archive", "archive_action_prompt": "{count} added to Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", @@ -500,7 +503,7 @@ "assets": "Assets", "assets_added_count": "Added {count, plural, one {# asset} other {# assets}}", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", - "assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal} albums", + "assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}", "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album", "assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums", "assets_count": "{count, plural, one {# asset} other {# assets}}", @@ -1073,12 +1076,18 @@ "gcast_enabled": "Google Cast", "gcast_enabled_description": "This feature loads external resources from Google in order to work.", "general": "General", + "geolocation_instruction_all_have_location": "All assets for this date already have location data. Try showing all assets or select a different date", + "geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map", + "geolocation_instruction_no_date": "Select a date to manage location data for photos and videos from that day", + "geolocation_instruction_no_photos": "No photos or videos found for this date. Select a different date to show them", "get_help": "Get Help", "get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "getting_started": "Getting Started", "go_back": "Go back", "go_to_folder": "Go to folder", "go_to_search": "Go to search", + "gps": "GPS", + "gps_missing": "No GPS", "grant_permission": "Grant permission", "group_albums_by": "Group albums by...", "group_country": "Group by country", @@ -1262,6 +1271,7 @@ "main_branch_warning": "You're using a development version; we strongly recommend using a release version!", "main_menu": "Main menu", "make": "Make", + "manage_geolocation": "Manage location", "manage_shared_links": "Manage shared links", "manage_sharing_with_partners": "Manage sharing with partners", "manage_the_app_settings": "Manage the app settings", @@ -1508,6 +1518,7 @@ "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", "profile_drawer_github": "GitHub", + "profile_drawer_readonly_mode": "Read-only mode enabled. Double-tap the user avatar icon to exit.", "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", "profile_image_of_user": "Profile image of {user}", @@ -1553,6 +1564,8 @@ "rating_description": "Display the EXIF rating in the info panel", "reaction_options": "Reaction options", "read_changelog": "Read Changelog", + "readonly_mode_disabled": "Read-only mode disabled", + "readonly_mode_enabled": "Read-only mode enabled", "reassign": "Reassign", "reassigned_assets_to_existing_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to {name, select, null {an existing person} other {{name}}}", "reassigned_assets_to_new_person": "Re-assigned {count, plural, one {# asset} other {# assets}} to a new person", @@ -1722,6 +1735,7 @@ "select_user_for_sharing_page_err_album": "Failed to create album", "selected": "Selected", "selected_count": "{count, plural, other {# selected}}", + "selected_gps_coordinates": "selected gps coordinates", "send_message": "Send message", "send_welcome_email": "Send welcome email", "server_endpoint": "Server Endpoint", @@ -1832,8 +1846,10 @@ "shift_to_permanent_delete": "press ⇧ to permanently delete asset", "show_album_options": "Show album options", "show_albums": "Show albums", + "show_all_assets": "Show all assets", "show_all_people": "Show all people", "show_and_hide_people": "Show & hide people", + "show_assets_without_location": "Show assets without location", "show_file_location": "Show file location", "show_gallery": "Show gallery", "show_hidden_people": "Show hidden people", @@ -1941,7 +1957,9 @@ "to_change_password": "Change password", "to_favorite": "Favorite", "to_login": "Login", + "to_multi_select": "to multi-select", "to_parent": "Go to parent", + "to_select": "to select", "to_trash": "Trash", "toggle_settings": "Toggle settings", "total": "Total", @@ -1991,6 +2009,7 @@ "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "untagged": "Untagged", "up_next": "Up next", + "update_location_action_prompt": "Update the location of {count} selected assets with:", "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", @@ -2015,6 +2034,7 @@ "use_biometric": "Use biometric", "use_current_connection": "use current connection", "use_custom_date_range": "Use custom date range instead", + "use_this_location": "Click to use location", "user": "User", "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 789805255b..35ce9a1f33 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -65,7 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then pnpm install --frozen-lockfile --prefix server pnpm --prefix server run build - make open-api + ( cd ./open-api && bash ./bin/generate-open-api.sh ) jq --arg version "$NEXT_SERVER" '.version = $version' open-api/typescript-sdk/package.json > open-api/typescript-sdk/package.json.tmp && mv open-api/typescript-sdk/package.json.tmp open-api/typescript-sdk/package.json diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 1b0b7170d2..bef051bff2 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -81,6 +81,7 @@ custom_lint: # acceptable exceptions for the time being (until Isar is fully replaced) - lib/providers/app_life_cycle.provider.dart - integration_test/test_utils/general_helper.dart + - lib/domain/services/background_worker.service.dart - lib/main.dart - lib/pages/album/album_asset_selection.page.dart - lib/routing/router.dart diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt index ff806870f9..4237643233 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt @@ -5,15 +5,15 @@ import androidx.work.Configuration import androidx.work.WorkManager class ImmichApp : Application() { - override fun onCreate() { - super.onCreate() - val config = Configuration.Builder().build() - WorkManager.initialize(this, config) - // always start BackupWorker after WorkManager init; this fixes the following bug: - // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. - // Thus, the BackupWorker is not started. If the system kills the process after each initialization - // (because of low memory etc.), the backup is never performed. - // As a workaround, we also run a backup check when initializing the application - ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) - } -} \ No newline at end of file + override fun onCreate() { + super.onCreate() + val config = Configuration.Builder().build() + WorkManager.initialize(this, config) + // always start BackupWorker after WorkManager init; this fixes the following bug: + // After the process is killed (by user or system), the first trigger (taking a new picture) is lost. + // Thus, the BackupWorker is not started. If the system kills the process after each initialization + // (because of low memory etc.), the backup is never performed. + // As a workaround, we also run a backup check when initializing the application + ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index b1a50695a3..a87feddd1a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,8 +1,10 @@ package app.alextran.immich +import android.content.Context import android.os.Build import android.os.ext.SdkExtensions -import androidx.annotation.NonNull +import app.alextran.immich.background.BackgroundWorkerApiImpl +import app.alextran.immich.background.BackgroundWorkerFgHostApi import app.alextran.immich.images.ThumbnailApi import app.alextran.immich.images.ThumbnailsImpl import app.alextran.immich.sync.NativeSyncApi @@ -12,19 +14,26 @@ import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterFragmentActivity() { - override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) - // No need to set up method channel here as it's now handled in the plugin + registerPlugins(this, flutterEngine) + } - val nativeSyncApiImpl = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { - NativeSyncApiImpl26(this) - } else { - NativeSyncApiImpl30(this) - } - NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl) - ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this)) + companion object { + fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { + flutterEngine.plugins.add(BackgroundServicePlugin()) + flutterEngine.plugins.add(HttpSSLOptionsPlugin()) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + val nativeSyncApiImpl = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { + NativeSyncApiImpl26(ctx) + } else { + NativeSyncApiImpl30(ctx) + } + NativeSyncApi.setUp(messenger, nativeSyncApiImpl) + ThumbnailApi.setUp(messenger, ThumbnailsImpl(ctx)) + BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) + } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt new file mode 100644 index 0000000000..39a2345a9b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt @@ -0,0 +1,238 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package app.alextran.immich.background + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +private object BackgroundWorkerPigeonUtils { + + fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") } + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() +private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface BackgroundWorkerFgHostApi { + fun enableSyncWorker() + fun enableUploadWorker(callbackHandle: Long) + fun disableUploadWorker() + + companion object { + /** The codec used by BackgroundWorkerFgHostApi. */ + val codec: MessageCodec by lazy { + BackgroundWorkerPigeonCodec() + } + /** Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.enableSyncWorker() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val callbackHandleArg = args[0] as Long + val wrapped: List = try { + api.enableUploadWorker(callbackHandleArg) + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.disableUploadWorker() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface BackgroundWorkerBgHostApi { + fun onInitialized() + + companion object { + /** The codec used by BackgroundWorkerBgHostApi. */ + val codec: MessageCodec by lazy { + BackgroundWorkerPigeonCodec() + } + /** Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.onInitialized() + listOf(null) + } catch (exception: Throwable) { + BackgroundWorkerPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by BackgroundWorkerFlutterApi. */ + val codec: MessageCodec by lazy { + BackgroundWorkerPigeonCodec() + } + } + fun onLocalSync(maxSecondsArg: Long?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(maxSecondsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onIosUpload(isRefreshArg: Boolean, maxSecondsArg: Long?, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(isRefreshArg, maxSecondsArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onAndroidUpload(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } + fun cancel(callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(null) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(BackgroundWorkerPigeonUtils.createConnectionError(channelName))) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt new file mode 100644 index 0000000000..0ce601b363 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -0,0 +1,162 @@ +package app.alextran.immich.background + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import app.alextran.immich.MainActivity +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import io.flutter.FlutterInjector +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.dart.DartExecutor.DartCallback +import io.flutter.embedding.engine.loader.FlutterLoader +import io.flutter.view.FlutterCallbackInformation + +private const val TAG = "BackgroundWorker" + +enum class BackgroundTaskType { + LOCAL_SYNC, + UPLOAD, +} + +class BackgroundWorker(context: Context, params: WorkerParameters) : + ListenableWorker(context, params), BackgroundWorkerBgHostApi { + private val ctx: Context = context.applicationContext + + /// The Flutter loader that loads the native Flutter library and resources. + /// This must be initialized before starting the Flutter engine. + private var loader: FlutterLoader = FlutterInjector.instance().flutterLoader() + + /// The Flutter engine created specifically for background execution. + /// This is a separate instance from the main Flutter engine that handles the UI. + /// It operates in its own isolate and doesn't share memory with the main engine. + /// Must be properly started, registered, and torn down during background execution. + private var engine: FlutterEngine? = null + + // Used to call methods on the flutter side + private var flutterApi: BackgroundWorkerFlutterApi? = null + + /// Result returned when the background task completes. This is used to signal + /// to the WorkManager that the task has finished, either successfully or with failure. + private val completionHandler: SettableFuture = SettableFuture.create() + + /// Flag to track whether the background task has completed to prevent duplicate completions + private var isComplete = false + + init { + if (!loader.initialized()) { + loader.startInitialization(ctx) + } + } + + override fun startWork(): ListenableFuture { + Log.i(TAG, "Starting background upload worker") + + loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + engine = FlutterEngine(ctx) + + // Retrieve the callback handle stored by the main Flutter app + // This handle points to the Flutter function that should be executed in the background + val callbackHandle = + ctx.getSharedPreferences(BackgroundWorkerApiImpl.SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getLong(BackgroundWorkerApiImpl.SHARED_PREF_CALLBACK_HANDLE, 0L) + + if (callbackHandle == 0L) { + // Without a valid callback handle, we cannot start the Flutter background execution + complete(Result.failure()) + return@ensureInitializationCompleteAsync + } + + // Start the Flutter engine with the specified callback as the entry point + val callback = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) + if (callback == null) { + complete(Result.failure()) + return@ensureInitializationCompleteAsync + } + + // Register custom plugins + MainActivity.registerPlugins(ctx, engine!!) + flutterApi = + BackgroundWorkerFlutterApi(binaryMessenger = engine!!.dartExecutor.binaryMessenger) + BackgroundWorkerBgHostApi.setUp( + binaryMessenger = engine!!.dartExecutor.binaryMessenger, + api = this + ) + + engine!!.dartExecutor.executeDartCallback( + DartCallback(ctx.assets, loader.findAppBundlePath(), callback) + ) + } + + return completionHandler + } + + /** + * Called by the Flutter side when it has finished initialization and is ready to receive commands. + * Routes the appropriate task type (refresh or processing) to the corresponding Flutter method. + * This method acts as a bridge between the native Android background task system and Flutter. + */ + override fun onInitialized() { + val taskTypeIndex = inputData.getInt(BackgroundWorkerApiImpl.WORKER_DATA_TASK_TYPE, 0) + val taskType = BackgroundTaskType.entries[taskTypeIndex] + + when (taskType) { + BackgroundTaskType.LOCAL_SYNC -> flutterApi?.onLocalSync(null) { handleHostResult(it) } + BackgroundTaskType.UPLOAD -> flutterApi?.onAndroidUpload { handleHostResult(it) } + } + } + + /** + * Called when the system has to stop this worker because constraints are + * no longer met or the system needs resources for more important tasks + * This is also called when the worker has been explicitly cancelled or replaced + */ + override fun onStopped() { + Log.d(TAG, "About to stop BackupWorker") + + if (isComplete) { + return + } + + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + if (flutterApi != null) { + flutterApi?.cancel { + complete(Result.failure()) + } + } + } + + Handler(Looper.getMainLooper()).postDelayed({ + complete(Result.failure()) + }, 5000) + } + + private fun handleHostResult(result: kotlin.Result) { + if (isComplete) { + return + } + + result.fold( + onSuccess = { _ -> complete(Result.success()) }, + onFailure = { _ -> onStopped() } + ) + } + + /** + * Cleans up resources by destroying the Flutter engine context and invokes the completion handler. + * This method ensures that the background task is marked as complete, releases the Flutter engine, + * and notifies the caller of the task's success or failure. This is the final step in the + * background task lifecycle and should only be called once per task instance. + * + * - Parameter success: Indicates whether the background task completed successfully + */ + private fun complete(success: Result) { + isComplete = true + engine?.destroy() + flutterApi = null + completionHandler.set(success) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt new file mode 100644 index 0000000000..7a3226f961 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerApiImpl.kt @@ -0,0 +1,92 @@ +package app.alextran.immich.background + +import android.content.Context +import android.provider.MediaStore +import android.util.Log +import androidx.core.content.edit +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +private const val TAG = "BackgroundUploadImpl" + +class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi { + private val ctx: Context = context.applicationContext + override fun enableSyncWorker() { + enqueueMediaObserver(ctx) + Log.i(TAG, "Scheduled media observer") + } + + override fun enableUploadWorker(callbackHandle: Long) { + updateUploadEnabled(ctx, true) + updateCallbackHandle(ctx, callbackHandle) + Log.i(TAG, "Scheduled background upload tasks") + } + + override fun disableUploadWorker() { + updateUploadEnabled(ctx, false) + WorkManager.getInstance(ctx).cancelUniqueWork(BACKGROUND_WORKER_NAME) + Log.i(TAG, "Cancelled background upload tasks") + } + + companion object { + private const val BACKGROUND_WORKER_NAME = "immich/BackgroundWorkerV1" + private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1" + + const val WORKER_DATA_TASK_TYPE = "taskType" + + const val SHARED_PREF_NAME = "Immich::Background" + const val SHARED_PREF_BACKUP_ENABLED = "Background::backup::enabled" + const val SHARED_PREF_CALLBACK_HANDLE = "Background::backup::callbackHandle" + + private fun updateUploadEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit { + putBoolean(SHARED_PREF_BACKUP_ENABLED, enabled) + } + } + + private fun updateCallbackHandle(context: Context, callbackHandle: Long) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit { + putLong(SHARED_PREF_CALLBACK_HANDLE, callbackHandle) + } + } + + fun enqueueMediaObserver(ctx: Context) { + val constraints = Constraints.Builder() + .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) + .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) + .setTriggerContentUpdateDelay(5, TimeUnit.SECONDS) + .setTriggerContentMaxDelay(1, TimeUnit.MINUTES) + .build() + + val work = OneTimeWorkRequest.Builder(MediaObserver::class.java) + .setConstraints(constraints) + .build() + WorkManager.getInstance(ctx) + .enqueueUniqueWork(OBSERVER_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) + + Log.i(TAG, "Enqueued media observer worker with name: $OBSERVER_WORKER_NAME") + } + + fun enqueueBackgroundWorker(ctx: Context, taskType: BackgroundTaskType) { + val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() + + val data = Data.Builder() + data.putInt(WORKER_DATA_TASK_TYPE, taskType.ordinal) + val work = OneTimeWorkRequest.Builder(BackgroundWorker::class.java) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .setInputData(data.build()).build() + WorkManager.getInstance(ctx) + .enqueueUniqueWork(BACKGROUND_WORKER_NAME, ExistingWorkPolicy.REPLACE, work) + + Log.i(TAG, "Enqueued background worker with name: $BACKGROUND_WORKER_NAME") + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/MediaObserver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/MediaObserver.kt new file mode 100644 index 0000000000..0ec6eeb3a5 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/MediaObserver.kt @@ -0,0 +1,34 @@ +package app.alextran.immich.background + +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters + +class MediaObserver(context: Context, params: WorkerParameters) : Worker(context, params) { + private val ctx: Context = context.applicationContext + + override fun doWork(): Result { + Log.i("MediaObserver", "Content change detected, starting background worker") + + // Enqueue backup worker only if there are new media changes + if (triggeredContentUris.isNotEmpty()) { + val type = + if (isBackupEnabled(ctx)) BackgroundTaskType.UPLOAD else BackgroundTaskType.LOCAL_SYNC + BackgroundWorkerApiImpl.enqueueBackgroundWorker(ctx, type) + } + + // Re-enqueue itself to listen for future changes + BackgroundWorkerApiImpl.enqueueMediaObserver(ctx) + return Result.success() + } + + private fun isBackupEnabled(context: Context): Boolean { + val prefs = + context.getSharedPreferences( + BackgroundWorkerApiImpl.SHARED_PREF_NAME, + Context.MODE_PRIVATE + ) + return prefs.getBoolean(BackgroundWorkerApiImpl.SHARED_PREF_BACKUP_ENABLED, false) + } +} diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 21431e33ac..dee51026d5 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3007, - "android.injected.version.name" => "1.139.2", + "android.injected.version.code" => 3010, + "android.injected.version.name" => "1.140.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') diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index cbd76a0bf9..087297ab71 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -16,6 +16,9 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; }; + B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; }; + B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; }; D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; }; F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; }; @@ -92,6 +95,9 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; + B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = ""; }; + B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = ""; }; + B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = ""; }; E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -235,6 +241,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + B21E34A62E5AF9760031FDB9 /* Background */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, FA9973382CF6DF4B000EF859 /* Runner.entitlements */, 65DD438629917FAD0047FFA8 /* BackgroundSync */, @@ -252,6 +259,16 @@ path = Runner; sourceTree = ""; }; + B21E34A62E5AF9760031FDB9 /* Background */ = { + isa = PBXGroup; + children = ( + B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */, + B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */, + B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */, + ); + path = Background; + sourceTree = ""; + }; FAC6F8B62D287F120078CB2F /* ShareExtension */ = { isa = PBXGroup; children = ( @@ -488,10 +505,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -520,10 +541,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -538,10 +563,13 @@ files = ( 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */, + B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -667,7 +695,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -811,7 +839,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -841,7 +869,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -875,7 +903,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -918,7 +946,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -958,7 +986,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -997,7 +1025,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1041,7 +1069,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1082,7 +1110,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 218; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index dedda5bd12..04422eb2b4 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -19,13 +19,12 @@ import UIKit } GeneratedPluginRegistrant.register(with: self) - BackgroundServicePlugin.registerBackgroundProcessing() - - BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) - let controller: FlutterViewController = window?.rootViewController as! FlutterViewController - NativeSyncApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: NativeSyncApiImpl()) - ThumbnailApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: ThumbnailApiImpl()) + AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger) + BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) + + BackgroundServicePlugin.registerBackgroundProcessing() + BackgroundWorkerApiImpl.registerBackgroundProcessing() BackgroundServicePlugin.setPluginRegistrantCallback { registry in if !registry.hasPlugin("org.cocoapods.path-provider-foundation") { @@ -51,4 +50,10 @@ import UIKit return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) { + NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl()) + ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl()) + BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl()) + } } diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift new file mode 100644 index 0000000000..e9513db8da --- /dev/null +++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift @@ -0,0 +1,245 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +private class BackgroundWorkerPigeonCodecReader: FlutterStandardReader { +} + +private class BackgroundWorkerPigeonCodecWriter: FlutterStandardWriter { +} + +private class BackgroundWorkerPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return BackgroundWorkerPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return BackgroundWorkerPigeonCodecWriter(data: data) + } +} + +class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol BackgroundWorkerFgHostApi { + func enableSyncWorker() throws + func enableUploadWorker(callbackHandle: Int64) throws + func disableUploadWorker() throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class BackgroundWorkerFgHostApiSetup { + static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared } + /// Sets up an instance of `BackgroundWorkerFgHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerFgHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let enableSyncWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + enableSyncWorkerChannel.setMessageHandler { _, reply in + do { + try api.enableSyncWorker() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + enableSyncWorkerChannel.setMessageHandler(nil) + } + let enableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + enableUploadWorkerChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let callbackHandleArg = args[0] as! Int64 + do { + try api.enableUploadWorker(callbackHandle: callbackHandleArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + enableUploadWorkerChannel.setMessageHandler(nil) + } + let disableUploadWorkerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disableUploadWorkerChannel.setMessageHandler { _, reply in + do { + try api.disableUploadWorker() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + disableUploadWorkerChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol BackgroundWorkerBgHostApi { + func onInitialized() throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class BackgroundWorkerBgHostApiSetup { + static var codec: FlutterStandardMessageCodec { BackgroundWorkerPigeonCodec.shared } + /// Sets up an instance of `BackgroundWorkerBgHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: BackgroundWorkerBgHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let onInitializedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + onInitializedChannel.setMessageHandler { _, reply in + do { + try api.onInitialized() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + onInitializedChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol BackgroundWorkerFlutterApiProtocol { + func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) + func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) + func onAndroidUpload(completion: @escaping (Result) -> Void) + func cancel(completion: @escaping (Result) -> Void) +} +class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: BackgroundWorkerPigeonCodec { + return BackgroundWorkerPigeonCodec.shared + } + func onLocalSync(maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([maxSecondsArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([isRefreshArg, maxSecondsArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func onAndroidUpload(completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage(nil) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func cancel(completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage(nil) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } +} diff --git a/mobile/ios/Runner/Background/BackgroundWorker.swift b/mobile/ios/Runner/Background/BackgroundWorker.swift new file mode 100644 index 0000000000..db849d942b --- /dev/null +++ b/mobile/ios/Runner/Background/BackgroundWorker.swift @@ -0,0 +1,202 @@ +import BackgroundTasks +import Flutter + +enum BackgroundTaskType { case localSync, refreshUpload, processingUpload } + +/* + * DEBUG: Testing Background Tasks in Xcode + * + * To test background task functionality during development: + * 1. Pause the application in Xcode debugger + * 2. In the debugger console, enter one of the following commands: + + ## For local sync (short-running sync): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.localSync"] + + ## For background refresh (short-running sync): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"] + + ## For background processing (long-running upload): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"] + + * To simulate task expiration (useful for testing expiration handlers): + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.localSync"] + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.refreshUpload"] + + e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateExpirationForTaskWithIdentifier:@"app.alextran.immich.background.processingUpload"] + + * 3. Resume the application to see the background code execute + * + * NOTE: This must be tested on a physical device, not in the simulator. + * In testing, only the background processing task can be reliably simulated. + * These commands submit the respective task to BGTaskScheduler for immediate processing. + * Use the expiration commands to test how the app handles iOS terminating background tasks. + */ + + +/// The background worker which creates a new Flutter VM, communicates with it +/// to run the backup job, and then finishes execution and calls back to its callback handler. +/// This class manages a separate Flutter engine instance for background execution, +/// independent of the main UI Flutter engine. +class BackgroundWorker: BackgroundWorkerBgHostApi { + private let taskType: BackgroundTaskType + /// The maximum number of seconds to run the task before timing out + private let maxSeconds: Int? + /// Callback function to invoke when the background task completes + private let completionHandler: (_ success: Bool) -> Void + + /// The Flutter engine created specifically for background execution. + /// This is a separate instance from the main Flutter engine that handles the UI. + /// It operates in its own isolate and doesn't share memory with the main engine. + /// Must be properly started, registered, and torn down during background execution. + private let engine = FlutterEngine(name: "BackgroundImmich") + + /// Used to call methods on the flutter side + private var flutterApi: BackgroundWorkerFlutterApi? + + /// Flag to track whether the background task has completed to prevent duplicate completions + private var isComplete = false + + /** + * Initializes a new background worker with the specified task type and execution constraints. + * Creates a new Flutter engine instance for background execution and sets up the necessary + * communication channels between native iOS and Flutter code. + * + * - Parameters: + * - taskType: The type of background task to execute (upload or sync task) + * - maxSeconds: Optional maximum execution time in seconds before the task is cancelled + * - completionHandler: Callback function invoked when the task completes, with success status + */ + init(taskType: BackgroundTaskType, maxSeconds: Int?, completionHandler: @escaping (_ success: Bool) -> Void) { + self.taskType = taskType + self.maxSeconds = maxSeconds + self.completionHandler = completionHandler + // Should be initialized only after the engine starts running + self.flutterApi = nil + } + + /** + * Starts the background Flutter engine and begins execution of the background task. + * Retrieves the callback handle from UserDefaults, looks up the Flutter callback, + * starts the engine, and sets up a timeout timer if specified. + */ + func run() { + // Retrieve the callback handle stored by the main Flutter app + // This handle points to the Flutter function that should be executed in the background + let callbackHandle = Int64(UserDefaults.standard.string( + forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) ?? "0") ?? 0 + + if callbackHandle == 0 { + // Without a valid callback handle, we cannot start the Flutter background execution + complete(success: false) + return + } + + // Use the callback handle to retrieve the actual Flutter callback information + guard let callback = FlutterCallbackCache.lookupCallbackInformation(callbackHandle) else { + // The callback handle is invalid or the callback was not found + complete(success: false) + return + } + + // Start the Flutter engine with the specified callback as the entry point + let isRunning = engine.run( + withEntrypoint: callback.callbackName, + libraryURI: callback.callbackLibraryPath + ) + + // Verify that the Flutter engine started successfully + if !isRunning { + complete(success: false) + return + } + + // Register plugins in the new engine + GeneratedPluginRegistrant.register(with: engine) + // Register custom plugins + AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger) + flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger) + BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self) + + // Set up a timeout timer if maxSeconds was specified to prevent runaway background tasks + if maxSeconds != nil { + // Schedule a timer to cancel the task after the specified timeout period + Timer.scheduledTimer(withTimeInterval: TimeInterval(maxSeconds!), repeats: false) { _ in + self.cancel() + } + } + } + + /** + * Called by the Flutter side when it has finished initialization and is ready to receive commands. + * Routes the appropriate task type (refresh or processing) to the corresponding Flutter method. + * This method acts as a bridge between the native iOS background task system and Flutter. + */ + func onInitialized() throws { + switch self.taskType { + case .refreshUpload, .processingUpload: + flutterApi?.onIosUpload(isRefresh: self.taskType == .refreshUpload, + maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in + self.handleHostResult(result: result) + }) + case .localSync: + flutterApi?.onLocalSync(maxSeconds: maxSeconds.map { Int64($0) }, completion: { result in + self.handleHostResult(result: result) + }) + } + } + + /** + * Cancels the currently running background task, either due to timeout or external request. + * Sends a cancel signal to the Flutter side and sets up a fallback timer to ensure + * the completion handler is eventually called even if Flutter doesn't respond. + */ + func cancel() { + if isComplete { + return + } + + isComplete = true + flutterApi?.cancel { result in + self.complete(success: false) + } + + // Fallback safety mechanism: ensure completion is called within 2 seconds + // This prevents the background task from hanging indefinitely if Flutter doesn't respond + Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in + self.complete(success: false) + } + } + + /** + * Handles the result from Flutter API calls and determines the success/failure status. + * Converts Flutter's Result type to a simple boolean success indicator for task completion. + * + * - Parameter result: The result returned from a Flutter API call + */ + private func handleHostResult(result: Result) { + switch result { + case .success(): self.complete(success: true) + case .failure(_): self.cancel() + } + } + + /** + * Cleans up resources by destroying the Flutter engine context and invokes the completion handler. + * This method ensures that the background task is marked as complete, releases the Flutter engine, + * and notifies the caller of the task's success or failure. This is the final step in the + * background task lifecycle and should only be called once per task instance. + * + * - Parameter success: Indicates whether the background task completed successfully + */ + private func complete(success: Bool) { + isComplete = true + engine.destroyContext() + completionHandler(success) + } +} diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift new file mode 100644 index 0000000000..f36085de0b --- /dev/null +++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift @@ -0,0 +1,155 @@ +import BackgroundTasks + +class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { + func enableSyncWorker() throws { + BackgroundWorkerApiImpl.scheduleLocalSync() + print("BackgroundUploadImpl:enableSyncWorker Local Sync worker scheduled") + } + + func enableUploadWorker(callbackHandle: Int64) throws { + BackgroundWorkerApiImpl.updateUploadEnabled(true) + // Store the callback handle for later use when starting background Flutter isolates + BackgroundWorkerApiImpl.updateUploadCallbackHandle(callbackHandle) + + BackgroundWorkerApiImpl.scheduleRefreshUpload() + BackgroundWorkerApiImpl.scheduleProcessingUpload() + print("BackgroundUploadImpl:enableUploadWorker Scheduled background upload tasks") + } + + func disableUploadWorker() throws { + BackgroundWorkerApiImpl.updateUploadEnabled(false) + BackgroundWorkerApiImpl.cancelUploadTasks() + print("BackgroundUploadImpl:disableUploadWorker Disabled background upload tasks") + } + + public static let backgroundUploadEnabledKey = "immich:background:backup:enabled" + public static let backgroundUploadCallbackHandleKey = "immich:background:backup:callbackHandle" + + private static let localSyncTaskID = "app.alextran.immich.background.localSync" + private static let refreshUploadTaskID = "app.alextran.immich.background.refreshUpload" + private static let processingUploadTaskID = "app.alextran.immich.background.processingUpload" + + private static func updateUploadEnabled(_ isEnabled: Bool) { + return UserDefaults.standard.set(isEnabled, forKey: BackgroundWorkerApiImpl.backgroundUploadEnabledKey) + } + + private static func updateUploadCallbackHandle(_ callbackHandle: Int64) { + return UserDefaults.standard.set(String(callbackHandle), forKey: BackgroundWorkerApiImpl.backgroundUploadCallbackHandleKey) + } + + private static func cancelUploadTasks() { + BackgroundWorkerApiImpl.updateUploadEnabled(false) + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: refreshUploadTaskID); + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: processingUploadTaskID); + } + + public static func registerBackgroundProcessing() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: processingUploadTaskID, using: nil) { task in + if task is BGProcessingTask { + handleBackgroundProcessing(task: task as! BGProcessingTask) + } + } + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: refreshUploadTaskID, using: nil) { task in + if task is BGAppRefreshTask { + handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .refreshUpload) + } + } + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: localSyncTaskID, using: nil) { task in + if task is BGAppRefreshTask { + handleBackgroundRefresh(task: task as! BGAppRefreshTask, taskType: .localSync) + } + } + } + + private static func scheduleLocalSync() { + let backgroundRefresh = BGAppRefreshTaskRequest(identifier: localSyncTaskID) + backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins + + do { + try BGTaskScheduler.shared.submit(backgroundRefresh) + } catch { + print("Could not schedule the local sync task \(error.localizedDescription)") + } + } + + private static func scheduleRefreshUpload() { + let backgroundRefresh = BGAppRefreshTaskRequest(identifier: refreshUploadTaskID) + backgroundRefresh.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // 5 mins + + do { + try BGTaskScheduler.shared.submit(backgroundRefresh) + } catch { + print("Could not schedule the refresh upload task \(error.localizedDescription)") + } + } + + private static func scheduleProcessingUpload() { + let backgroundProcessing = BGProcessingTaskRequest(identifier: processingUploadTaskID) + + backgroundProcessing.requiresNetworkConnectivity = true + backgroundProcessing.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 mins + + do { + try BGTaskScheduler.shared.submit(backgroundProcessing) + } catch { + print("Could not schedule the processing upload task \(error.localizedDescription)") + } + } + + private static func handleBackgroundRefresh(task: BGAppRefreshTask, taskType: BackgroundTaskType) { + scheduleRefreshUpload() + // Restrict the refresh task to run only for a maximum of 20 seconds + runBackgroundWorker(task: task, taskType: taskType, maxSeconds: 20) + } + + private static func handleBackgroundProcessing(task: BGProcessingTask) { + scheduleProcessingUpload() + // There are no restrictions for processing tasks. Although, the OS could signal expiration at any time + runBackgroundWorker(task: task, taskType: .processingUpload, maxSeconds: nil) + } + + /** + * Executes the background worker within the context of a background task. + * This method creates a BackgroundWorker, sets up task expiration handling, + * and manages the synchronization between the background task and the Flutter engine. + * + * - Parameters: + * - task: The iOS background task that provides the execution context + * - taskType: The type of background operation to perform (refresh or processing) + * - maxSeconds: Optional timeout for the operation in seconds + */ + private static func runBackgroundWorker(task: BGTask, taskType: BackgroundTaskType, maxSeconds: Int?) { + let semaphore = DispatchSemaphore(value: 0) + var isSuccess = true + + let backgroundWorker = BackgroundWorker(taskType: taskType, maxSeconds: maxSeconds) { success in + isSuccess = success + semaphore.signal() + } + + task.expirationHandler = { + DispatchQueue.main.async { + backgroundWorker.cancel() + } + isSuccess = false + + // Schedule a timer to signal the semaphore after 2 seconds + Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in + semaphore.signal() + } + } + + DispatchQueue.main.async { + backgroundWorker.run() + } + + semaphore.wait() + task.setTaskCompleted(success: isSuccess) + print("Background task completed with success: \(isSuccess)") + } +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 31ad10c35f..1a3658ed16 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -6,6 +6,9 @@ $(CUSTOM_GROUP_ID) BGTaskSchedulerPermittedIdentifiers + app.alextran.immich.background.localSync + app.alextran.immich.background.refreshUpload + app.alextran.immich.background.processingUpload app.alextran.immich.backgroundFetch app.alextran.immich.backgroundProcessing @@ -78,7 +81,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.138.1 + 1.139.3 CFBundleSignature ???? CFBundleURLTypes @@ -105,7 +108,7 @@ CFBundleVersion - 215 + 217 FLTEnableImpeller ITSAppUsesNonExemptEncryption @@ -134,6 +137,9 @@ We need to access the camera to let you take beautiful video using this app NSFaceIDUsageDescription We need to use FaceID to allow access to your locked folder + NSLocalNetworkUsageDescription + We need local network permission to connect to the local server using IP address and + allow the casting feature to work NSLocationAlwaysAndWhenInUseUsageDescription We require this permission to access the local WiFi name for background upload mechanism NSLocationUsageDescription @@ -180,8 +186,5 @@ io.flutter.embedded_views_preview - NSLocalNetworkUsageDescription - We need local network permission to connect to the local server using IP address and - allow the casting feature to work - + \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index d5841b5da2..85b3c451d7 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -22,7 +22,7 @@ platform :ios do path: "./Runner.xcodeproj", ) increment_version_number( - version_number: "1.139.2" + version_number: "1.140.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e4e316b814..6dcd81774a 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -67,6 +67,9 @@ enum StoreKey { loadOriginalVideo._(136), manageLocalMediaAndroid._(137), + // Read-only Mode settings + readonlyModeEnabled._(138), + // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index b0a66f7d70..aaf6d81934 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -75,6 +75,8 @@ profileChangedAt: $profileChangedAt bool? isPartnerSharedWith, bool? hasProfileImage, DateTime? profileChangedAt, + int? quotaSizeInBytes, + int? quotaUsageInBytes, }) => UserDto( id: id ?? this.id, email: email ?? this.email, @@ -88,6 +90,8 @@ profileChangedAt: $profileChangedAt isPartnerSharedWith: isPartnerSharedWith ?? this.isPartnerSharedWith, hasProfileImage: hasProfileImage ?? this.hasProfileImage, profileChangedAt: profileChangedAt ?? this.profileChangedAt, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, ); @override @@ -105,7 +109,9 @@ profileChangedAt: $profileChangedAt other.memoryEnabled == memoryEnabled && other.inTimeline == inTimeline && other.hasProfileImage == hasProfileImage && - other.profileChangedAt.isAtSameMomentAs(profileChangedAt); + other.profileChangedAt.isAtSameMomentAs(profileChangedAt) && + other.quotaSizeInBytes == quotaSizeInBytes && + other.quotaUsageInBytes == quotaUsageInBytes; } @override @@ -121,7 +127,9 @@ profileChangedAt: $profileChangedAt isPartnerSharedBy.hashCode ^ isPartnerSharedWith.hashCode ^ hasProfileImage.hashCode ^ - profileChangedAt.hashCode; + profileChangedAt.hashCode ^ + quotaSizeInBytes.hashCode ^ + quotaUsageInBytes.hashCode; } class PartnerUserDto { diff --git a/mobile/lib/domain/models/user_metadata.model.dart b/mobile/lib/domain/models/user_metadata.model.dart index 1c371a9d3e..c477be1a41 100644 --- a/mobile/lib/domain/models/user_metadata.model.dart +++ b/mobile/lib/domain/models/user_metadata.model.dart @@ -74,7 +74,6 @@ isOnboarded: $isOnboarded, int get hashCode => isOnboarded.hashCode; } -// TODO: wait to be overwritten class Preferences { final bool foldersEnabled; final bool memoriesEnabled; @@ -133,17 +132,17 @@ class Preferences { factory Preferences.fromMap(Map map) { return Preferences( - foldersEnabled: map["folders-Enabled"] as bool? ?? false, - memoriesEnabled: map["memories-Enabled"] as bool? ?? true, - peopleEnabled: map["people-Enabled"] as bool? ?? true, - ratingsEnabled: map["ratings-Enabled"] as bool? ?? false, - sharedLinksEnabled: map["sharedLinks-Enabled"] as bool? ?? true, - tagsEnabled: map["tags-Enabled"] as bool? ?? false, + foldersEnabled: (map["folders"] as Map?)?["enabled"] as bool? ?? false, + memoriesEnabled: (map["memories"] as Map?)?["enabled"] as bool? ?? true, + peopleEnabled: (map["people"] as Map?)?["enabled"] as bool? ?? true, + ratingsEnabled: (map["ratings"] as Map?)?["enabled"] as bool? ?? false, + sharedLinksEnabled: (map["sharedLinks"] as Map?)?["enabled"] as bool? ?? true, + tagsEnabled: (map["tags"] as Map?)?["enabled"] as bool? ?? false, userAvatarColor: AvatarColor.values.firstWhere( - (e) => e.value == map["avatar-Color"] as String?, + (e) => e.value == (map["avatar"] as Map?)?["color"] as String?, orElse: () => AvatarColor.primary, ), - showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true, + showSupportBadge: (map["purchase"] as Map?)?["showSupportBadge"] as bool? ?? true, ); } @@ -213,7 +212,7 @@ class License { factory License.fromMap(Map map) { return License( - activatedAt: map["activatedAt"] as DateTime, + activatedAt: DateTime.parse(map["activatedAt"] as String), activationKey: map["activationKey"] as String, licenseKey: map["licenseKey"] as String, ); diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index c8cc61314e..df34a41e54 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -17,9 +17,14 @@ class AssetService { _localAssetRepository = localAssetRepository, _platform = const LocalPlatform(); + Future getAsset(BaseAsset asset) { + final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; + return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id); + } + Stream watchAsset(BaseAsset asset) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id; - return asset is LocalAsset ? _localAssetRepository.watchAsset(id) : _remoteAssetRepository.watchAsset(id); + return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id); } Future getRemoteAsset(String id) { diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart new file mode 100644 index 0000000000..33c58cf743 --- /dev/null +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.dart'; +import 'package:immich_mobile/platform/background_worker_api.g.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/localization.service.dart'; +import 'package:immich_mobile/services/upload.service.dart'; +import 'package:immich_mobile/utils/bootstrap.dart'; +import 'package:immich_mobile/utils/http_ssl_options.dart'; +import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; + +class BackgroundWorkerFgService { + final BackgroundWorkerFgHostApi _foregroundHostApi; + + const BackgroundWorkerFgService(this._foregroundHostApi); + + // TODO: Move this call to native side once old timeline is removed + Future enableSyncService() => _foregroundHostApi.enableSyncWorker(); + + Future enableUploadService() => _foregroundHostApi.enableUploadWorker( + PluginUtilities.getCallbackHandle(_backgroundSyncNativeEntrypoint)!.toRawHandle(), + ); + + Future disableUploadService() => _foregroundHostApi.disableUploadWorker(); +} + +class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { + late final ProviderContainer _ref; + final Isar _isar; + final Drift _drift; + final DriftLogger _driftLogger; + final BackgroundWorkerBgHostApi _backgroundHostApi; + final Logger _logger = Logger('BackgroundUploadBgService'); + + bool _isCleanedUp = false; + + BackgroundWorkerBgService({required Isar isar, required Drift drift, required DriftLogger driftLogger}) + : _isar = isar, + _drift = drift, + _driftLogger = driftLogger, + _backgroundHostApi = BackgroundWorkerBgHostApi() { + _ref = ProviderContainer( + overrides: [ + dbProvider.overrideWithValue(isar), + isarProvider.overrideWithValue(isar), + driftProvider.overrideWith(driftOverride(drift)), + ], + ); + BackgroundWorkerFlutterApi.setUp(this); + } + + bool get _isBackupEnabled => _ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup); + + Future init() async { + await loadTranslations(); + HttpSSLOptions.apply(applyNative: false); + await _ref.read(authServiceProvider).setOpenApiServiceEndpoint(); + + // Initialize the file downloader + await FileDownloader().configure( + globalConfig: [ + // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 + (Config.holdingQueue, (6, 6, 3)), + // On Android, if files are larger than 256MB, run in foreground service + (Config.runInForegroundIfFileLargerThan, 256), + ], + ); + await FileDownloader().trackTasksInGroup(kDownloadGroupLivePhoto, markDownloadedComplete: false); + await FileDownloader().trackTasks(); + configureFileDownloaderNotifications(); + + // Notify the host that the background upload service has been initialized and is ready to use + await _backgroundHostApi.onInitialized(); + } + + @override + Future onLocalSync(int? maxSeconds) async { + _logger.info('Local background syncing started'); + final sw = Stopwatch()..start(); + + final timeout = maxSeconds != null ? Duration(seconds: maxSeconds) : null; + await _syncAssets(hashTimeout: timeout, syncRemote: false); + + sw.stop(); + _logger.info("Local sync completed in ${sw.elapsed.inSeconds}s"); + } + + /* We do the following on Android upload + * - Sync local assets + * - Hash local assets 3 / 6 minutes + * - Sync remote assets + * - Check and requeue upload tasks + */ + @override + Future onAndroidUpload() async { + _logger.info('Android background processing started'); + final sw = Stopwatch()..start(); + + await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6)); + await _handleBackup(processBulk: false); + + await _cleanup(); + + sw.stop(); + _logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s"); + } + + /* We do the following on background upload + * - Sync local assets + * - Hash local assets + * - Sync remote assets + * - Check and requeue upload tasks + * + * The native side will not send the maxSeconds value for processing tasks + */ + @override + Future onIosUpload(bool isRefresh, int? maxSeconds) async { + _logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s'); + final sw = Stopwatch()..start(); + + final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6); + await _syncAssets(hashTimeout: timeout); + + final backupFuture = _handleBackup(); + if (maxSeconds != null) { + await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {}); + } else { + await backupFuture; + } + + await _cleanup(); + + sw.stop(); + _logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s"); + } + + @override + Future cancel() async { + _logger.warning("Background upload cancelled"); + await _cleanup(); + } + + Future _cleanup() async { + if (_isCleanedUp) { + return; + } + + _isCleanedUp = true; + await _ref.read(backgroundSyncProvider).cancel(); + await _ref.read(backgroundSyncProvider).cancelLocal(); + await _isar.close(); + await _drift.close(); + await _driftLogger.close(); + _ref.dispose(); + } + + Future _handleBackup({bool processBulk = true}) async { + if (!_isBackupEnabled) { + return; + } + + final currentUser = _ref.read(currentUserProvider); + if (currentUser == null) { + return; + } + + if (processBulk) { + return _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); + } + + final activeTask = await _ref.read(uploadServiceProvider).getActiveTasks(currentUser.id); + if (activeTask.isNotEmpty) { + await _ref.read(uploadServiceProvider).resumeBackup(); + } else { + await _ref.read(uploadServiceProvider).startBackupSerial(currentUser.id); + } + } + + Future _syncAssets({Duration? hashTimeout, bool syncRemote = true}) async { + final futures = >[]; + + final localSyncFuture = _ref.read(backgroundSyncProvider).syncLocal().then((_) async { + if (_isCleanedUp) { + return; + } + + var hashFuture = _ref.read(backgroundSyncProvider).hashAssets(); + if (hashTimeout != null) { + hashFuture = hashFuture.timeout( + hashTimeout, + onTimeout: () { + // Consume cancellation errors as we want to continue processing + }, + ); + } + + return hashFuture; + }); + + futures.add(localSyncFuture); + if (syncRemote) { + final remoteSyncFuture = _ref.read(backgroundSyncProvider).syncRemote(); + futures.add(remoteSyncFuture); + } + + await Future.wait(futures); + } +} + +@pragma('vm:entry-point') +Future _backgroundSyncNativeEntrypoint() async { + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + + final (isar, drift, logDB) = await Bootstrap.initDB(); + await Bootstrap.initDomain(isar, drift, logDB, shouldBufferLogs: false); + await BackgroundWorkerBgService(isar: isar, drift: drift, driftLogger: logDB).init(); +} diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index a8eea2c25e..90720fdc76 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -15,6 +15,7 @@ class HashService { final DriftLocalAssetRepository _localAssetRepository; final StorageRepository _storageRepository; final NativeSyncApi _nativeSyncApi; + final bool Function()? _cancelChecker; final _log = Logger('HashService'); HashService({ @@ -22,13 +23,17 @@ class HashService { required DriftLocalAssetRepository localAssetRepository, required StorageRepository storageRepository, required NativeSyncApi nativeSyncApi, + bool Function()? cancelChecker, this.batchSizeLimit = kBatchHashSizeLimit, this.batchFileLimit = kBatchHashFileLimit, }) : _localAlbumRepository = localAlbumRepository, _localAssetRepository = localAssetRepository, _storageRepository = storageRepository, + _cancelChecker = cancelChecker, _nativeSyncApi = nativeSyncApi; + bool get isCancelled => _cancelChecker?.call() ?? false; + Future hashAssets() async { final Stopwatch stopwatch = Stopwatch()..start(); // Sorted by backupSelection followed by isCloud @@ -37,6 +42,11 @@ class HashService { ); for (final album in localAlbums) { + if (isCancelled) { + _log.warning("Hashing cancelled. Stopped processing albums."); + break; + } + final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id); if (assetsToHash.isNotEmpty) { await _hashAssets(assetsToHash); @@ -55,6 +65,11 @@ class HashService { final toHash = <_AssetToPath>[]; for (final asset in assetsToHash) { + if (isCancelled) { + _log.warning("Hashing cancelled. Stopped processing assets."); + return; + } + final file = await _storageRepository.getFileForAsset(asset.id); if (file == null) { continue; @@ -89,6 +104,11 @@ class HashService { ); for (int i = 0; i < hashes.length; i++) { + if (isCancelled) { + _log.warning("Hashing cancelled. Stopped processing batch."); + return; + } + final hash = hashes[i]; final asset = toHash[i].asset; if (hash?.length == 20) { diff --git a/mobile/lib/domain/services/log.service.dart b/mobile/lib/domain/services/log.service.dart index 1053d5e54f..d21cb7ab09 100644 --- a/mobile/lib/domain/services/log.service.dart +++ b/mobile/lib/domain/services/log.service.dart @@ -123,6 +123,11 @@ class LogService { _flushTimer = null; final buffer = [..._msgBuffer]; _msgBuffer.clear(); + + if (buffer.isEmpty) { + return; + } + await _logRepository.insertAll(buffer); } } diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index cbf4030788..d8042c707c 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -59,6 +59,28 @@ class BackgroundSyncManager { } } + Future cancelLocal() async { + final futures = []; + + if (_hashTask != null) { + futures.add(_hashTask!.future); + } + _hashTask?.cancel(); + _hashTask = null; + + if (_deviceAlbumSyncTask != null) { + futures.add(_deviceAlbumSyncTask!.future); + } + _deviceAlbumSyncTask?.cancel(); + _deviceAlbumSyncTask = null; + + try { + await Future.wait(futures); + } on CanceledError { + // Ignore cancellation errors + } + } + // No need to cancel the task, as it can also be run when the user logs out Future syncLocal({bool full = false}) { if (_deviceAlbumSyncTask != null) { diff --git a/mobile/lib/infrastructure/entities/user.entity.dart b/mobile/lib/infrastructure/entities/user.entity.dart index 78fc76b45d..c3612cb8c6 100644 --- a/mobile/lib/infrastructure/entities/user.entity.dart +++ b/mobile/lib/infrastructure/entities/user.entity.dart @@ -54,6 +54,8 @@ class User { avatarColor: dto.avatarColor, memoryEnabled: dto.memoryEnabled, inTimeline: dto.inTimeline, + quotaUsageInBytes: dto.quotaUsageInBytes, + quotaSizeInBytes: dto.quotaSizeInBytes, ); UserDto toDto() => UserDto( diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index fe62469461..78f6b9479b 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -64,19 +64,62 @@ class RemoteImageRequest extends ImageRequest { if (_isCancelled) { return null; } - final bytes = Uint8List(response.contentLength); - int offset = 0; - final subscription = response.listen((List chunk) { - // this is important to break the response stream if the request is cancelled + + final cacheManager = this.cacheManager; + final streamController = StreamController>(sync: true); + final Stream> stream; + cacheManager?.putStreamedFile(url, streamController.stream); + stream = response.map((chunk) { if (_isCancelled) { throw StateError('Cancelled request'); } - bytes.setAll(offset, chunk); - offset += chunk.length; - }, cancelOnError: true); - cacheManager?.putStreamedFile(url, response); - await subscription.asFuture(); - return await ImmutableBuffer.fromUint8List(bytes); + if (cacheManager != null) { + streamController.add(chunk); + } + return chunk; + }); + + try { + final Uint8List bytes = await _downloadBytes(stream, response.contentLength); + streamController.close(); + return await ImmutableBuffer.fromUint8List(bytes); + } catch (e) { + streamController.addError(e); + streamController.close(); + if (_isCancelled) { + return null; + } + rethrow; + } + } + + Future _downloadBytes(Stream> stream, int length) async { + final Uint8List bytes; + int offset = 0; + if (length > 0) { + // Known content length - use pre-allocated buffer + bytes = Uint8List(length); + await stream.listen((chunk) { + bytes.setAll(offset, chunk); + offset += chunk.length; + }, cancelOnError: true).asFuture(); + } else { + // Unknown content length - collect chunks dynamically + final chunks = >[]; + int totalLength = 0; + await stream.listen((chunk) { + chunks.add(chunk); + totalLength += chunk.length; + }, cancelOnError: true).asFuture(); + + bytes = Uint8List(totalLength); + for (final chunk in chunks) { + bytes.setAll(offset, chunk); + offset += chunk.length; + } + } + + return bytes; } Future _loadCachedFile( diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 58adac30db..5865447064 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -9,7 +9,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { final Drift _db; const DriftLocalAssetRepository(this._db) : super(_db); - Stream watchAsset(String id) { + SingleOrNullSelectable _assetSelectable(String id) { final query = _db.localAssetEntity.select().addColumns([_db.remoteAssetEntity.id]).join([ leftOuterJoin( _db.remoteAssetEntity, @@ -21,9 +21,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { return query.map((row) { final asset = row.readTable(_db.localAssetEntity).toDto(); return asset.copyWith(remoteId: row.read(_db.remoteAssetEntity.id)); - }).watchSingleOrNull(); + }); } + Future get(String id) => _assetSelectable(id).getSingleOrNull(); + + Stream watch(String id) => _assetSelectable(id).watchSingleOrNull(); + Future updateHashes(Iterable hashes) { if (hashes.isEmpty) { return Future.value(); diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 2a52faf2dd..0dcf7200cc 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -15,8 +15,8 @@ class DriftMemoryRepository extends DriftDatabaseRepository { final query = _db.select(_db.memoryEntity).join([ - leftOuterJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)), - leftOuterJoin( + innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)), + innerJoin( _db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.memoryAssetEntity.assetId) & _db.remoteAssetEntity.deletedAt.isNull() & @@ -30,6 +30,9 @@ class DriftMemoryRepository extends DriftDatabaseRepository { ..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); final rows = await query.get(); + if (rows.isEmpty) { + return const []; + } final Map memoriesMap = {}; @@ -46,7 +49,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository { } } - return memoriesMap.values.toList(); + return memoriesMap.values.toList(growable: false); } Future get(String memoryId) async { diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 41ce131871..44a288787e 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -17,7 +17,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { const DriftRemoteAlbumRepository(this._db) : super(_db); Future> getAll({Set sortBy = const {SortRemoteAlbumsBy.updatedAt}}) { - final assetCount = _db.remoteAlbumAssetEntity.assetId.count(); + final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true); final query = _db.remoteAlbumEntity.select().join([ leftOuterJoin( @@ -41,7 +41,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { ..where(_db.remoteAssetEntity.deletedAt.isNull()) ..addColumns([assetCount]) ..addColumns([_db.userEntity.name]) - ..addColumns([_db.remoteAlbumUserEntity.userId.count()]) + ..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)]) ..groupBy([_db.remoteAlbumEntity.id]); if (sortBy.isNotEmpty) { @@ -62,14 +62,14 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { .toDto( assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!, - isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2, + isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2, ), ) .get(); } Future get(String albumId) { - final assetCount = _db.remoteAlbumAssetEntity.assetId.count(); + final assetCount = _db.remoteAlbumAssetEntity.assetId.count(distinct: true); final query = _db.remoteAlbumEntity.select().join([ @@ -97,7 +97,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { ..where(_db.remoteAlbumEntity.id.equals(albumId) & _db.remoteAssetEntity.deletedAt.isNull()) ..addColumns([assetCount]) ..addColumns([_db.userEntity.name]) - ..addColumns([_db.remoteAlbumUserEntity.userId.count()]) + ..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)]) ..groupBy([_db.remoteAlbumEntity.id]); return query @@ -107,7 +107,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { .toDto( assetCount: row.read(assetCount) ?? 0, ownerName: row.read(_db.userEntity.name)!, - isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2, + isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2, ), ) .getSingleOrNull(); @@ -282,7 +282,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { ]) ..where(_db.remoteAlbumEntity.id.equals(albumId)) ..addColumns([_db.userEntity.name]) - ..addColumns([_db.remoteAlbumUserEntity.userId.count()]) + ..addColumns([_db.remoteAlbumUserEntity.userId.count(distinct: true)]) ..groupBy([_db.remoteAlbumEntity.id]); return query.map((row) { @@ -290,7 +290,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { .readTable(_db.remoteAlbumEntity) .toDto( ownerName: row.read(_db.userEntity.name)!, - isShared: row.read(_db.remoteAlbumUserEntity.userId.count())! > 2, + isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2, ); return album; }).watchSingleOrNull(); diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 44d7cfb6bb..3ed7dddfe8 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -55,24 +55,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository { return _assetSelectable(id).getSingleOrNull(); } - Stream watchAsset(String id) { - final query = - _db.remoteAssetEntity.select().addColumns([_db.localAssetEntity.id]).join([ - leftOuterJoin( - _db.localAssetEntity, - _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), - useColumns: false, - ), - ]) - ..where(_db.remoteAssetEntity.id.equals(id)) - ..limit(1); - - return query.map((row) { - final asset = row.readTable(_db.remoteAssetEntity).toDto(); - return asset.copyWith(localId: row.read(_db.localAssetEntity.id)); - }).watchSingleOrNull(); - } - Future> getStackChildren(RemoteAsset asset) { if (asset.stackId == null) { return Future.value([]); diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index 1caab462cd..3081aee1a9 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -1,9 +1,11 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:isar/isar.dart'; class IsarUserRepository extends IsarDatabaseRepository { @@ -70,8 +72,16 @@ class DriftUserRepository extends DriftDatabaseRepository { final Drift _db; const DriftUserRepository(super.db) : _db = db; - Future get(String id) => - _db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull().then((user) => user?.toDto()); + Future get(String id) async { + final user = await _db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull(); + + if (user == null) return null; + + final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(id)); + final metadata = await query.map((row) => row.toDto()).get(); + + return user.toDto(metadata); + } Future upsert(UserDto user) async { await _db.userEntity.insertOnConflictUpdate( @@ -87,10 +97,35 @@ class DriftUserRepository extends DriftDatabaseRepository { ); return user; } + + Future> getAll() async { + final users = await _db.userEntity.select().get(); + final List result = []; + + for (final user in users) { + final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(user.id)); + final metadata = await query.map((row) => row.toDto()).get(); + result.add(user.toDto(metadata)); + } + + return result; + } } extension on UserEntityData { - UserDto toDto() { + UserDto toDto([List? metadata]) { + AvatarColor avatarColor = AvatarColor.primary; + bool memoryEnabled = true; + + if (metadata != null) { + for (final meta in metadata) { + if (meta.key == UserMetadataKey.preferences && meta.preferences != null) { + avatarColor = meta.preferences?.userAvatarColor ?? AvatarColor.primary; + memoryEnabled = meta.preferences?.memoriesEnabled ?? true; + } + } + } + return UserDto( id: id, email: email, @@ -99,6 +134,8 @@ extension on UserEntityData { updatedAt: updatedAt, profileChangedAt: profileChangedAt, hasProfileImage: hasProfileImage, + avatarColor: avatarColor, + memoryEnabled: memoryEnabled, ); } } diff --git a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart index 7205c7f73a..173ec10b97 100644 --- a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart @@ -16,7 +16,7 @@ class DriftUserMetadataRepository extends DriftDatabaseRepository { } } -extension on UserMetadataEntityData { +extension UserMetadataDataExtension on UserMetadataEntityData { UserMetadata toDto() => switch (key) { UserMetadataKey.onboarding => UserMetadata(userId: userId, key: key, onboarding: Onboarding.fromMap(value)), UserMetadataKey.preferences => UserMetadata(userId: userId, key: key, preferences: Preferences.fromMap(value)), diff --git a/mobile/lib/infrastructure/utils/user.converter.dart b/mobile/lib/infrastructure/utils/user.converter.dart index dc107e6fb2..bf35077e1f 100644 --- a/mobile/lib/infrastructure/utils/user.converter.dart +++ b/mobile/lib/infrastructure/utils/user.converter.dart @@ -29,6 +29,8 @@ abstract final class UserConverter { isPartnerSharedWith: false, profileChangedAt: adminDto.profileChangedAt, hasProfileImage: adminDto.profileImagePath.isNotEmpty, + quotaSizeInBytes: adminDto.quotaSizeInBytes ?? 0, + quotaUsageInBytes: adminDto.quotaUsageInBytes ?? 0, ); static UserDto fromPartnerDto(PartnerResponseDto dto) => UserDto( diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 0cab21748c..21093df24d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -12,10 +12,13 @@ import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/locales.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; @@ -23,6 +26,7 @@ import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/deep_link.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; @@ -165,36 +169,6 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve await ref.read(localNotificationService).setup(); } - void _configureFileDownloaderNotifications() { - FileDownloader().configureNotificationForGroup( - kDownloadGroupImage, - running: TaskNotification('downloading_media'.tr(), '${'file_name'.tr()}: {filename}'), - complete: TaskNotification('download_finished'.tr(), '${'file_name'.tr()}: {filename}'), - progressBar: true, - ); - - FileDownloader().configureNotificationForGroup( - kDownloadGroupVideo, - running: TaskNotification('downloading_media'.tr(), '${'file_name'.tr()}: {filename}'), - complete: TaskNotification('download_finished'.tr(), '${'file_name'.tr()}: {filename}'), - progressBar: true, - ); - - FileDownloader().configureNotificationForGroup( - kManualUploadGroup, - running: TaskNotification('uploading_media'.tr(), '${'file_name'.tr()}: {displayName}'), - complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'), - progressBar: true, - ); - - FileDownloader().configureNotificationForGroup( - kBackupGroup, - running: TaskNotification('uploading_media'.tr(), '${'file_name'.tr()}: {displayName}'), - complete: TaskNotification('upload_finished'.tr(), '${'file_name'.tr()}: {displayName}'), - progressBar: true, - ); - } - Future _deepLinkBuilder(PlatformDeepLink deepLink) async { final deepLinkHandler = ref.read(deepLinkServiceProvider); final currentRouteName = ref.read(currentRouteNameProvider.notifier).state; @@ -221,7 +195,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve super.didChangeDependencies(); Intl.defaultLocale = context.locale.toLanguageTag(); WidgetsBinding.instance.addPostFrameCallback((_) { - _configureFileDownloaderNotifications(); + configureFileDownloaderNotifications(); }); } @@ -231,7 +205,16 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve initApp().then((_) => debugPrint("App Init Completed")); WidgetsBinding.instance.addPostFrameCallback((_) { // needs to be delayed so that EasyLocalization is working - ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + if (Store.isBetaTimelineEnabled) { + ref.read(driftBackgroundUploadFgService).enableSyncService(); + if (ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.enableBackup)) { + ref.read(backgroundServiceProvider).disableService(); + ref.read(driftBackgroundUploadFgService).enableUploadService(); + } + } else { + ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + ref.read(driftBackgroundUploadFgService).disableUploadService(); + } }); ref.read(shareIntentUploadProvider.notifier).init(); diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index b125c35908..5140c62a0d 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.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_album.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -42,10 +43,12 @@ class _DriftBackupPageState extends ConsumerState { await ref.read(backgroundSyncProvider).syncRemote(); await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); + await ref.read(driftBackgroundUploadFgService).enableUploadService(); await ref.read(driftBackupProvider.notifier).startBackup(currentUser.id); } Future stopBackup() async { + await ref.read(driftBackgroundUploadFgService).disableUploadService(); await ref.read(driftBackupProvider.notifier).cancel(); } diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 3e9747ce32..9bb2895907 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -13,7 +13,9 @@ import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -68,12 +70,16 @@ class _ChangeExperiencePageState extends ConsumerState { await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider)); await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider)); await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider)); + await ref.read(backgroundServiceProvider).disableService(); } } else { await ref.read(backgroundSyncProvider).cancel(); ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); ref.read(websocketProvider.notifier).startListeningToOldEvents(); + ref.read(readonlyModeProvider.notifier).setReadonlyMode(false); await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider)); + await ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + await ref.read(driftBackgroundUploadFgService).disableUploadService(); } await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta); diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 983164831a..41b01ad3a3 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -54,6 +55,7 @@ class _TabShellPageState extends ConsumerState { @override Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final navigationDestinations = [ NavigationDestination( @@ -65,16 +67,19 @@ class _TabShellPageState extends ConsumerState { label: 'search'.tr(), icon: const Icon(Icons.search_rounded), selectedIcon: Icon(Icons.search, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), NavigationDestination( label: 'albums'.tr(), icon: const Icon(Icons.photo_album_outlined), selectedIcon: Icon(Icons.photo_album_rounded, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), NavigationDestination( label: 'library'.tr(), icon: const Icon(Icons.space_dashboard_outlined), selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, ), ]; diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart new file mode 100644 index 0000000000..646eb63b76 --- /dev/null +++ b/mobile/lib/platform/background_worker_api.g.dart @@ -0,0 +1,296 @@ +// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class BackgroundWorkerFgHostApi { + /// Constructor for [BackgroundWorkerFgHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + BackgroundWorkerFgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future enableSyncWorker() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableSyncWorker$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future enableUploadWorker(int callbackHandle) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enableUploadWorker$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([callbackHandle]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future disableUploadWorker() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disableUploadWorker$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} + +class BackgroundWorkerBgHostApi { + /// Constructor for [BackgroundWorkerBgHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + BackgroundWorkerBgHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future onInitialized() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerBgHostApi.onInitialized$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} + +abstract class BackgroundWorkerFlutterApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + Future onLocalSync(int? maxSeconds); + + Future onIosUpload(bool isRefresh, int? maxSeconds); + + Future onAndroidUpload(); + + Future cancel(); + + static void setUp( + BackgroundWorkerFlutterApi? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onLocalSync was null.', + ); + final List args = (message as List?)!; + final int? arg_maxSeconds = (args[0] as int?); + try { + await api.onLocalSync(arg_maxSeconds); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload was null.', + ); + final List args = (message as List?)!; + final bool? arg_isRefresh = (args[0] as bool?); + assert( + arg_isRefresh != null, + 'Argument for dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onIosUpload was null, expected non-null bool.', + ); + final int? arg_maxSeconds = (args[1] as int?); + try { + await api.onIosUpload(arg_isRefresh!, arg_maxSeconds); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + await api.onAndroidUpload(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.cancel$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + try { + await api.cancel(); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + } +} diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 3764443566..60a296a22c 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; @RoutePage() class MainTimelinePage extends ConsumerWidget { @@ -12,25 +11,10 @@ class MainTimelinePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); - final memoriesEnabled = ref.watch(currentUserProvider.select((user) => user?.memoryEnabled ?? true)); - - // TODO: the user preferences need to be updated - // from the server to get live hiding/showing of memory lane - - return memoryLaneProvider.maybeWhen( - data: (memories) { - return memories.isEmpty || !memoriesEnabled - ? const Timeline() - : Timeline( - topSliverWidget: SliverToBoxAdapter( - key: Key('memory-lane-${memories.first.assets.first.id}'), - child: DriftMemoryLane(memories: memories), - ), - topSliverWidgetHeight: 200, - ); - }, - orElse: () => const Timeline(), + final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false)); + return Timeline( + topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), + topSliverWidgetHeight: hasMemories ? 200 : 0, ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 6c78cfac3e..5e906b820f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -24,6 +24,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -308,7 +309,7 @@ class _AssetViewerState extends ConsumerState { bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); } - if (distanceToOrigin > openThreshold && !showingBottomSheet) { + if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) { _openBottomSheet(ctx); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 732afee7f9..e581e32df0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_acti import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart'; @@ -26,6 +27,7 @@ class ViewerBottomBar extends ConsumerWidget { return const SizedBox.shrink(); } + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); @@ -60,7 +62,7 @@ class ViewerBottomBar extends ConsumerWidget { duration: Durations.short2, child: AnimatedSwitcher( duration: Durations.short4, - child: isSheetOpen + child: isSheetOpen || isReadonlyModeEnabled ? const SizedBox.shrink() : Theme( data: context.themeData.copyWith( diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 411e279460..570df1afbb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -34,6 +35,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isInLockedView = ref.watch(inLockedViewProvider); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final previousRouteName = ref.watch(previousRouteNameProvider); final showViewInTimelineButton = @@ -94,7 +96,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet + actions: isShowingSheet || isReadonlyModeEnabled ? null : isInLockedView ? lockedViewActions diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 32510c2ca5..fa7f204596 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -87,9 +87,10 @@ class NativeVideoViewer extends HookConsumerWidget { return null; } + final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset; try { - if (asset.hasLocal && asset.livePhotoVideoId == null) { - final id = asset is LocalAsset ? (asset as LocalAsset).id : (asset as RemoteAsset).localId!; + if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { + final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; final file = await const StorageRepository().getFileForAsset(id); if (file == null) { throw Exception('No file found for the video'); @@ -99,14 +100,14 @@ class NativeVideoViewer extends HookConsumerWidget { return source; } - final remoteId = (asset as RemoteAsset).id; + final remoteId = (videoAsset as RemoteAsset).id; // Use a network URL for the video player controller final serverEndpoint = Store.get(StoreKey.serverEndpoint); final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; - final String videoUrl = asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl' + final String videoUrl = videoAsset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' : '$serverEndpoint/assets/$remoteId/$postfixUrl'; final source = await VideoSource.init( @@ -116,7 +117,7 @@ class NativeVideoViewer extends HookConsumerWidget { ); return source; } catch (error) { - log.severe('Error creating video source for asset ${asset.name}: $error'); + log.severe('Error creating video source for asset ${videoAsset.name}: $error'); return null; } } diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index d0428e5013..dd87d2f228 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -50,12 +50,11 @@ mixin CancellableImageProviderMixin on CancellableImageProvide Stream loadRequest(ImageRequest request, ImageDecoderCallback decode) async* { if (isCancelled) { + this.request = null; evict(); return; } - this.request = request; - try { final image = await request.load(decode); if (image == null || isCancelled) { diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 8bdbe3c16a..223d095432 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -35,7 +35,8 @@ class LocalThumbProvider extends CancellableImageProvider } Stream _codec(LocalThumbProvider key, ImageDecoderCallback decode) { - return loadRequest(LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType), decode); + final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType); + return loadRequest(request, decode); } @override @@ -87,7 +88,7 @@ class LocalFullImageProvider extends CancellableImageProvider } Stream _codec(RemoteThumbProvider key, ImageDecoderCallback decode) { - final request = RemoteImageRequest( + final request = this.request = RemoteImageRequest( uri: getThumbnailUrlForRemoteId(key.assetId), headers: ApiService.getRequestHeaders(), cacheManager: cacheManager, @@ -92,16 +92,12 @@ class RemoteFullImageProvider extends CancellableImageProvider @override ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) { - return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode))..addOnLastListenerRemovedCallback(cancel); + return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel); } Stream _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) { - return loadRequest(ThumbhashImageRequest(thumbhash: key.thumbHash), decode); + final request = this.request = ThumbhashImageRequest(thumbhash: key.thumbHash); + return loadRequest(request, decode); } @override diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 347d7efd3e..3ecd5cd491 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; @@ -94,7 +95,7 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix imageInfo.dispose(); return; } - + _fadeController.value = 1.0; setState(() { _providerImage = imageInfo.image; }); @@ -115,7 +116,7 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty); final imageStreamListener = _imageStreamListener = ImageStreamListener( (ImageInfo imageInfo, bool synchronousCall) { - _stopListeningToStream(); + _stopListeningToThumbhashStream(); if (!mounted) { imageInfo.dispose(); return; @@ -125,7 +126,7 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix return; } - if (synchronousCall && _providerImage == null) { + if ((synchronousCall && _providerImage == null) || !_isVisible()) { _fadeController.value = 1.0; } else if (_fadeController.isAnimating) { _fadeController.forward(); @@ -201,6 +202,15 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix _loadFromThumbhashProvider(); } + bool _isVisible() { + final renderObject = context.findRenderObject() as RenderBox?; + if (renderObject == null || !renderObject.attached) return false; + + final topLeft = renderObject.localToGlobal(Offset.zero); + final bottomRight = renderObject.localToGlobal(Offset(renderObject.size.width, renderObject.size.height)); + return topLeft.dy < context.height && bottomRight.dy > 0; + } + @override Widget build(BuildContext context) { final colorScheme = context.colorScheme; @@ -226,6 +236,16 @@ class _ThumbnailState extends State with SingleTickerProviderStateMix @override void dispose() { + final imageProvider = widget.imageProvider; + if (imageProvider is CancellableImageProvider) { + imageProvider.cancel(); + } + + final thumbhashProvider = widget.thumbhashProvider; + if (thumbhashProvider is CancellableImageProvider) { + thumbhashProvider.cancel(); + } + _fadeController.removeStatusListener(_onAnimationStatusChanged); _fadeController.dispose(); _stopListeningToStream(); diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index ec49bbec96..b2c61c7488 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -7,15 +7,20 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart' import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class DriftMemoryLane extends ConsumerWidget { - final List memories; - - const DriftMemoryLane({super.key, required this.memories}); + const DriftMemoryLane({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); + final memories = memoryLaneProvider.value ?? const []; + if (memories.isEmpty) { + return const SizedBox.shrink(); + } + return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: CarouselView( @@ -38,7 +43,9 @@ class DriftMemoryLane extends ConsumerWidget { context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)); }, - children: memories.map((memory) => DriftMemoryCard(memory: memory)).toList(), + children: memories + .map((memory) => DriftMemoryCard(key: Key(memory.id), memory: memory)) + .toList(growable: false), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 05f96d49de..5eda738e76 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -190,11 +191,12 @@ class _AssetTileWidget extends ConsumerWidget { final lockSelection = _getLockSelectionStatus(ref); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); return RepaintBoundary( child: GestureDetector( onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset), - onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset), + onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset), child: ThumbnailTile( asset, lockSelection: lockSelection, diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart index b8c6668a38..3eff305251 100644 --- a/mobile/lib/presentation/widgets/timeline/header.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -7,9 +7,10 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class TimelineHeader extends StatelessWidget { +class TimelineHeader extends HookConsumerWidget { final Bucket bucket; final HeaderType header; final double height; @@ -36,13 +37,12 @@ class TimelineHeader extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (bucket is! TimeBucket || header == HeaderType.none) { return const SizedBox.shrink(); } final date = (bucket as TimeBucket).date; - final isMonthHeader = header == HeaderType.month || header == HeaderType.monthAndDay; final isDayHeader = header == HeaderType.day || header == HeaderType.monthAndDay; @@ -57,7 +57,10 @@ class TimelineHeader extends StatelessWidget { if (isMonthHeader) Row( children: [ - Text(_formatMonth(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 24)), + Text( + toBeginningOfSentenceCase(_formatMonth(context, date)), + style: context.textTheme.labelLarge?.copyWith(fontSize: 24), + ), const Spacer(), if (header != HeaderType.monthAndDay) _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset), ], @@ -65,7 +68,10 @@ class TimelineHeader extends StatelessWidget { if (isDayHeader) Row( children: [ - Text(_formatDay(context, date), style: context.textTheme.labelLarge?.copyWith(fontSize: 15)), + Text( + toBeginningOfSentenceCase(_formatDay(context, date)), + style: context.textTheme.labelLarge?.copyWith(fontSize: 15), + ), const Spacer(), _BulkSelectIconButton(bucket: bucket, assetOffset: assetOffset), ], @@ -92,16 +98,19 @@ class _BulkSelectIconButton extends ConsumerWidget { bucketAssets = []; } + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isAllSelected = ref.watch(bucketSelectionProvider(bucketAssets)); - return IconButton( - onPressed: () { - ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount); - ref.read(hapticFeedbackProvider.notifier).heavyImpact(); - }, - icon: isAllSelected - ? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor) - : Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary), - ); + return isReadonlyModeEnabled + ? const SizedBox.shrink() + : IconButton( + onPressed: () { + ref.read(multiSelectProvider.notifier).toggleBucketSelection(assetOffset, bucket.assetCount); + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + }, + icon: isAllSelected + ? Icon(Icons.check_circle_rounded, size: 26, color: context.primaryColor) + : Icon(Icons.check_circle_outline_rounded, size: 26, color: context.colorScheme.onSurfaceSecondary), + ); } } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index c859ae0e80..125f8505a1 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -19,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -256,6 +257,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); return PopScope( canPop: !isMultiSelectEnabled, @@ -342,9 +344,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ), }, child: TimelineDragRegion( - onStart: _setDragStartIndex, + onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null, onAssetEnter: _handleDragAssetEnter, - onEnd: _stopDrag, + onEnd: !isReadonlyModeEnabled ? _stopDrag : null, onScroll: _dragScroll, onScrollStart: () { // Minimize the bottom sheet when drag selection starts diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 76cb383465..6035e53e5d 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; +import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; @@ -34,6 +36,8 @@ import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; +final driftBackgroundUploadFgService = Provider((ref) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi())); + final backupProvider = StateNotifierProvider((ref) { return BackupNotifier( ref.watch(backupServiceProvider), diff --git a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart b/mobile/lib/providers/image/cache/remote_image_cache_manager.dart index df5f4566c0..41c541ccdb 100644 --- a/mobile/lib/providers/image/cache/remote_image_cache_manager.dart +++ b/mobile/lib/providers/image/cache/remote_image_cache_manager.dart @@ -38,9 +38,21 @@ abstract class RemoteCacheManager extends CacheManager { final file = await store.fileSystem.createFile(path); final sink = file.openWrite(); try { - await source.pipe(sink); + await source.listen(sink.add, cancelOnError: true).asFuture(); } catch (e) { + try { + await sink.close(); + await file.delete(); + } catch (e) { + _log.severe('Failed to delete incomplete cache file: $e'); + } + return; + } + + try { + await sink.flush(); await sink.close(); + } catch (e) { try { await file.delete(); } catch (e) { diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index e5809a12b4..0965f4349b 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -14,13 +14,12 @@ final driftMemoryServiceProvider = Provider( (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), ); -final driftMemoryFutureProvider = FutureProvider.autoDispose>((ref) async { - final user = ref.watch(currentUserProvider); - if (user == null) { - return []; +final driftMemoryFutureProvider = FutureProvider.autoDispose>((ref) { + final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); + if (userId == null || !enabled) { + return const []; } final service = ref.watch(driftMemoryServiceProvider); - - return service.getMemoryLane(user.id); + return service.getMemoryLane(userId); }); diff --git a/mobile/lib/providers/infrastructure/readonly_mode.provider.dart b/mobile/lib/providers/infrastructure/readonly_mode.provider.dart new file mode 100644 index 0000000000..9e96c3cfc4 --- /dev/null +++ b/mobile/lib/providers/infrastructure/readonly_mode.provider.dart @@ -0,0 +1,36 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; + +class ReadOnlyModeNotifier extends Notifier { + late AppSettingsService _appSettingService; + + @override + bool build() { + _appSettingService = ref.read(appSettingsServiceProvider); + final readonlyMode = _appSettingService.getSetting(AppSettingsEnum.readonlyModeEnabled); + return readonlyMode; + } + + void setMode(bool value) { + _appSettingService.setSetting(AppSettingsEnum.readonlyModeEnabled, value); + state = value; + + if (value) { + ref.read(appRouterProvider).navigate(const MainTimelineRoute()); + } + } + + void setReadonlyMode(bool isEnabled) { + state = isEnabled; + setMode(state); + } + + void toggleReadonlyMode() { + state = !state; + setMode(state); + } +} + +final readonlyModeProvider = NotifierProvider(() => ReadOnlyModeNotifier()); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 220dbf81c3..c8b06ae102 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -27,8 +27,12 @@ class UploadRepository { ); } - void enqueueBackgroundAll(List tasks) { - FileDownloader().enqueueAll(tasks); + Future enqueueBackground(UploadTask task) { + return FileDownloader().enqueue(task); + } + + Future enqueueBackgroundAll(List tasks) { + return FileDownloader().enqueueAll(tasks); } Future deleteDatabaseRecords(String group) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 8a4b0c6719..d98b14408f 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -49,7 +49,8 @@ enum AppSettingsEnum { betaTimeline(StoreKey.betaTimeline, null, false), enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), - useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false); + useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), + readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 9e5193c8cb..635604b096 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -78,8 +78,8 @@ class UploadService { _taskProgressController.close(); } - void enqueueTasks(List tasks) { - _uploadRepository.enqueueBackgroundAll(tasks); + Future enqueueTasks(List tasks) { + return _uploadRepository.enqueueBackgroundAll(tasks); } Future> getActiveTasks(String group) { @@ -113,7 +113,7 @@ class UploadService { } if (tasks.isNotEmpty) { - enqueueTasks(tasks); + await enqueueTasks(tasks); } } @@ -149,13 +149,37 @@ class UploadService { if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { count += tasks.length; - enqueueTasks(tasks); + await enqueueTasks(tasks); onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length)); } } } + // Enqueue All does not work from the background on Android yet. This method is a temporary workaround + // that enqueues tasks one by one. + Future startBackupSerial(String userId) async { + await _storageRepository.clearCache(); + + shouldAbortQueuingTasks = false; + + final candidates = await _backupRepository.getCandidates(userId); + if (candidates.isEmpty) { + return; + } + + for (final asset in candidates) { + if (shouldAbortQueuingTasks) { + break; + } + + final task = await _getUploadTask(asset); + if (task != null) { + await _uploadRepository.enqueueBackground(task); + } + } + } + /// Cancel all ongoing uploads and reset the upload queue /// /// Return the number of left over tasks in the queue diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 480d918b4e..e7abc66040 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; @@ -11,6 +13,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; @@ -22,6 +25,36 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; +void configureFileDownloaderNotifications() { + FileDownloader().configureNotificationForGroup( + kDownloadGroupImage, + running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'), + progressBar: true, + ); + + FileDownloader().configureNotificationForGroup( + kDownloadGroupVideo, + running: TaskNotification('downloading_media'.t(), '${'file_name'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name'.t()}: {filename}'), + progressBar: true, + ); + + FileDownloader().configureNotificationForGroup( + kManualUploadGroup, + running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()), + complete: TaskNotification('upload_finished'.t(), 'backup_background_service_in_progress_notification'.t()), + groupNotificationId: kManualUploadGroup, + ); + + FileDownloader().configureNotificationForGroup( + kBackupGroup, + running: TaskNotification('uploading_media'.t(), 'backup_background_service_in_progress_notification'.t()), + complete: TaskNotification('upload_finished'.t(), 'backup_background_service_in_progress_notification'.t()), + groupNotificationId: kBackupGroup, + ); +} + abstract final class Bootstrap { static Future<(Isar isar, Drift drift, DriftLogger logDb)> initDB() async { final drift = Drift(); diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 58e7ad7f25..cca1498e0f 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -57,7 +57,7 @@ Cancelable runInIsolateGentle({ log.severe("Error in runInIsolateGentle ${debugLabel == null ? '' : ' for $debugLabel'}", error, stack); } finally { try { - await LogService.I.flush(); + await LogService.I.dispose(); await logDb.close(); await ref.read(driftProvider).close(); @@ -72,8 +72,8 @@ Cancelable runInIsolateGentle({ } ref.dispose(); - } catch (error) { - debugPrint("Error closing resources in isolate: $error"); + } catch (error, stack) { + debugPrint("Error closing resources in isolate: $error, $stack"); } finally { ref.dispose(); // Delay to ensure all resources are released diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 3e45390198..33199d5225 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -28,6 +28,7 @@ dynamic upgradeDto(dynamic value, String targetType) { case 'AssetResponseDto': if (value is Map) { addDefault(value, 'visibility', 'timeline'); + addDefault(value, 'createdAt', DateTime.now().toIso8601String()); } break; case 'UserAdminResponseDto': diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index ccfc374fef..b204058859 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart'; @@ -33,6 +34,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { final horizontalPadding = isHorizontal ? 100.0 : 20.0; final user = ref.watch(currentUserProvider); final isLoggingOut = useState(false); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); useEffect(() { ref.read(backupProvider.notifier).updateDiskInfo(); @@ -214,6 +216,25 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); } + buildReadonlyMessage() { + return Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0), + child: ListTile( + dense: true, + visualDensity: VisualDensity.standard, + contentPadding: const EdgeInsets.only(left: 20, right: 20), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + minLeadingWidth: 20, + tileColor: theme.primaryColor.withAlpha(80), + title: Text( + "profile_drawer_readonly_mode", + style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)), + textAlign: TextAlign.center, + ).tr(), + ), + ); + } + return Dismissible( behavior: HitTestBehavior.translucent, direction: DismissDirection.down, @@ -238,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { const AppBarProfileInfoBox(), buildStorageInformation(), const AppBarServerInfo(), + if (isReadonlyModeEnabled) buildReadonlyMessage(), buildAppLogButton(), buildSettingButton(), buildSignOutButton(), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index b1f5b192dd..a9c7a467c2 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -1,9 +1,12 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/upload_profile_image.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -17,6 +20,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authProvider); final uploadProfileImageStatus = ref.watch(uploadProfileImageProvider).status; + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); buildUserProfileImage() { @@ -55,6 +59,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget { } } + void toggleReadonlyMode() { + // read only mode is only supported int he beta experience + // TODO: remove this check when the beta UI goes stable + if (!Store.isBetaTimelineEnabled) return; + + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); + + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } + return Padding( padding: const EdgeInsets.symmetric(horizontal: 10.0), child: Container( @@ -67,23 +90,25 @@ class AppBarProfileInfoBox extends HookConsumerWidget { minLeadingWidth: 50, leading: GestureDetector( onTap: pickUserProfileImage, + onDoubleTap: toggleReadonlyMode, child: Stack( clipBehavior: Clip.none, children: [ buildUserProfileImage(), - Positioned( - bottom: -5, - right: -8, - child: Material( - color: context.colorScheme.surfaceContainerHighest, - elevation: 3, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), + if (!isReadonlyModeEnabled) + Positioned( + bottom: -5, + right: -8, + child: Material( + color: context.colorScheme.surfaceContainerHighest, + elevation: 3, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(50.0))), + child: Padding( + padding: const EdgeInsets.all(5.0), + child: Icon(Icons.camera_alt_outlined, color: context.primaryColor, size: 14), + ), ), ), - ), ], ), ), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 06a97d1ce5..78fa607666 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -42,6 +43,7 @@ class ImmichSliverAppBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); return SliverAnimatedOpacity( @@ -57,7 +59,7 @@ class ImmichSliverAppBar extends ConsumerWidget { centerTitle: false, title: title ?? const _ImmichLogoWithText(), actions: [ - if (isCasting) + if (isCasting && !isReadonlyModeEnabled) Padding( padding: const EdgeInsets.only(right: 12), child: IconButton( @@ -70,12 +72,13 @@ class ImmichSliverAppBar extends ConsumerWidget { const _SyncStatusIndicator(), if (actions != null) ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if (kDebugMode || kProfileMode) + if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) IconButton( icon: const Icon(Icons.science_rounded), onPressed: () => context.pushRoute(const FeatInDevRoute()), ), - if (showUploadButton) const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), + if (showUploadButton && !isReadonlyModeEnabled) + const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()), ], ), @@ -137,8 +140,24 @@ class _ProfileIndicator extends ConsumerWidget { final user = ref.watch(currentUserProvider); const widgetSize = 30.0; + void toggleReadonlyMode() { + final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); + + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (isReadonlyModeEnabled ? "readonly_mode_disabled" : "readonly_mode_enabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + } + return InkWell( onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()), + onDoubleTap: () => toggleReadonlyMode(), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( label: Container( diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 359b400456..73dbbfc85b 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -272,9 +272,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic void initState() { super.initState(); - _zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this); + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); - _crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this); + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); _zoomAnimation = Tween( begin: 1.0, diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 1cc117139d..0f9555a101 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic void initState() { super.initState(); - _zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this); + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); - _crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this); + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); _zoomAnimation = Tween( begin: 1.0, diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 54497a10de..f9768d575e 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -378,9 +378,17 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with Tic void initState() { super.initState(); - _zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this); + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); - _crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this); + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + animationBehavior: AnimationBehavior.preserve, + ); _zoomAnimation = Tween( begin: 1.0, diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 1fbfce6c53..b46f560122 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -48,7 +48,7 @@ class UserCircleAvatar extends ConsumerWidget { borderRadius: const BorderRadius.all(Radius.circular(50)), child: CachedNetworkImage( fit: BoxFit.cover, - cacheKey: user.profileChangedAt.toIso8601String(), + cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}', width: size, height: size, placeholder: (_, __) => Image.memory(kTransparentImage), diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 0742bb95c3..ab78536a92 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -18,7 +18,6 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/version_compatibility.dart'; @@ -277,7 +276,6 @@ class LoginForm extends HookConsumerWidget { } if (isBeta) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - await runNewSync(ref); context.replaceRoute(const TabShellRoute()); return; } diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index 65037dde96..a2ad04e6b5 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -172,12 +172,36 @@ class _ImageWrapperState extends State { @override Widget build(BuildContext context) { - if (_loading) { - return _buildLoading(context); - } - - if (_lastException != null) { - return _buildError(context); + if (_loading || _lastException != null) { + return CustomChildWrapper( + childSize: null, + backgroundDecoration: widget.backgroundDecoration, + heroAttributes: widget.heroAttributes, + scaleStateChangedCallback: widget.scaleStateChangedCallback, + enableRotation: widget.enableRotation, + controller: widget.controller, + scaleStateController: widget.scaleStateController, + maxScale: widget.maxScale, + minScale: widget.minScale, + initialScale: widget.initialScale, + basePosition: widget.basePosition, + scaleStateCycle: widget.scaleStateCycle, + onTapUp: widget.onTapUp, + onTapDown: widget.onTapDown, + onDragStart: widget.onDragStart, + onDragEnd: widget.onDragEnd, + onDragUpdate: widget.onDragUpdate, + onScaleEnd: widget.onScaleEnd, + onLongPressStart: widget.onLongPressStart, + outerSize: widget.outerSize, + gestureDetectorBehavior: widget.gestureDetectorBehavior, + tightMode: widget.tightMode, + filterQuality: widget.filterQuality, + disableGestures: widget.disableGestures, + disableScaleGestures: true, + enablePanAlways: widget.enablePanAlways, + child: _loading ? _buildLoading(context) : _buildError(context), + ); } final scaleBoundaries = ScaleBoundaries( diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 3f196b840b..cd2fa93b85 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; @@ -31,6 +34,7 @@ class AdvancedSettings extends HookConsumerWidget { final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); + final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final logLevel = Level.LEVELS[levelId.value].name; @@ -102,6 +106,26 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_enable_alternate_media_filter_title".tr(), subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(), ), + // TODO: Remove this check when beta timeline goes stable + if (Store.isBetaTimelineEnabled) + SettingsSwitchListTile( + valueNotifier: readonlyModeEnabled, + title: "advanced_settings_readonly_mode_title".tr(), + subtitle: "advanced_settings_readonly_mode_subtitle".tr(), + onChanged: (value) { + readonlyModeEnabled.value = value; + ref.read(readonlyModeProvider.notifier).setReadonlyMode(value); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + (value ? "readonly_mode_enabled" : "readonly_mode_disabled").tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), + ); + }, + ), ]; return SettingsSubPageScaffold(settings: advancedSettings); diff --git a/mobile/makefile b/mobile/makefile index 5a31481f45..1a20e769ef 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -8,8 +8,10 @@ build: pigeon: dart run pigeon --input pigeon/native_sync_api.dart dart run pigeon --input pigeon/thumbnail_api.dart + dart run pigeon --input pigeon/background_worker_api.dart dart format lib/platform/native_sync_api.g.dart dart format lib/platform/thumbnail_api.g.dart + dart format lib/platform/background_worker_api.g.dart watch: dart run build_runner watch --delete-conflicting-outputs diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9039ad6400..6e9b23ed8f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.139.2 +- API version: 1.140.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen @@ -97,16 +97,20 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | checkBulkUpload *AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | checkExistingAssets +*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | +*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | +*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | *AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | +*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | @@ -328,6 +332,10 @@ Class | Method | HTTP request | Description - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) - [AssetMediaSize](doc//AssetMediaSize.md) - [AssetMediaStatus](doc//AssetMediaStatus.md) + - [AssetMetadataKey](doc//AssetMetadataKey.md) + - [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md) + - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) + - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) - [AssetOrder](doc//AssetOrder.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetStackResponseDto](doc//AssetStackResponseDto.md) @@ -485,6 +493,8 @@ Class | Method | HTTP request | Description - [SyncAssetExifV1](doc//SyncAssetExifV1.md) - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md) - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md) + - [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md) + - [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) - [SyncAuthUserV1](doc//SyncAuthUserV1.md) - [SyncEntityType](doc//SyncEntityType.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f5f353c968..a197f17fa7 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -106,6 +106,10 @@ part 'model/asset_jobs_dto.dart'; part 'model/asset_media_response_dto.dart'; part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; +part 'model/asset_metadata_key.dart'; +part 'model/asset_metadata_response_dto.dart'; +part 'model/asset_metadata_upsert_dto.dart'; +part 'model/asset_metadata_upsert_item_dto.dart'; part 'model/asset_order.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_stack_response_dto.dart'; @@ -263,6 +267,8 @@ part 'model/sync_asset_delete_v1.dart'; part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_face_delete_v1.dart'; part 'model/sync_asset_face_v1.dart'; +part 'model/sync_asset_metadata_delete_v1.dart'; +part 'model/sync_asset_metadata_v1.dart'; part 'model/sync_asset_v1.dart'; part 'model/sync_auth_user_v1.dart'; part 'model/sync_entity_type.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index c0de1a0801..0b53e09938 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -128,6 +128,56 @@ class AssetsApi { return null; } + /// This endpoint requires the `asset.update` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata/{key}' + .replaceAll('{id}', id) + .replaceAll('{key}', key.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.update` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future deleteAssetMetadata(String id, AssetMetadataKey key,) async { + final response = await deleteAssetMetadataWithHttpInfo(id, key,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// This endpoint requires the `asset.delete` permission. /// /// Note: This method returns the HTTP [Response]. @@ -368,6 +418,120 @@ class AssetsApi { return null; } + /// This endpoint requires the `asset.read` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetMetadataWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.read` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + Future?> getAssetMetadata(String id,) async { + final response = await getAssetMetadataWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// This endpoint requires the `asset.read` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata/{key}' + .replaceAll('{id}', id) + .replaceAll('{key}', key.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.read` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataKey] key (required): + Future getAssetMetadataByKey(String id, AssetMetadataKey key,) async { + final response = await getAssetMetadataByKeyWithHttpInfo(id, key,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMetadataResponseDto',) as AssetMetadataResponseDto; + + } + return null; + } + /// This endpoint requires the `asset.statistics` permission. /// /// Note: This method returns the HTTP [Response]. @@ -795,6 +959,66 @@ class AssetsApi { return null; } + /// This endpoint requires the `asset.update` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataUpsertDto] assetMetadataUpsertDto (required): + Future updateAssetMetadataWithHttpInfo(String id, AssetMetadataUpsertDto assetMetadataUpsertDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/metadata' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = assetMetadataUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.update` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetMetadataUpsertDto] assetMetadataUpsertDto (required): + Future?> updateAssetMetadata(String id, AssetMetadataUpsertDto assetMetadataUpsertDto,) async { + final response = await updateAssetMetadataWithHttpInfo(id, assetMetadataUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// This endpoint requires the `asset.update` permission. /// /// Note: This method returns the HTTP [Response]. @@ -855,6 +1079,8 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// + /// * [List] metadata (required): + /// /// * [String] key: /// /// * [String] slug: @@ -873,7 +1099,7 @@ class AssetsApi { /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets'; @@ -936,6 +1162,10 @@ class AssetsApi { hasFields = true; mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId); } + if (metadata != null) { + hasFields = true; + mp.fields[r'metadata'] = parameterToString(metadata); + } if (sidecarData != null) { hasFields = true; mp.fields[r'sidecarData'] = sidecarData.field; @@ -974,6 +1204,8 @@ class AssetsApi { /// /// * [DateTime] fileModifiedAt (required): /// + /// * [List] metadata (required): + /// /// * [String] key: /// /// * [String] slug: @@ -992,8 +1224,8 @@ class AssetsApi { /// * [MultipartFile] sidecarData: /// /// * [AssetVisibility] visibility: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, List metadata, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, metadata, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3f31d4ed90..3ea3b3c3e3 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -266,6 +266,14 @@ class ApiClient { return AssetMediaSizeTypeTransformer().decode(value); case 'AssetMediaStatus': return AssetMediaStatusTypeTransformer().decode(value); + case 'AssetMetadataKey': + return AssetMetadataKeyTypeTransformer().decode(value); + case 'AssetMetadataResponseDto': + return AssetMetadataResponseDto.fromJson(value); + case 'AssetMetadataUpsertDto': + return AssetMetadataUpsertDto.fromJson(value); + case 'AssetMetadataUpsertItemDto': + return AssetMetadataUpsertItemDto.fromJson(value); case 'AssetOrder': return AssetOrderTypeTransformer().decode(value); case 'AssetResponseDto': @@ -580,6 +588,10 @@ class ApiClient { return SyncAssetFaceDeleteV1.fromJson(value); case 'SyncAssetFaceV1': return SyncAssetFaceV1.fromJson(value); + case 'SyncAssetMetadataDeleteV1': + return SyncAssetMetadataDeleteV1.fromJson(value); + case 'SyncAssetMetadataV1': + return SyncAssetMetadataV1.fromJson(value); case 'SyncAssetV1': return SyncAssetV1.fromJson(value); case 'SyncAuthUserV1': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 4adb62768b..b34e9210c8 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -67,6 +67,9 @@ String parameterToString(dynamic value) { if (value is AssetMediaStatus) { return AssetMediaStatusTypeTransformer().encode(value).toString(); } + if (value is AssetMetadataKey) { + return AssetMetadataKeyTypeTransformer().encode(value).toString(); + } if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/albums_add_assets_response_dto.dart b/mobile/openapi/lib/model/albums_add_assets_response_dto.dart index 168b3f2c45..4ad2c5e150 100644 --- a/mobile/openapi/lib/model/albums_add_assets_response_dto.dart +++ b/mobile/openapi/lib/model/albums_add_assets_response_dto.dart @@ -13,16 +13,10 @@ part of openapi.api; class AlbumsAddAssetsResponseDto { /// Returns a new [AlbumsAddAssetsResponseDto] instance. AlbumsAddAssetsResponseDto({ - required this.albumSuccessCount, - required this.assetSuccessCount, this.error, required this.success, }); - int albumSuccessCount; - - int assetSuccessCount; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -35,26 +29,20 @@ class AlbumsAddAssetsResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto && - other.albumSuccessCount == albumSuccessCount && - other.assetSuccessCount == assetSuccessCount && other.error == error && other.success == success; @override int get hashCode => // ignore: unnecessary_parenthesis - (albumSuccessCount.hashCode) + - (assetSuccessCount.hashCode) + (error == null ? 0 : error!.hashCode) + (success.hashCode); @override - String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]'; + String toString() => 'AlbumsAddAssetsResponseDto[error=$error, success=$success]'; Map toJson() { final json = {}; - json[r'albumSuccessCount'] = this.albumSuccessCount; - json[r'assetSuccessCount'] = this.assetSuccessCount; if (this.error != null) { json[r'error'] = this.error; } else { @@ -73,8 +61,6 @@ class AlbumsAddAssetsResponseDto { final json = value.cast(); return AlbumsAddAssetsResponseDto( - albumSuccessCount: mapValueOfType(json, r'albumSuccessCount')!, - assetSuccessCount: mapValueOfType(json, r'assetSuccessCount')!, error: BulkIdErrorReason.fromJson(json[r'error']), success: mapValueOfType(json, r'success')!, ); @@ -124,8 +110,6 @@ class AlbumsAddAssetsResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'albumSuccessCount', - 'assetSuccessCount', 'success', }; } diff --git a/mobile/openapi/lib/model/asset_metadata_key.dart b/mobile/openapi/lib/model/asset_metadata_key.dart new file mode 100644 index 0000000000..70186cd41c --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_key.dart @@ -0,0 +1,82 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetMetadataKey { + /// Instantiate a new enum with the provided [value]. + const AssetMetadataKey._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const mobileApp = AssetMetadataKey._(r'mobile-app'); + + /// List of all possible values in this [enum][AssetMetadataKey]. + static const values = [ + mobileApp, + ]; + + static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataKey.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String, +/// and [decode] dynamic data back to [AssetMetadataKey]. +class AssetMetadataKeyTypeTransformer { + factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._(); + + const AssetMetadataKeyTypeTransformer._(); + + String encode(AssetMetadataKey data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetMetadataKey. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'mobile-app': return AssetMetadataKey.mobileApp; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetMetadataKeyTypeTransformer] instance. + static AssetMetadataKeyTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_response_dto.dart new file mode 100644 index 0000000000..af5769b9bb --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_response_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataResponseDto { + /// Returns a new [AssetMetadataResponseDto] instance. + AssetMetadataResponseDto({ + required this.key, + required this.updatedAt, + required this.value, + }); + + AssetMetadataKey key; + + DateTime updatedAt; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataResponseDto && + other.key == key && + other.updatedAt == updatedAt && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (key.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataResponseDto[key=$key, updatedAt=$updatedAt, value=$value]'; + + Map toJson() { + final json = {}; + json[r'key'] = this.key; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataResponseDto( + key: AssetMetadataKey.fromJson(json[r'key'])!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'key', + 'updatedAt', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_dto.dart new file mode 100644 index 0000000000..45d044feb0 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_upsert_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataUpsertDto { + /// Returns a new [AssetMetadataUpsertDto] instance. + AssetMetadataUpsertDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataUpsertDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataUpsertDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataUpsertDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataUpsertDto( + items: AssetMetadataUpsertItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataUpsertDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataUpsertDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart new file mode 100644 index 0000000000..4b7e6579a1 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetMetadataUpsertItemDto { + /// Returns a new [AssetMetadataUpsertItemDto] instance. + AssetMetadataUpsertItemDto({ + required this.key, + required this.value, + }); + + AssetMetadataKey key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataUpsertItemDto && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataUpsertItemDto[key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataUpsertItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataUpsertItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataUpsertItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataUpsertItemDto( + key: AssetMetadataKey.fromJson(json[r'key'])!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataUpsertItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataUpsertItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataUpsertItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataUpsertItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index e2f60937f8..dc957b3bfc 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -14,6 +14,7 @@ class AssetResponseDto { /// Returns a new [AssetResponseDto] instance. AssetResponseDto({ required this.checksum, + required this.createdAt, required this.deviceAssetId, required this.deviceId, this.duplicateId, @@ -49,6 +50,9 @@ class AssetResponseDto { /// base64 encoded sha1 hash String checksum; + /// The UTC timestamp when the asset was originally uploaded to Immich. + DateTime createdAt; + String deviceAssetId; String deviceId; @@ -142,6 +146,7 @@ class AssetResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && other.checksum == checksum && + other.createdAt == createdAt && other.deviceAssetId == deviceAssetId && other.deviceId == deviceId && other.duplicateId == duplicateId && @@ -177,6 +182,7 @@ class AssetResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (checksum.hashCode) + + (createdAt.hashCode) + (deviceAssetId.hashCode) + (deviceId.hashCode) + (duplicateId == null ? 0 : duplicateId!.hashCode) + @@ -209,11 +215,12 @@ class AssetResponseDto { (visibility.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]'; + String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]'; Map toJson() { final json = {}; json[r'checksum'] = this.checksum; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'deviceAssetId'] = this.deviceAssetId; json[r'deviceId'] = this.deviceId; if (this.duplicateId != null) { @@ -293,6 +300,7 @@ class AssetResponseDto { return AssetResponseDto( checksum: mapValueOfType(json, r'checksum')!, + createdAt: mapDateTime(json, r'createdAt', r'')!, deviceAssetId: mapValueOfType(json, r'deviceAssetId')!, deviceId: mapValueOfType(json, r'deviceId')!, duplicateId: mapValueOfType(json, r'duplicateId'), @@ -371,6 +379,7 @@ class AssetResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'checksum', + 'createdAt', 'deviceAssetId', 'deviceId', 'duration', diff --git a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart new file mode 100644 index 0000000000..c9a7ef4670 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetMetadataDeleteV1 { + /// Returns a new [SyncAssetMetadataDeleteV1] instance. + SyncAssetMetadataDeleteV1({ + required this.assetId, + required this.key, + }); + + String assetId; + + AssetMetadataKey key; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 && + other.assetId == assetId && + other.key == key; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode); + + @override + String toString() => 'SyncAssetMetadataDeleteV1[assetId=$assetId, key=$key]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + return json; + } + + /// Returns a new [SyncAssetMetadataDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetMetadataDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetMetadataDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetMetadataDeleteV1( + assetId: mapValueOfType(json, r'assetId')!, + key: AssetMetadataKey.fromJson(json[r'key'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetMetadataDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetMetadataDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetMetadataDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetMetadataDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart new file mode 100644 index 0000000000..720fcef947 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetMetadataV1 { + /// Returns a new [SyncAssetMetadataV1] instance. + SyncAssetMetadataV1({ + required this.assetId, + required this.key, + required this.value, + }); + + String assetId; + + AssetMetadataKey key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataV1 && + other.assetId == assetId && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'SyncAssetMetadataV1[assetId=$assetId, key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [SyncAssetMetadataV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetMetadataV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetMetadataV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetMetadataV1( + assetId: mapValueOfType(json, r'assetId')!, + key: AssetMetadataKey.fromJson(json[r'key'])!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetMetadataV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetMetadataV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetMetadataV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetMetadataV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index f259fdc9d9..1a86b870e1 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -29,6 +29,8 @@ class SyncEntityType { static const assetV1 = SyncEntityType._(r'AssetV1'); static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); + static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1'); + static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1'); static const partnerV1 = SyncEntityType._(r'PartnerV1'); static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); @@ -76,6 +78,8 @@ class SyncEntityType { assetV1, assetDeleteV1, assetExifV1, + assetMetadataV1, + assetMetadataDeleteV1, partnerV1, partnerDeleteV1, partnerAssetV1, @@ -158,6 +162,8 @@ class SyncEntityTypeTypeTransformer { case r'AssetV1': return SyncEntityType.assetV1; case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; case r'AssetExifV1': return SyncEntityType.assetExifV1; + case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1; + case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1; case r'PartnerV1': return SyncEntityType.partnerV1; case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 8a1857366e..c3dc1c4d61 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -30,6 +30,7 @@ class SyncRequestType { static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); static const assetsV1 = SyncRequestType._(r'AssetsV1'); static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); + static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1'); static const authUsersV1 = SyncRequestType._(r'AuthUsersV1'); static const memoriesV1 = SyncRequestType._(r'MemoriesV1'); static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1'); @@ -52,6 +53,7 @@ class SyncRequestType { albumAssetExifsV1, assetsV1, assetExifsV1, + assetMetadataV1, authUsersV1, memoriesV1, memoryToAssetsV1, @@ -109,6 +111,7 @@ class SyncRequestTypeTypeTransformer { case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; case r'AssetsV1': return SyncRequestType.assetsV1; case r'AssetExifsV1': return SyncRequestType.assetExifsV1; + case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1; case r'AuthUsersV1': return SyncRequestType.authUsersV1; case r'MemoriesV1': return SyncRequestType.memoriesV1; case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1; diff --git a/mobile/pigeon/background_worker_api.dart b/mobile/pigeon/background_worker_api.dart new file mode 100644 index 0000000000..eb1b7a2c5e --- /dev/null +++ b/mobile/pigeon/background_worker_api.dart @@ -0,0 +1,48 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/background_worker_api.g.dart', + swiftOut: 'ios/Runner/Background/BackgroundWorker.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.background'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class BackgroundWorkerFgHostApi { + void enableSyncWorker(); + + // Enables the background upload service with the given callback handle + void enableUploadWorker(int callbackHandle); + + // Disables the background upload service + void disableUploadWorker(); +} + +@HostApi() +abstract class BackgroundWorkerBgHostApi { + // Called from the background flutter engine when it has bootstrapped and established the + // required platform channels to notify the native side to start the background upload + void onInitialized(); +} + +@FlutterApi() +abstract class BackgroundWorkerFlutterApi { + // Android & iOS: Called when the local sync is triggered + @async + void onLocalSync(int? maxSeconds); + + // iOS Only: Called when the iOS background upload is triggered + @async + void onIosUpload(bool isRefresh, int? maxSeconds); + + // Android Only: Called when the Android background upload is triggered + @async + void onAndroidUpload(); + + @async + void cancel(); +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a069751921..b3f659af24 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.139.2+3007 +version: 1.140.0+3010 environment: sdk: '>=3.8.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 36099c10f7..2692aa7593 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2245,6 +2245,203 @@ "description": "This endpoint requires the `asset.update` permission." } }, + "/assets/{id}/metadata": { + "get": { + "operationId": "getAssetMetadata", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.read", + "description": "This endpoint requires the `asset.read` permission." + }, + "put": { + "operationId": "updateAssetMetadata", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.update", + "description": "This endpoint requires the `asset.update` permission." + } + }, + "/assets/{id}/metadata/{key}": { + "delete": { + "operationId": "deleteAssetMetadata", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/AssetMetadataKey" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.update", + "description": "This endpoint requires the `asset.update` permission." + }, + "get": { + "operationId": "getAssetMetadataByKey", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/AssetMetadataKey" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Assets" + ], + "x-immich-permission": "asset.read", + "description": "This endpoint requires the `asset.read` permission." + } + }, "/assets/{id}/original": { "get": { "operationId": "downloadAsset", @@ -9592,7 +9789,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.139.2", + "version": "1.140.0", "contact": {} }, "tags": [], @@ -10007,12 +10204,6 @@ }, "AlbumsAddAssetsResponseDto": { "properties": { - "albumSuccessCount": { - "type": "integer" - }, - "assetSuccessCount": { - "type": "integer" - }, "error": { "allOf": [ { @@ -10025,8 +10216,6 @@ } }, "required": [ - "albumSuccessCount", - "assetSuccessCount", "success" ], "type": "object" @@ -10623,6 +10812,12 @@ "format": "uuid", "type": "string" }, + "metadata": { + "items": { + "$ref": "#/components/schemas/AssetMetadataUpsertItemDto" + }, + "type": "array" + }, "sidecarData": { "format": "binary", "type": "string" @@ -10640,7 +10835,8 @@ "deviceAssetId", "deviceId", "fileCreatedAt", - "fileModifiedAt" + "fileModifiedAt", + "metadata" ], "type": "object" }, @@ -10715,6 +10911,69 @@ ], "type": "string" }, + "AssetMetadataKey": { + "enum": [ + "mobile-app" + ], + "type": "string" + }, + "AssetMetadataResponseDto": { + "properties": { + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "key", + "updatedAt", + "value" + ], + "type": "object" + }, + "AssetMetadataUpsertDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataUpsertItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AssetMetadataUpsertItemDto": { + "properties": { + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + }, + "value": { + "type": "object" + } + }, + "required": [ + "key", + "value" + ], + "type": "object" + }, "AssetOrder": { "enum": [ "asc", @@ -10728,6 +10987,12 @@ "description": "base64 encoded sha1 hash", "type": "string" }, + "createdAt": { + "description": "The UTC timestamp when the asset was originally uploaded to Immich.", + "example": "2024-01-15T20:30:00.000Z", + "format": "date-time", + "type": "string" + }, "deviceAssetId": { "type": "string" }, @@ -10863,6 +11128,7 @@ }, "required": [ "checksum", + "createdAt", "deviceAssetId", "deviceId", "duration", @@ -14945,6 +15211,48 @@ ], "type": "object" }, + "SyncAssetMetadataDeleteV1": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + } + }, + "required": [ + "assetId", + "key" + ], + "type": "object" + }, + "SyncAssetMetadataV1": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetMetadataKey" + } + ] + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "value" + ], + "type": "object" + }, "SyncAssetV1": { "properties": { "checksum": { @@ -15115,6 +15423,8 @@ "AssetV1", "AssetDeleteV1", "AssetExifV1", + "AssetMetadataV1", + "AssetMetadataDeleteV1", "PartnerV1", "PartnerDeleteV1", "PartnerAssetV1", @@ -15374,6 +15684,7 @@ "AlbumAssetExifsV1", "AssetsV1", "AssetExifsV1", + "AssetMetadataV1", "AuthUsersV1", "MemoriesV1", "MemoryToAssetsV1", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index a840986448..e67bcdaac6 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.139.2", + "version": "1.140.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7d319e2fcc..c80e8d6a4b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.139.2 + * 1.140.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -317,6 +317,8 @@ export type TagResponseDto = { export type AssetResponseDto = { /** base64 encoded sha1 hash */ checksum: string; + /** The UTC timestamp when the asset was originally uploaded to Immich. */ + createdAt: string; deviceAssetId: string; deviceId: string; duplicateId?: string | null; @@ -389,8 +391,6 @@ export type AlbumsAddAssetsDto = { assetIds: string[]; }; export type AlbumsAddAssetsResponseDto = { - albumSuccessCount: number; - assetSuccessCount: number; error?: BulkIdErrorReason; success: boolean; }; @@ -447,6 +447,10 @@ export type AssetBulkDeleteDto = { force?: boolean; ids: string[]; }; +export type AssetMetadataUpsertItemDto = { + key: AssetMetadataKey; + value: object; +}; export type AssetMediaCreateDto = { assetData: Blob; deviceAssetId: string; @@ -457,6 +461,7 @@ export type AssetMediaCreateDto = { filename?: string; isFavorite?: boolean; livePhotoVideoId?: string; + metadata: AssetMetadataUpsertItemDto[]; sidecarData?: Blob; visibility?: AssetVisibility; }; @@ -516,6 +521,14 @@ export type UpdateAssetDto = { rating?: number; visibility?: AssetVisibility; }; +export type AssetMetadataResponseDto = { + key: AssetMetadataKey; + updatedAt: string; + value: object; +}; +export type AssetMetadataUpsertDto = { + items: AssetMetadataUpsertItemDto[]; +}; export type AssetMediaReplaceDto = { assetData: Blob; deviceAssetId: string; @@ -2273,6 +2286,61 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } +/** + * This endpoint requires the `asset.read` permission. + */ +export function getAssetMetadata({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataResponseDto[]; + }>(`/assets/${encodeURIComponent(id)}/metadata`, { + ...opts + })); +} +/** + * This endpoint requires the `asset.update` permission. + */ +export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { + id: string; + assetMetadataUpsertDto: AssetMetadataUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataResponseDto[]; + }>(`/assets/${encodeURIComponent(id)}/metadata`, oazapfts.json({ + ...opts, + method: "PUT", + body: assetMetadataUpsertDto + }))); +} +/** + * This endpoint requires the `asset.update` permission. + */ +export function deleteAssetMetadata({ id, key }: { + id: string; + key: AssetMetadataKey; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * This endpoint requires the `asset.read` permission. + */ +export function getAssetMetadataByKey({ id, key }: { + id: string; + key: AssetMetadataKey; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataResponseDto; + }>(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { + ...opts + })); +} /** * This endpoint requires the `asset.download` permission. */ @@ -4725,6 +4793,9 @@ export enum Permission { AdminUserDelete = "adminUser.delete", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } +export enum AssetMetadataKey { + MobileApp = "mobile-app" +} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", @@ -4811,6 +4882,8 @@ export enum SyncEntityType { AssetV1 = "AssetV1", AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", + AssetMetadataV1 = "AssetMetadataV1", + AssetMetadataDeleteV1 = "AssetMetadataDeleteV1", PartnerV1 = "PartnerV1", PartnerDeleteV1 = "PartnerDeleteV1", PartnerAssetV1 = "PartnerAssetV1", @@ -4858,6 +4931,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = "AlbumAssetExifsV1", AssetsV1 = "AssetsV1", AssetExifsV1 = "AssetExifsV1", + AssetMetadataV1 = "AssetMetadataV1", AuthUsersV1 = "AuthUsersV1", MemoriesV1 = "MemoriesV1", MemoryToAssetsV1 = "MemoryToAssetsV1", diff --git a/open-api/typescript-sdk/src/index.ts b/open-api/typescript-sdk/src/index.ts index 77be18f0e7..7adbca4d7e 100644 --- a/open-api/typescript-sdk/src/index.ts +++ b/open-api/typescript-sdk/src/index.ts @@ -6,11 +6,15 @@ export * from './fetch-errors.js'; export interface InitOptions { baseUrl: string; apiKey: string; + headers?: Record; } -export const init = ({ baseUrl, apiKey }: InitOptions) => { +export const init = ({ baseUrl, apiKey, headers }: InitOptions) => { setBaseUrl(baseUrl); setApiKey(apiKey); + if (headers) { + setHeaders(headers); + } }; export const getBaseUrl = () => defaults.baseUrl; @@ -24,6 +28,26 @@ export const setApiKey = (apiKey: string) => { defaults.headers['x-api-key'] = apiKey; }; +export const setHeader = (key: string, value: string) => { + assertNoApiKey(key); + defaults.headers = defaults.headers || {}; + defaults.headers[key] = value; +}; + +export const setHeaders = (headers: Record) => { + defaults.headers = defaults.headers || {}; + for (const [key, value] of Object.entries(headers)) { + assertNoApiKey(key); + defaults.headers[key] = value; + } +}; + +const assertNoApiKey = (headerKey: string) => { + if (headerKey.toLowerCase() === 'x-api-key') { + throw new Error('The API key header can only be set using setApiKey().'); + } +}; + export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`; export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 103cacfdc0..fa24e5b31f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: packageExtensionsChecksum: sha256-DAYr0FTkvKYnvBH4muAER9UE1FVGKhqfRU4/QwA2xPQ= -pnpmfileChecksum: sha256-7GOLcTtuczNumtarIG1mbRinBOSpiOOVzgbeV3Xp4X4= +pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0= importers: @@ -2124,6 +2124,9 @@ packages: resolution: {integrity: sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ==} engines: {node: '>=18.0'} + '@emnapi/runtime@1.4.5': + resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} + '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} engines: {node: '>=12'} @@ -2553,11 +2556,48 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/sharp-darwin-arm64@0.34.2': + resolution: {integrity: sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.2': + resolution: {integrity: sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.1.0': + resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.1.0': + resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.1.0': resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} cpu: [arm64] os: [linux] + '@img/sharp-libvips-linux-arm@1.1.0': + resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.1.0': + resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.1.0': + resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} + cpu: [s390x] + os: [linux] + '@img/sharp-libvips-linux-x64@1.1.0': resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} cpu: [x64] @@ -2579,6 +2619,18 @@ packages: cpu: [arm64] os: [linux] + '@img/sharp-linux-arm@0.34.2': + resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.34.2': + resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + '@img/sharp-linux-x64@0.34.2': resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2597,6 +2649,29 @@ packages: cpu: [x64] os: [linux] + '@img/sharp-wasm32@0.34.2': + resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.2': + resolution: {integrity: sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.2': + resolution: {integrity: sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.2': + resolution: {integrity: sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@immich/ui@0.24.1': resolution: {integrity: sha512-phJ9BHV0+OnKsxXD+5+Te5Amnb1N4ExYpRGSJPYFqutd5WXeN7kZGKZXd3CfcQ1e31SXRy4DsHSGdM1pY7AUgA==} peerDependencies: @@ -13888,6 +13963,11 @@ snapshots: - uglify-js - webpack-cli + '@emnapi/runtime@1.4.5': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.19.12': optional: true @@ -14184,9 +14264,34 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/sharp-darwin-arm64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.1.0 + optional: true + + '@img/sharp-darwin-x64@0.34.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.1.0 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-darwin-x64@1.1.0': + optional: true + '@img/sharp-libvips-linux-arm64@1.1.0': optional: true + '@img/sharp-libvips-linux-arm@1.1.0': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-s390x@1.1.0': + optional: true + '@img/sharp-libvips-linux-x64@1.1.0': optional: true @@ -14201,6 +14306,16 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.1.0 optional: true + '@img/sharp-linux-arm@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + + '@img/sharp-linux-s390x@0.34.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.1.0 + optional: true + '@img/sharp-linux-x64@0.34.2': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.1.0 @@ -14216,6 +14331,20 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.1.0 optional: true + '@img/sharp-wasm32@0.34.2': + dependencies: + '@emnapi/runtime': 1.4.5 + optional: true + + '@img/sharp-win32-arm64@0.34.2': + optional: true + + '@img/sharp-win32-ia32@0.34.2': + optional: true + + '@img/sharp-win32-x64@0.34.2': + optional: true + '@immich/ui@0.24.1(@internationalized/date@3.8.2)(svelte@5.35.5)': dependencies: '@mdi/js': 7.4.47 @@ -23758,14 +23887,27 @@ snapshots: node-gyp: 11.2.0 semver: 7.7.2 optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.2 + '@img/sharp-darwin-x64': 0.34.2 + '@img/sharp-libvips-darwin-arm64': 1.1.0 + '@img/sharp-libvips-darwin-x64': 1.1.0 + '@img/sharp-libvips-linux-arm': 1.1.0 '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-ppc64': 1.1.0 + '@img/sharp-libvips-linux-s390x': 1.1.0 '@img/sharp-libvips-linux-x64': 1.1.0 '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + '@img/sharp-linux-arm': 0.34.2 '@img/sharp-linux-arm64': 0.34.2 + '@img/sharp-linux-s390x': 0.34.2 '@img/sharp-linux-x64': 0.34.2 '@img/sharp-linuxmusl-arm64': 0.34.2 '@img/sharp-linuxmusl-x64': 0.34.2 + '@img/sharp-wasm32': 0.34.2 + '@img/sharp-win32-arm64': 0.34.2 + '@img/sharp-win32-ia32': 0.34.2 + '@img/sharp-win32-x64': 0.34.2 transitivePeerDependencies: - supports-color diff --git a/readme_i18n/README_it_IT.md b/readme_i18n/README_it_IT.md index 358faeb40c..5a368fe1f9 100644 --- a/readme_i18n/README_it_IT.md +++ b/readme_i18n/README_it_IT.md @@ -1,22 +1,23 @@

-
- License: AGPLv3 +
+ Licenza: AGPLv3 - + Discord -
-
+
+

- +

-

Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video

+

Soluzione ad alte prestazioni per la gestione self-hosted di foto e video


- +
+

English Català @@ -36,64 +37,97 @@ ภาษาไทย

-## Declino di responsabilità +## Avvertenze -- ⚠️ Il progetto è in una fase **molto intensa** di sviluppo. -- ⚠️ Possibilità di bug e cambiamenti rilevanti. -- ⚠️ **Non utilizzare l'app come unico salvataggio delle tue foto e dei tuoi video.** -- ⚠️ Utilizza sempre una tecnica [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) di backup per le foto e i video a cui tieni! +- ⚠️ Il progetto è in fase di sviluppo **molto attivo**. +- ⚠️ Possono esserci bug o cambiamenti radicali, che possono non essere retrocompatibili (breaking changes). +- ⚠️ **Non usare l’app come unico modo per archiviare le tue foto e i tuoi video.** +- ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni! -## Contenuto +> [!NOTE] +> La documentazione principale, comprese le guide all’installazione, si trova su https://immich.app/. -- [Documentazione Ufficiale](https://immich.app/docs) -- [Roadmap](https://github.com/orgs/immich-app/projects/1) -- [Demo](#demo) -- [Funzionalità](#features) -- [Introduzione](https://immich.app/docs/overview/introduction) -- [Installazione](https://immich.app/docs/install/requirements) -- [Linee Guida per Contribuire](https://immich.app/docs/overview/support-the-project) +## Link utili -## Documentazione - -La documentazione ufficiale, inclusa la guida all'installazione, è disponibile qui: https://immich.app/. +- [Documentazione](https://immich.app/docs) +- [Informazioni](https://immich.app/docs/overview/introduction) +- [Installazione](https://immich.app/docs/install/requirements) +- [Roadmap](https://immich.app/roadmap) +- [Demo](#demo) +- [Funzionalità](#funzionalità) +- [Traduzioni](https://immich.app/docs/developer/translations) +- [Contribuire](https://immich.app/docs/overview/support-the-project) ## Demo -Prova la demo del progetto https://demo.immich.app. Sull'app mobile, imposta `https://demo.immich.app` come `Server Endpoint URL` +Accedi alla demo [qui](https://demo.immich.app). +Per l’app mobile puoi usare `https://demo.immich.app` come `Server Endpoint URL`. -```bash title="Demo Credential" -Credenziali di accesso -email: demo@immich.app -password: demo -``` +### Credenziali di accesso -# Funzionalità +| Email | Password | +| --------------- | -------- | +| demo@immich.app | demo | -| Funzionalità | Mobile | Web | -| ---------------------------------------------- | ------ | --- | -| Caricamento e visualizzazione di foto e video | Sì | Sì | -| Backup automatico quando l'app è in esecuzione | Sì | N/D | -| Selezione degli album per backup | Sì | N/D | -| Download foto e video sul dispositivo | Sì | Sì | -| Supporto multi utente | Sì | Sì | -| Album e album condivisi | Sì | Sì | -| Barra di scorrimento con trascinamento | Sì | Sì | -| Supporto formati raw | Sì | Sì | -| Visualizzazione metadata (EXIF, map) | Sì | Sì | -| Ricerca per metadata, oggetti, volti e CLIP | Sì | Sì | -| Funzioni di amministrazione degli utenti | No | Sì | -| Backup in background | Sì | N/D | -| Scroll virtuale | Sì | Sì | -| Supporto OAuth | Sì | Sì | -| API Keys | N/D | Sì | -| Backup e riproduzione di LivePhoto | iOS | Sì | -| Archiviazione impostata dall'utente | Sì | Sì | -| Condivisione pubblica | No | Sì | -| Archivio e Preferiti | Sì | Sì | -| Mappa globale | Sì | Sì | -| Collaborazione con utenti | Sì | Sì | -| Riconoscimento facciale e categorizzazione | Sì | Sì | -| Ricordi (x anni fa) | Sì | Sì | -| Supporto offline | Sì | No | -| Galleria sola lettura | Sì | Sì | -| Foto raggruppate | Sì | Sì | +## Funzionalità + +| Funzionalità | Mobile | Web | +| :------------------------------------------ | ------ | --- | +| Caricare e visualizzare foto e video | Sì | Sì | +| Backup automatico all’apertura dell’app | Sì | N/D | +| Evita la duplicazione dei file | Sì | Sì | +| Backup selettivo di album | Sì | N/D | +| Scaricare foto e video sul dispositivo | Sì | Sì | +| Supporto multi-utente | Sì | Sì | +| Album e album condivisi | Sì | Sì | +| Barra di scorrimento trascinabile | Sì | Sì | +| Supporto ai formati RAW | Sì | Sì | +| Visualizzazione metadati (EXIF, mappa) | Sì | Sì | +| Ricerca per metadati, oggetti, volti, CLIP | Sì | Sì | +| Funzioni amministrative (gestione utenti) | No | Sì | +| Backup in background | Sì | N/D | +| Scorrimento virtuale | Sì | Sì | +| Supporto OAuth | Sì | Sì | +| Chiavi API | N/D | Sì | +| Backup e riproduzione LivePhoto/MotionPhoto | Sì | Sì | +| Supporto immagini a 360° | No | Sì | +| Struttura di archiviazione personalizzata | Sì | Sì | +| Condivisione pubblica | Sì | Sì | +| Archivio e preferiti | Sì | Sì | +| Mappa globale | Sì | Sì | +| Condivisione con partner | Sì | Sì | +| Riconoscimento e raggruppamento facciale | Sì | Sì | +| Ricordi (anni fa) | Sì | Sì | +| Supporto offline | Sì | No | +| Galleria in sola lettura | Sì | Sì | +| Foto impilate | Sì | Sì | +| Tag | No | Sì | +| Vista per cartelle | Sì | Sì | + +## Traduzioni + +Scopri di più sulle traduzioni [qui](https://immich.app/docs/developer/translations). + + +Stato traduzioni + + +## Attività del repository + +![Attività](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Immagine analisi repobeats") + +## Cronologia delle stelle + + + + + + Grafico storico delle stelle + + + +## Contributori + + + + diff --git a/server/Dockerfile b/server/Dockerfile index ae3198c5d3..bd5c55402e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -91,9 +91,8 @@ FROM prod-builder-base AS server-prod WORKDIR /usr/src/app COPY ./package* ./pnpm* .pnpmfile.cjs ./ COPY ./server ./server/ -# SHARP_IGNORE_GLOBAL_LIBVIPS because 'deploy' will always build sharp bindings from source RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \ - pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned + SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned # web production build FROM prod-builder-base AS web-prod diff --git a/server/nest-cli.json b/server/nest-cli.json index 1eaf1888d5..16a8b8a09b 100644 --- a/server/nest-cli.json +++ b/server/nest-cli.json @@ -3,7 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true, + "deleteOutDir": false, "webpack": false, "plugins": [ { diff --git a/server/package.json b/server/package.json index b377d1de28..217fc80dfe 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.139.2", + "version": "1.140.0", "description": "", "author": "", "private": true, diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 66d2d7c206..7a7a37fe2e 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -1,4 +1,5 @@ import { AssetController } from 'src/controllers/asset.controller'; +import { AssetMetadataKey } from 'src/enum'; import { AssetService } from 'src/services/asset.service'; import request from 'supertest'; import { factory } from 'test/small.factory'; @@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils' describe(AssetController.name, () => { let ctx: ControllerContext; + const service = mockBaseService(AssetService); beforeAll(async () => { - ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]); + ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]); return () => ctx.close(); }); beforeEach(() => { ctx.reset(); + service.resetAllMocks(); }); describe('PUT /assets', () => { @@ -115,4 +118,120 @@ describe(AssetController.name, () => { ); }); }); + + describe('GET /assets/:id/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('PUT /assets/:id/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); + }); + + it('should require items to be an array', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({}); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['items must be an array'])); + }); + + it('should require each item to have a valid key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/${factory.uuid()}/metadata`) + .send({ items: [{ key: 'someKey' }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]), + ), + ); + }); + + it('should require each item to have a value', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/${factory.uuid()}/metadata`) + .send({ items: [{ key: 'mobile-app', value: null }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])), + ); + }); + + describe(AssetMetadataKey.MobileApp, () => { + it('should accept valid data and pass to service correctly', async () => { + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()) + .put(`/assets/${assetId}/metadata`) + .send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] }); + expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, { + items: [{ key: 'mobile-app', value: { iCloudId: '123' } }], + }); + expect(status).toBe(200); + }); + + it('should work without iCloudId', async () => { + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()) + .put(`/assets/${assetId}/metadata`) + .send({ items: [{ key: 'mobile-app', value: {} }] }); + expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, { + items: [{ key: 'mobile-app', value: {} }], + }); + expect(status).toBe(200); + }); + }); + }); + + describe('GET /assets/:id/metadata/:key', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); + }); + + it('should require a valid key', async () => { + const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining([expect.stringContaining('key must be one of the following value')]), + ), + ); + }); + }); + + describe('DELETE /assets/:id/metadata/:key', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); + }); + + it('should require a valid key', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]), + ); + }); + }); }); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index edb5aab602..1f320f6595 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -6,6 +6,9 @@ import { AssetBulkDeleteDto, AssetBulkUpdateDto, AssetJobsDto, + AssetMetadataResponseDto, + AssetMetadataRouteParams, + AssetMetadataUpsertDto, AssetStatsDto, AssetStatsResponseDto, DeviceIdDto, @@ -85,4 +88,36 @@ export class AssetController { ): Promise { return this.service.update(auth, id, dto); } + + @Get(':id/metadata') + @Authenticated({ permission: Permission.AssetRead }) + getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getMetadata(auth, id); + } + + @Put(':id/metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + updateAssetMetadata( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AssetMetadataUpsertDto, + ): Promise { + return this.service.upsertMetadata(auth, id, dto); + } + + @Get(':id/metadata/:key') + @Authenticated({ permission: Permission.AssetRead }) + getAssetMetadataByKey( + @Auth() auth: AuthDto, + @Param() { id, key }: AssetMetadataRouteParams, + ): Promise { + return this.service.getMetadataByKey(auth, id, key); + } + + @Delete(':id/metadata/:key') + @Authenticated({ permission: Permission.AssetUpdate }) + @HttpCode(HttpStatus.NO_CONTENT) + deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise { + return this.service.deleteMetadataByKey(auth, id, key); + } } diff --git a/server/src/controllers/user-admin.controller.spec.ts b/server/src/controllers/user-admin.controller.spec.ts new file mode 100644 index 0000000000..bd9c966d42 --- /dev/null +++ b/server/src/controllers/user-admin.controller.spec.ts @@ -0,0 +1,79 @@ +import { UserAdminController } from 'src/controllers/user-admin.controller'; +import { UserAdminCreateDto } from 'src/dtos/user.dto'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserAdminService } from 'src/services/user-admin.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(UserAdminController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(UserAdminService); + + beforeAll(async () => { + ctx = await controllerSetup(UserAdminController, [ + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + { provide: UserAdminService, useValue: service }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /admin/users', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/admin/users'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('POST /admin/users', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/admin/users'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should not allow decimal quota`, async () => { + const dto: UserAdminCreateDto = { + email: 'user@immich.app', + password: 'test', + name: 'Test User', + quotaSizeInBytes: 1.2, + }; + + const { status, body } = await request(ctx.getHttpServer()) + .post(`/admin/users`) + .set('Authorization', `Bearer token`) + .send(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number']))); + }); + }); + + describe('GET /admin/users/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('PUT /admin/users/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should not allow decimal quota`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/admin/users/${factory.uuid()}`) + .set('Authorization', `Bearer token`) + .send({ quotaSizeInBytes: 1.2 }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number']))); + }); + }); +}); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 73630b63cb..00f5759aac 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -65,10 +65,6 @@ export class AlbumsAddAssetsDto { export class AlbumsAddAssetsResponseDto { success!: boolean; - @ApiProperty({ type: 'integer' }) - albumSuccessCount!: number; - @ApiProperty({ type: 'integer' }) - assetSuccessCount!: number; @ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true }) error?: BulkIdErrorReason; } diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index ea86e087d8..25395000cd 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto'; import { AssetVisibility } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; @@ -64,6 +65,12 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateUUID({ optional: true }) livePhotoVideoId?: string; + @Optional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataUpsertItemDto) + metadata!: AssetMetadataUpsertItemDto[]; + @ApiProperty({ type: 'string', format: 'binary', required: false }) [UploadFieldName.SIDECAR_DATA]?: any; } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 98ed8669f0..f60f2a8824 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -37,6 +37,13 @@ export class SanitizedAssetResponseDto { } export class AssetResponseDto extends SanitizedAssetResponseDto { + @ApiProperty({ + type: 'string', + format: 'date-time', + description: 'The UTC timestamp when the asset was originally uploaded to Immich.', + example: '2024-01-15T20:30:00.000Z', + }) + createdAt!: Date; deviceAssetId!: string; deviceId!: string; ownerId!: string; @@ -190,6 +197,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset return { id: entity.id, + createdAt: entity.createdAt, deviceAssetId: entity.deviceAssetId, ownerId: entity.ownerId, owner: entity.owner ? mapUser(entity.owner) : undefined, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 31e5679e76..6a89b7e2cf 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -1,21 +1,25 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + IsArray, IsDateString, IsInt, IsLatitude, IsLongitude, IsNotEmpty, + IsObject, IsPositive, IsString, IsTimeZone, Max, Min, ValidateIf, + ValidateNested, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetType, AssetVisibility } from 'src/enum'; +import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; +import { AssetMetadata, AssetMetadataItem } from 'src/types'; import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -135,6 +139,53 @@ export class AssetStatsResponseDto { total!: number; } +export class AssetMetadataRouteParams { + @ValidateUUID() + id!: string; + + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; +} + +export class AssetMetadataUpsertDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataUpsertItemDto) + items!: AssetMetadataUpsertItemDto[]; +} + +export class AssetMetadataUpsertItemDto implements AssetMetadataItem { + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; + + @IsObject() + @ValidateNested() + @Type((options) => { + switch (options?.object.key) { + case AssetMetadataKey.MobileApp: { + return AssetMetadataMobileAppDto; + } + default: { + return Object; + } + } + }) + value!: AssetMetadata[AssetMetadataKey]; +} + +export class AssetMetadataMobileAppDto { + @IsString() + @Optional() + iCloudId?: string; +} + +export class AssetMetadataResponseDto { + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; + value!: object; + updatedAt!: Date; +} + export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { return { images: stats[AssetType.Image], diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 9ac85755ab..0fae619e0f 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -4,6 +4,7 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, + AssetMetadataKey, AssetOrder, AssetType, AssetVisibility, @@ -162,6 +163,21 @@ export class SyncAssetExifV1 { fps!: number | null; } +@ExtraModel() +export class SyncAssetMetadataV1 { + assetId!: string; + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; + value!: object; +} + +@ExtraModel() +export class SyncAssetMetadataDeleteV1 { + assetId!: string; + @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) + key!: AssetMetadataKey; +} + @ExtraModel() export class SyncAlbumDeleteV1 { albumId!: string; @@ -328,6 +344,8 @@ export type SyncItem = { [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; [SyncEntityType.AssetV1]: SyncAssetV1; [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1; + [SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1; + [SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1; [SyncEntityType.AssetExifV1]: SyncAssetExifV1; [SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 0da86bfcb5..443178aa10 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; +import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; @@ -91,7 +91,7 @@ export class UserAdminCreateDto { storageLabel?: string | null; @Optional({ nullable: true }) - @IsNumber() + @IsInt() @Min(0) @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; @@ -137,7 +137,7 @@ export class UserAdminUpdateDto { shouldChangePassword?: boolean; @Optional({ nullable: true }) - @IsNumber() + @IsInt() @Min(0) @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; diff --git a/server/src/enum.ts b/server/src/enum.ts index 02ef222883..bf72b24a14 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -276,6 +276,10 @@ export enum UserMetadataKey { Onboarding = 'onboarding', } +export enum AssetMetadataKey { + MobileApp = 'mobile-app', +} + export enum UserAvatarColor { Primary = 'primary', Pink = 'pink', @@ -627,6 +631,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AssetsV1 = 'AssetsV1', AssetExifsV1 = 'AssetExifsV1', + AssetMetadataV1 = 'AssetMetadataV1', AuthUsersV1 = 'AuthUsersV1', MemoriesV1 = 'MemoriesV1', MemoryToAssetsV1 = 'MemoryToAssetsV1', @@ -650,6 +655,8 @@ export enum SyncEntityType { AssetV1 = 'AssetV1', AssetDeleteV1 = 'AssetDeleteV1', AssetExifV1 = 'AssetExifV1', + AssetMetadataV1 = 'AssetMetadataV1', + AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1', PartnerV1 = 'PartnerV1', PartnerDeleteV1 = 'PartnerDeleteV1', diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index df8163be3e..ef1b5fe79e 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -468,9 +468,8 @@ where "asset"."visibility" != $1 and "asset"."deletedAt" is null and "job_status"."previewAt" is not null - and "job_status"."facesRecognizedAt" is null order by - "asset"."createdAt" desc + "asset"."fileCreatedAt" desc -- AssetJobRepository.streamForMigrationJob select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 712fb08a50..1283ff0a66 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -19,6 +19,33 @@ returning "dateTimeOriginal", "timeZone" +-- AssetRepository.getMetadata +select + "key", + "value", + "updatedAt" +from + "asset_metadata" +where + "assetId" = $1 + +-- AssetRepository.getMetadataByKey +select + "key", + "value", + "updatedAt" +from + "asset_metadata" +where + "assetId" = $1 + and "key" = $2 + +-- AssetRepository.deleteMetadataByKey +delete from "asset_metadata" +where + "assetId" = $1 + and "key" = $2 + -- AssetRepository.getByDayOfYear with "res" as ( @@ -277,7 +304,7 @@ with epoch from ( - asset."localDateTime" - asset."fileCreatedAt" at time zone 'UTC' + asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC' ) )::real / 3600 as "localOffsetHours", "asset"."ownerId", diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 0e13b98b5d..0f46846c14 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -38,7 +38,11 @@ from select "album".*, coalesce( - json_agg("assets") filter ( + json_agg( + "assets" + order by + "assets"."fileCreatedAt" asc + ) filter ( where "assets"."id" is not null ), diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 80021368a0..3e70baa5d4 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -539,6 +539,37 @@ where order by "asset_face"."updateId" asc +-- SyncRepository.assetMetadata.getDeletes +select + "asset_metadata_audit"."id", + "assetId", + "key" +from + "asset_metadata_audit" as "asset_metadata_audit" + left join "asset" on "asset"."id" = "asset_metadata_audit"."assetId" +where + "asset_metadata_audit"."id" < $1 + and "asset_metadata_audit"."id" > $2 + and "asset"."ownerId" = $3 +order by + "asset_metadata_audit"."id" asc + +-- SyncRepository.assetMetadata.getUpserts +select + "assetId", + "key", + "value", + "asset_metadata"."updateId" +from + "asset_metadata" as "asset_metadata" + inner join "asset" on "asset"."id" = "asset_metadata"."assetId" +where + "asset_metadata"."updateId" < $1 + and "asset_metadata"."updateId" > $2 + and "asset"."ownerId" = $3 +order by + "asset_metadata"."updateId" asc + -- SyncRepository.authUser.getUpserts select "id", diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index 81f5ca20b8..31da10123f 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -12,6 +12,8 @@ where and "fileCreatedAt" is not null and "fileModifiedAt" is not null and "localDateTime" is not null +order by + "directoryPath" asc -- ViewRepository.getAssetsByOriginalPath select diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index f077c36c41..b023068f16 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -321,6 +321,14 @@ export class AlbumRepository { .execute(); } + @Chunked({ chunkSize: 30_000 }) + async addAssetIdsToAlbums(values: { albumsId: string; assetsId: string }[]): Promise { + if (values.length === 0) { + return; + } + await this.db.insertInto('album_asset').values(values).execute(); + } + /** * Makes sure all thumbnails for albums are updated by: * - Removing thumbnails from albums without assets diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 0500bb867f..f7715b027c 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -334,9 +334,9 @@ export class AssetJobRepository { @GenerateSql({ params: [], stream: true }) streamForDetectFacesJob(force?: boolean) { return this.assetsWithPreviews() - .$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null)) + .$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null)) .select(['asset.id']) - .orderBy('asset.createdAt', 'desc') + .orderBy('asset.fileCreatedAt', 'desc') .stream(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 61ccbf6541..ae595e35ae 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,15 +1,16 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely'; +import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetMetadataItem } from 'src/types'; import { anyUuid, asUuid, @@ -210,6 +211,43 @@ export class AssetRepository { .execute(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getMetadata(assetId: string) { + return this.db + .selectFrom('asset_metadata') + .select(['key', 'value', 'updatedAt']) + .where('assetId', '=', assetId) + .execute(); + } + + upsertMetadata(id: string, items: AssetMetadataItem[]) { + return this.db + .insertInto('asset_metadata') + .values(items.map((item) => ({ assetId: id, ...item }))) + .onConflict((oc) => + oc + .columns(['assetId', 'key']) + .doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })), + ) + .returning(['key', 'value', 'updatedAt']) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + getMetadataByKey(assetId: string, key: AssetMetadataKey) { + return this.db + .selectFrom('asset_metadata') + .select(['key', 'value', 'updatedAt']) + .where('assetId', '=', assetId) + .where('key', '=', key) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async deleteMetadataByKey(id: string, key: AssetMetadataKey) { + await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); + } + create(asset: Insertable) { return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); } @@ -566,7 +604,7 @@ export class AssetRepository { sql`asset.type = 'IMAGE'`.as('isImage'), sql`asset."deletedAt" is not null`.as('isTrashed'), 'asset.livePhotoVideoId', - sql`extract(epoch from (asset."localDateTime" - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as( + sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as( 'localOffsetHours', ), 'asset.ownerId', diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index f653bb8179..725304938c 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; @@ -68,12 +68,6 @@ const withPerson = (eb: ExpressionBuilder) => { ).as('person'); }; -const withAsset = (eb: ExpressionBuilder) => { - return jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as( - 'asset', - ); -}; - const withFaceSearch = (eb: ExpressionBuilder) => { return jsonObjectFrom( eb.selectFrom('face_search').selectAll('face_search').whereRef('face_search.faceId', '=', 'asset_face.id'), @@ -481,7 +475,12 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withAsset) + .select((eb) => + jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as( + 'asset', + ), + ) + .$narrowType<{ asset: NotNull }>() .select(withPerson) .where('asset_face.assetId', 'in', assetIds) .where('asset_face.personId', 'in', personIds) diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 54eab7c86f..cdade25f76 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -86,7 +86,16 @@ export class SharedLinkRepository { (join) => join.onTrue(), ) .select((eb) => - eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'), + eb.fn + .coalesce( + eb.fn + .jsonAgg('assets') + .orderBy('assets.fileCreatedAt', 'asc') + .filterWhere('assets.id', 'is not', null), + + sql`'[]'`, + ) + .as('assets'), ) .select((eb) => eb.fn.toJson('owner').as('owner')) .groupBy(['album.id', sql`"owner".*`]) diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 13e933fd2f..398d49bd5d 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -54,6 +54,7 @@ export class SyncRepository { asset: AssetSync; assetExif: AssetExifSync; assetFace: AssetFaceSync; + assetMetadata: AssetMetadataSync; authUser: AuthUserSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; @@ -75,6 +76,7 @@ export class SyncRepository { this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); this.assetFace = new AssetFaceSync(this.db); + this.assetMetadata = new AssetMetadataSync(this.db); this.authUser = new AuthUserSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); @@ -685,3 +687,23 @@ class UserMetadataSync extends BaseSync { .stream(); } } + +class AssetMetadataSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true }) + getDeletes(options: SyncQueryOptions, userId: string) { + return this.auditQuery('asset_metadata_audit', options) + .select(['asset_metadata_audit.id', 'assetId', 'key']) + .leftJoin('asset', 'asset.id', 'asset_metadata_audit.assetId') + .where('asset.ownerId', '=', userId) + .stream(); + } + + @GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true }) + getUpserts(options: SyncQueryOptions, userId: string) { + return this.upsertQuery('asset_metadata', options) + .select(['assetId', 'key', 'value', 'asset_metadata.updateId']) + .innerJoin('asset', 'asset.id', 'asset_metadata.assetId') + .where('asset.ownerId', '=', userId) + .stream(); + } +} diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index a63a4cc553..44f4a2bb9c 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -7,13 +7,10 @@ import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetType, AssetVisibility, UserStatus } from 'src/enum'; import { DB } from 'src/schema'; -import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { UserMetadata, UserMetadataItem } from 'src/types'; import { asUuid } from 'src/utils/database'; -type Upsert = Insertable; - export interface UserListFilter { id?: string; withDeleted?: boolean; @@ -211,12 +208,12 @@ export class UserRepository { async upsertMetadata(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { await this.db .insertInto('user_metadata') - .values({ userId: id, key, value } as Upsert) + .values({ userId: id, key, value }) .onConflict((oc) => oc.columns(['userId', 'key']).doUpdateSet({ key, value, - } as Upsert), + }), ) .execute(); } diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index 93c1280191..ceab79f6eb 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -20,6 +20,7 @@ export class ViewRepository { .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) .where('localDateTime', 'is not', null) + .orderBy('directoryPath', 'asc') .execute(); return results.map((row) => row.directoryPath.replaceAll(/\/$/g, '')); diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 786e7a1ffa..e255742b5d 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -230,6 +230,19 @@ export const user_metadata_audit = registerFunction({ END`, }); +export const asset_metadata_audit = registerFunction({ + name: 'asset_metadata_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_metadata_audit ("assetId", "key") + SELECT "assetId", "key" + FROM OLD; + RETURN NULL; + END`, +}); + export const asset_face_audit = registerFunction({ name: 'asset_face_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 8982437b34..48f454d455 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -5,6 +5,7 @@ import { album_user_delete_audit, asset_delete_audit, asset_face_audit, + asset_metadata_audit, f_concat_ws, f_unaccent, immich_uuid_v7, @@ -32,6 +33,8 @@ import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { AuditTable } from 'src/schema/tables/audit.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; @@ -81,6 +84,8 @@ export class ImmichDatabase { AssetAuditTable, AssetFaceTable, AssetFaceAuditTable, + AssetMetadataTable, + AssetMetadataAuditTable, AssetJobStatusTable, AssetTable, AssetFileTable, @@ -135,6 +140,7 @@ export class ImmichDatabase { stack_delete_audit, person_delete_audit, user_metadata_audit, + asset_metadata_audit, asset_face_audit, ]; @@ -164,6 +170,8 @@ export interface DB { asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; asset_file: AssetFileTable; + asset_metadata: AssetMetadataTable; + asset_metadata_audit: AssetMetadataAuditTable; asset_job_status: AssetJobStatusTable; asset_audit: AssetAuditTable; diff --git a/server/src/schema/migrations/1756318797207-AssetMetadataTables.ts b/server/src/schema/migrations/1756318797207-AssetMetadataTables.ts new file mode 100644 index 0000000000..ba0bad9d9a --- /dev/null +++ b/server/src/schema/migrations/1756318797207-AssetMetadataTables.ts @@ -0,0 +1,58 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_metadata_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_metadata_audit ("assetId", "key") + SELECT "assetId", "key" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_metadata_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "assetId" uuid NOT NULL, + "key" character varying NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "asset_metadata_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "asset_metadata_audit_assetId_idx" ON "asset_metadata_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_metadata_audit_key_idx" ON "asset_metadata_audit" ("key");`.execute(db); + await sql`CREATE INDEX "asset_metadata_audit_deletedAt_idx" ON "asset_metadata_audit" ("deletedAt");`.execute(db); + await sql`CREATE TABLE "asset_metadata" ( + "assetId" uuid NOT NULL, + "key" character varying NOT NULL, + "value" jsonb NOT NULL, + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "asset_metadata_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "asset_metadata_pkey" PRIMARY KEY ("assetId", "key") +);`.execute(db); + await sql`CREATE INDEX "asset_metadata_updateId_idx" ON "asset_metadata" ("updateId");`.execute(db); + await sql`CREATE INDEX "asset_metadata_updatedAt_idx" ON "asset_metadata" ("updatedAt");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_metadata_audit" + AFTER DELETE ON "asset_metadata" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_metadata_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_metadata_updated_at" + BEFORE UPDATE ON "asset_metadata" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_metadata_audit', '{"type":"function","name":"asset_metadata_audit","sql":"CREATE OR REPLACE FUNCTION asset_metadata_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_metadata_audit (\\"assetId\\", \\"key\\")\\n SELECT \\"assetId\\", \\"key\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_audit', '{"type":"trigger","name":"asset_metadata_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_audit\\"\\n AFTER DELETE ON \\"asset_metadata\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_metadata_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_updated_at', '{"type":"trigger","name":"asset_metadata_updated_at","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_updated_at\\"\\n BEFORE UPDATE ON \\"asset_metadata\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "asset_metadata_audit";`.execute(db); + await sql`DROP TABLE "asset_metadata";`.execute(db); + await sql`DROP FUNCTION asset_metadata_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_metadata_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_updated_at';`.execute(db); +} diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts new file mode 100644 index 0000000000..3b94ce6d1a --- /dev/null +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -0,0 +1,18 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { AssetMetadataKey } from 'src/enum'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('asset_metadata_audit') +export class AssetMetadataAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @Column({ index: true }) + key!: AssetMetadataKey; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts new file mode 100644 index 0000000000..486101408d --- /dev/null +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -0,0 +1,46 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { AssetMetadataKey } from 'src/enum'; +import { asset_metadata_audit } from 'src/schema/functions'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { + AfterDeleteTrigger, + Column, + ForeignKeyColumn, + Generated, + PrimaryColumn, + Table, + Timestamp, + UpdateDateColumn, +} from 'src/sql-tools'; +import { AssetMetadata, AssetMetadataItem } from 'src/types'; + +@UpdatedAtTrigger('asset_metadata_updated_at') +@Table('asset_metadata') +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_metadata_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) +export class AssetMetadataTable implements AssetMetadataItem { + @ForeignKeyColumn(() => AssetTable, { + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + primary: true, + // [assetId, key] is the PK constraint + index: false, + }) + assetId!: string; + + @PrimaryColumn({ type: 'character varying' }) + key!: T; + + @Column({ type: 'jsonb' }) + value!: AssetMetadata[T]; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; + + @UpdateDateColumn({ index: true }) + updatedAt!: Generated; +} diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 480a95b3ab..c098c2389b 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -779,9 +779,7 @@ describe(AlbumService.name, () => { describe('addAssetsToAlbums', () => { it('should allow the owner to add assets', async () => { - mocks.access.album.checkOwnerAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.album.getById .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) @@ -793,7 +791,7 @@ describe(AlbumService.name, () => { albumIds: ['album-123', 'album-321'], assetIds: ['asset-1', 'asset-2', 'asset-3'], }), - ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { @@ -806,14 +804,18 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ + { albumsId: 'album-123', assetsId: 'asset-1' }, + { albumsId: 'album-123', assetsId: 'asset-2' }, + { albumsId: 'album-123', assetsId: 'asset-3' }, + { albumsId: 'album-321', assetsId: 'asset-1' }, + { albumsId: 'album-321', assetsId: 'asset-2' }, + { albumsId: 'album-321', assetsId: 'asset-3' }, + ]); }); it('should not set the thumbnail if the album has one already', async () => { - mocks.access.album.checkOwnerAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.album.getById .mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })) @@ -825,7 +827,7 @@ describe(AlbumService.name, () => { albumIds: ['album-123', 'album-321'], assetIds: ['asset-1', 'asset-2', 'asset-3'], }), - ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { @@ -838,14 +840,18 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-id', }); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ + { albumsId: 'album-123', assetsId: 'asset-1' }, + { albumsId: 'album-123', assetsId: 'asset-2' }, + { albumsId: 'album-123', assetsId: 'asset-3' }, + { albumsId: 'album-321', assetsId: 'asset-1' }, + { albumsId: 'album-321', assetsId: 'asset-2' }, + { albumsId: 'album-321', assetsId: 'asset-3' }, + ]); }); it('should allow a shared user to add assets', async () => { - mocks.access.album.checkSharedAlbumAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.album.getById .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) @@ -857,7 +863,7 @@ describe(AlbumService.name, () => { albumIds: ['album-123', 'album-321'], assetIds: ['asset-1', 'asset-2', 'asset-3'], }), - ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { @@ -870,8 +876,14 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ + { albumsId: 'album-123', assetsId: 'asset-1' }, + { albumsId: 'album-123', assetsId: 'asset-2' }, + { albumsId: 'album-123', assetsId: 'asset-3' }, + { albumsId: 'album-321', assetsId: 'asset-1' }, + { albumsId: 'album-321', assetsId: 'asset-2' }, + { albumsId: 'album-321', assetsId: 'asset-3' }, + ]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: 'album-123', userId: 'user-id', @@ -899,18 +911,14 @@ describe(AlbumService.name, () => { }), ).resolves.toEqual({ success: false, - albumSuccessCount: 0, - assetSuccessCount: 0, - error: BulkIdErrorReason.UNKNOWN, + error: BulkIdErrorReason.NO_PERMISSION, }); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should not allow a shared link user to add assets to multiple albums', async () => { - mocks.access.album.checkSharedLinkAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set()); + mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set(['album-123'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.album.getById .mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser)) @@ -922,7 +930,7 @@ describe(AlbumService.name, () => { albumIds: ['album-123', 'album-321'], assetIds: ['asset-1', 'asset-2', 'asset-3'], }), - ).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); + ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { @@ -930,8 +938,11 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ + { albumsId: 'album-123', assetsId: 'asset-1' }, + { albumsId: 'album-123', assetsId: 'asset-2' }, + { albumsId: 'album-123', assetsId: 'asset-3' }, + ]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: 'album-123', userId: 'admin_id', @@ -939,14 +950,12 @@ describe(AlbumService.name, () => { }); expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, - new Set(['album-123']), + new Set(['album-123', 'album-321']), ); }); it('should allow adding assets shared via partner sharing', async () => { - mocks.access.album.checkOwnerAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); mocks.album.getById .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) @@ -958,7 +967,7 @@ describe(AlbumService.name, () => { albumIds: ['album-123', 'album-321'], assetIds: ['asset-1', 'asset-2', 'asset-3'], }), - ).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 }); + ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(2); expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', { @@ -971,8 +980,14 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ + { albumsId: 'album-123', assetsId: 'asset-1' }, + { albumsId: 'album-123', assetsId: 'asset-2' }, + { albumsId: 'album-123', assetsId: 'asset-3' }, + { albumsId: 'album-321', assetsId: 'asset-1' }, + { albumsId: 'album-321', assetsId: 'asset-2' }, + { albumsId: 'album-321', assetsId: 'asset-3' }, + ]); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['asset-1', 'asset-2', 'asset-3']), @@ -980,23 +995,21 @@ describe(AlbumService.name, () => { }); it('should skip some duplicate assets', async () => { - mocks.access.album.checkOwnerAccess - .mockResolvedValueOnce(new Set(['album-123'])) - .mockResolvedValueOnce(new Set(['album-321'])); + mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123', 'album-321'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.album.getById - .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) - .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); mocks.album.getAssetIds .mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3'])) .mockResolvedValueOnce(new Set()); + mocks.album.getById + .mockResolvedValueOnce(_.cloneDeep(albumStub.empty)) + .mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset)); await expect( sut.addAssetsToAlbums(authStub.admin, { albumIds: ['album-123', 'album-321'], assetIds: ['asset-1', 'asset-2', 'asset-3'], }), - ).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 }); + ).resolves.toEqual({ success: true, error: undefined }); expect(mocks.album.update).toHaveBeenCalledTimes(1); expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', { @@ -1004,8 +1017,11 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1); - expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']); + expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([ + { albumsId: 'album-321', assetsId: 'asset-1' }, + { albumsId: 'album-321', assetsId: 'asset-2' }, + { albumsId: 'album-321', assetsId: 'asset-3' }, + ]); }); it('should skip all duplicate assets', async () => { @@ -1025,8 +1041,6 @@ describe(AlbumService.name, () => { }), ).resolves.toEqual({ success: false, - albumSuccessCount: 0, - assetSuccessCount: 0, error: BulkIdErrorReason.DUPLICATE, }); @@ -1050,9 +1064,7 @@ describe(AlbumService.name, () => { }), ).resolves.toEqual({ success: false, - albumSuccessCount: 0, - assetSuccessCount: 0, - error: BulkIdErrorReason.UNKNOWN, + error: BulkIdErrorReason.NO_PERMISSION, }); expect(mocks.album.update).not.toHaveBeenCalled(); @@ -1080,9 +1092,7 @@ describe(AlbumService.name, () => { }), ).resolves.toEqual({ success: false, - albumSuccessCount: 0, - assetSuccessCount: 0, - error: BulkIdErrorReason.UNKNOWN, + error: BulkIdErrorReason.NO_PERMISSION, }); expect(mocks.album.update).not.toHaveBeenCalled(); @@ -1103,9 +1113,7 @@ describe(AlbumService.name, () => { }), ).resolves.toEqual({ success: false, - albumSuccessCount: 0, - assetSuccessCount: 0, - error: BulkIdErrorReason.UNKNOWN, + error: BulkIdErrorReason.NO_PERMISSION, }); expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 551dae9775..419549cd99 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -199,36 +199,57 @@ export class AlbumService extends BaseService { async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise { const results: AlbumsAddAssetsResponseDto = { success: false, - albumSuccessCount: 0, - assetSuccessCount: 0, error: BulkIdErrorReason.DUPLICATE, }; - const successfulAssetIds: Set = new Set(); - for (const albumId of dto.albumIds) { - try { - const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds }); - let success = false; - for (const res of albumResults) { - if (res.success) { - success = true; - results.success = true; - results.error = undefined; - successfulAssetIds.add(res.id); - } else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) { - results.error = BulkIdErrorReason.UNKNOWN; - } - } - if (success) { - results.albumSuccessCount++; - } - } catch { - if (results.error) { - results.error = BulkIdErrorReason.UNKNOWN; - } + const allowedAlbumIds = await this.checkAccess({ + auth, + permission: Permission.AlbumAssetCreate, + ids: dto.albumIds, + }); + if (allowedAlbumIds.size === 0) { + results.error = BulkIdErrorReason.NO_PERMISSION; + return results; + } + + const allowedAssetIds = await this.checkAccess({ auth, permission: Permission.AssetShare, ids: dto.assetIds }); + if (allowedAssetIds.size === 0) { + results.error = BulkIdErrorReason.NO_PERMISSION; + return results; + } + + const albumAssetValues: { albumsId: string; assetsId: string }[] = []; + const events: { id: string; recipients: string[] }[] = []; + for (const albumId of allowedAlbumIds) { + const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]); + const notPresentAssetIds = [...allowedAssetIds].filter((id) => !existingAssetIds.has(id)); + if (notPresentAssetIds.length === 0) { + continue; + } + const album = await this.findOrFail(albumId, { withAssets: false }); + results.error = undefined; + results.success = true; + + for (const assetId of notPresentAssetIds) { + albumAssetValues.push({ albumsId: albumId, assetsId: assetId }); + } + await this.albumRepository.update(albumId, { + id: albumId, + updatedAt: new Date(), + albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0], + }); + const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter( + (userId) => userId !== auth.user.id, + ); + events.push({ id: albumId, recipients: allUsersExceptUs }); + } + + await this.albumRepository.addAssetIdsToAlbums(albumAssetValues); + for (const event of events) { + for (const recipientId of event.recipients) { + await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId }); } } - results.assetSuccessCount = successfulAssetIds.size; return results; } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 517a1f665f..54bbedea9c 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -27,7 +27,7 @@ import { BaseService } from 'src/services/base.service'; import { UploadFile } from 'src/types'; import { requireUploadAccess } from 'src/utils/access'; import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; -import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; +import { isAssetChecksumConstraint } from 'src/utils/database'; import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; @@ -318,7 +318,7 @@ export class AssetMediaService extends BaseService { }); // handle duplicates with a success response - if (error.constraint_name === ASSET_CHECKSUM_CONSTRAINT) { + if (isAssetChecksumConstraint(error)) { const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); if (!duplicateId) { this.logger.error(`Error locating duplicate for checksum constraint`); @@ -423,6 +423,10 @@ export class AssetMediaService extends BaseService { sidecarPath: sidecarFile?.originalPath, }); + if (dto.metadata) { + await this.assetRepository.upsertMetadata(asset.id, dto.metadata); + } + if (sidecarFile) { await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 9a2c580707..725e3cff15 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -9,12 +9,14 @@ import { AssetBulkUpdateDto, AssetJobName, AssetJobsDto, + AssetMetadataResponseDto, + AssetMetadataUpsertDto, AssetStatsDto, UpdateAssetDto, mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; +import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; @@ -93,7 +95,7 @@ export class AssetService extends BaseService { } } - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); + await this.updateExif({ id, description, dateTimeOriginal, latitude, longitude, rating }); const asset = await this.assetRepository.update({ id, ...rest }); @@ -273,6 +275,31 @@ export class AssetService extends BaseService { }); } + async getMetadata(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); + return this.assetRepository.getMetadata(id); + } + + async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); + return this.assetRepository.upsertMetadata(id, dto.items); + } + + async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); + + const item = await this.assetRepository.getMetadataByKey(id, key); + if (!item) { + throw new BadRequestException(`Metadata with key "${key}" not found for asset with id "${id}"`); + } + return item; + } + + async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); + return this.assetRepository.deleteMetadataByKey(id, key); + } + async run(auth: AuthDto, dto: AssetJobsDto) { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); @@ -313,7 +340,7 @@ export class AssetService extends BaseService { return asset; } - private async updateMetadata(dto: ISidecarWriteJob) { + private async updateExif(dto: ISidecarWriteJob) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 4c96ad0062..c5f6971a83 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -123,6 +123,10 @@ export class LibraryService extends BaseService { { usePolling: false, ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 5000, + pollInterval: 1000, + }, }, { onReady: () => _resolve(), diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 42f7c7bba3..e494dd9bcc 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -42,7 +42,6 @@ describe(MediaService.name, () => { mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image])); mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); await sut.handleQueueGenerateThumbnails({ force: true }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index c675b7200d..ac2b927510 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -29,6 +29,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { isAssetChecksumConstraint } from 'src/utils/database'; import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; @@ -545,47 +546,62 @@ export class MetadataService extends BaseService { }); } const checksum = this.cryptoRepository.hashSha1(video); + const checksumQuery = { ownerId: asset.ownerId, libraryId: asset.libraryId ?? undefined, checksum }; - let motionAsset = await this.assetRepository.getByChecksum({ - ownerId: asset.ownerId, - libraryId: asset.libraryId ?? undefined, - checksum, - }); - if (motionAsset) { + let motionAsset = await this.assetRepository.getByChecksum(checksumQuery); + let isNewMotionAsset = false; + + if (!motionAsset) { + try { + const motionAssetId = this.cryptoRepository.randomUUID(); + motionAsset = await this.assetRepository.create({ + id: motionAssetId, + libraryId: asset.libraryId, + type: AssetType.Video, + fileCreatedAt: dates.dateTimeOriginal, + fileModifiedAt: stats.mtime, + localDateTime: dates.localDateTime, + checksum, + ownerId: asset.ownerId, + originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), + originalFileName: `${path.parse(asset.originalFileName).name}.mp4`, + visibility: AssetVisibility.Hidden, + deviceAssetId: 'NONE', + deviceId: 'NONE', + }); + + isNewMotionAsset = true; + + if (!asset.isExternal) { + await this.userRepository.updateUsage(asset.ownerId, video.byteLength); + } + } catch (error) { + if (!isAssetChecksumConstraint(error)) { + throw error; + } + + motionAsset = await this.assetRepository.getByChecksum(checksumQuery); + if (!motionAsset) { + this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`); + return; + } + } + } + + if (!isNewMotionAsset) { this.logger.debugFn(() => { const base64Checksum = checksum.toString('base64'); return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`; }); + } - // Hide the motion photo video asset if it's not already hidden to prepare for linking - if (motionAsset.visibility === AssetVisibility.Timeline) { - await this.assetRepository.update({ - id: motionAsset.id, - visibility: AssetVisibility.Hidden, - }); - this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`); - } - } else { - const motionAssetId = this.cryptoRepository.randomUUID(); - motionAsset = await this.assetRepository.create({ - id: motionAssetId, - libraryId: asset.libraryId, - type: AssetType.Video, - fileCreatedAt: dates.dateTimeOriginal, - fileModifiedAt: stats.mtime, - localDateTime: dates.localDateTime, - checksum, - ownerId: asset.ownerId, - originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), - originalFileName: `${path.parse(asset.originalFileName).name}.mp4`, + // Hide the motion photo video asset if it's not already hidden to prepare for linking + if (motionAsset.visibility === AssetVisibility.Timeline) { + await this.assetRepository.update({ + id: motionAsset.id, visibility: AssetVisibility.Hidden, - deviceAssetId: 'NONE', - deviceId: 'NONE', }); - - if (!asset.isExternal) { - await this.userRepository.updateUsage(asset.ownerId, video.byteLength); - } + this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`); } if (asset.livePhotoVideoId !== motionAsset.id) { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index d0c43c3dad..344b69efde 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -197,6 +197,10 @@ export class PersonService extends BaseService { throw new BadRequestException('Invalid assetId for feature face'); } + if (face.asset.isOffline) { + throw new BadRequestException('An offline asset cannot be used for feature face'); + } + faceId = face.id; } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 6b8512eacb..677c799fb8 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -74,6 +74,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.PeopleV1, SyncRequestType.AssetFacesV1, SyncRequestType.UserMetadataV1, + SyncRequestType.AssetMetadataV1, ]; const throwSessionRequired = () => { @@ -156,6 +157,7 @@ export class SyncService extends BaseService { [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id), + [SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth), [SyncRequestType.PartnerAssetExifsV1]: () => this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id), [SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap), @@ -759,6 +761,33 @@ export class SyncService extends BaseService { } } + private async syncAssetMetadataV1( + options: SyncQueryOptions, + response: Writable, + checkpointMap: CheckpointMap, + auth: AuthDto, + ) { + const deleteType = SyncEntityType.AssetMetadataDeleteV1; + const deletes = this.syncRepository.assetMetadata.getDeletes( + { ...options, ack: checkpointMap[deleteType] }, + auth.user.id, + ); + + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetMetadataV1; + const upserts = this.syncRepository.assetMetadata.getUpserts( + { ...options, ack: checkpointMap[upsertType] }, + auth.user.id, + ); + + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) { const { type, sessionId, createId } = item; await this.syncCheckpointRepository.upsertAll([ diff --git a/server/src/types.ts b/server/src/types.ts index 9cd1aa996b..b77dd4df6e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,6 +1,7 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { + AssetMetadataKey, AssetOrder, AssetType, DatabaseSslMode, @@ -465,11 +466,6 @@ export interface SystemMetadata extends Record = { - key: T; - value: UserMetadata[T]; -}; - export interface UserPreferences { albums: { defaultAssetOrder: AssetOrder; @@ -514,8 +510,22 @@ export interface UserPreferences { }; } +export type UserMetadataItem = { + key: T; + value: UserMetadata[T]; +}; + export interface UserMetadata extends Record> { [UserMetadataKey.Preferences]: DeepPartial; [UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string }; [UserMetadataKey.Onboarding]: { isOnboarded: boolean }; } + +export type AssetMetadataItem = { + key: T; + value: AssetMetadata[T]; +}; + +export interface AssetMetadata extends Record> { + [AssetMetadataKey.MobileApp]: { iCloudId: string }; +} diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 1ef9b8e926..d9fe6b7897 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -14,7 +14,7 @@ import { import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { parse } from 'pg-connection-string'; -import postgres, { Notice } from 'postgres'; +import postgres, { Notice, PostgresError } from 'postgres'; import { columns, Exif, Person } from 'src/database'; import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; @@ -153,6 +153,10 @@ export function toJson { + return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum'; +}; + export function withDefaultVisibility(qb: SelectQueryBuilder) { return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]); } diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 1cd36f1f23..19a62ad193 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -46,6 +46,7 @@ const assetInfo: ExifResponseDto = { const assetResponse: AssetResponseDto = { id: 'id_1', + createdAt: today, deviceAssetId: 'device_asset_id_1', ownerId: 'user_id_1', deviceId: 'device_id_1', diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 8b0878a35a..87c8406f55 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -33,6 +33,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository'; @@ -286,6 +287,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case PersonRepository: case SearchRepository: case SessionRepository: + case SharedLinkRepository: case StackRepository: case SyncRepository: case SyncCheckpointRepository: @@ -391,7 +393,7 @@ const assetInsert = (asset: Partial> = {}) => { checksum: randomBytes(32), type: AssetType.Image, originalPath: '/path/to/something.jpg', - ownerId: '@immich.cloud', + ownerId: 'not-a-valid-uuid', isFavorite: false, fileCreatedAt: now, fileModifiedAt: now, diff --git a/server/test/medium/specs/services/shared-link.service.spec.ts b/server/test/medium/specs/services/shared-link.service.spec.ts new file mode 100644 index 0000000000..88e7e86df5 --- /dev/null +++ b/server/test/medium/specs/services/shared-link.service.spec.ts @@ -0,0 +1,65 @@ +import { Kysely } from 'kysely'; +import { randomBytes } from 'node:crypto'; +import { SharedLinkType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { DB } from 'src/schema'; +import { SharedLinkService } from 'src/services/shared-link.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(SharedLinkService, { + database: db || defaultDatabase, + real: [AccessRepository, DatabaseRepository, SharedLinkRepository], + mock: [LoggingRepository, StorageRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SharedLinkService.name, () => { + describe('get', () => { + it('should return the correct dates on the shared link album', async () => { + const { sut, ctx } = setup(); + + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const dates = ['2021-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z']; + + for (const date of dates) { + const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, make: 'Canon' }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + } + + const sharedLinkRepo = ctx.get(SharedLinkRepository); + + const sharedLink = await sharedLinkRepo.create({ + key: randomBytes(16), + id: factory.uuid(), + userId: user.id, + albumId: album.id, + allowUpload: true, + type: SharedLinkType.Album, + }); + + await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({ + album: expect.objectContaining({ + startDate: '2020-01-01T00:00:00+00:00', + endDate: '2022-01-01T00:00:00+00:00', + }), + }); + }); + }); +}); diff --git a/server/test/medium/specs/sync/sync-asset-metadata.spec.ts b/server/test/medium/specs/sync/sync-asset-metadata.spec.ts new file mode 100644 index 0000000000..84353883a2 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-metadata.spec.ts @@ -0,0 +1,126 @@ +import { Kysely } from 'kysely'; +import { AssetMetadataKey, SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.AssetMetadataV1, () => { + it('should detect and sync new asset metadata', async () => { + const { auth, user, ctx } = await setup(); + + const assetRepo = ctx.get(AssetRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc123' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([]); + }); + + it('should update asset metadata', async () => { + const { auth, user, ctx } = await setup(); + + const assetRepo = ctx.get(AssetRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc123' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc456' } }]); + + const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(updatedResponse).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc456' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, updatedResponse); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([]); + }); +}); + +describe(SyncEntityType.AssetMetadataDeleteV1, () => { + it('should delete and sync asset metadata', async () => { + const { auth, user, ctx } = await setup(); + + const assetRepo = ctx.get(AssetRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await assetRepo.upsertMetadata(asset.id, [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'abc123' } }]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + key: AssetMetadataKey.MobileApp, + assetId: asset.id, + value: { iCloudId: 'abc123' }, + }, + type: 'AssetMetadataV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + + await assetRepo.deleteMetadataByKey(asset.id, AssetMetadataKey.MobileApp); + + await expect(ctx.syncStream(auth, [SyncRequestType.AssetMetadataV1])).resolves.toEqual([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + key: AssetMetadataKey.MobileApp, + }, + type: 'AssetMetadataDeleteV1', + }, + ]); + }); +}); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 79e3d506f3..e735b37564 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -41,5 +41,9 @@ export const newAssetRepositoryMock = (): Mocked \ No newline at end of file diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 452510f508..0737031635 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -491,7 +491,7 @@ onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} - haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} + haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} /> {/if} {:else} diff --git a/web/src/lib/components/asset-viewer/detail-panel-location.svelte b/web/src/lib/components/asset-viewer/detail-panel-location.svelte index 42cbefadf1..783eba9c5f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-location.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-location.svelte @@ -16,18 +16,22 @@ let isShowChangeLocation = $state(false); - async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) { + const onClose = async (point?: { lng: number; lat: number }) => { isShowChangeLocation = false; + if (!point) { + return; + } + try { asset = await updateAsset({ id: asset.id, - updateAssetDto: { latitude: gps.lat, longitude: gps.lng }, + updateAssetDto: { latitude: point.lat, longitude: point.lng }, }); } catch (error) { handleError(error, $t('errors.unable_to_change_location')); } - } + }; {#if asset.exifInfo?.country} @@ -85,6 +89,6 @@ {#if isShowChangeLocation} - (isShowChangeLocation = false)} /> + {/if} diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index 4b8bb40f77..6b5c77a189 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -7,12 +7,10 @@ import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { loopVideo as loopVideoPreference, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; - import { handleError } from '$lib/utils/handle-error'; import { AssetMediaSize } from '@immich/sdk'; import { onDestroy, onMount } from 'svelte'; import type { SwipeCustomEvent } from 'svelte-gestures'; import { swipe } from 'svelte-gestures'; - import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; interface Props { @@ -40,7 +38,6 @@ let videoPlayer: HTMLVideoElement | undefined = $state(); let isLoading = $state(true); let assetFileUrl = $state(''); - let forceMuted = $state(false); let isScrubbing = $state(false); let showVideo = $state(false); @@ -49,7 +46,6 @@ showVideo = true; assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey }); if (videoPlayer) { - forceMuted = false; videoPlayer.load(); } }); @@ -67,23 +63,27 @@ onVideoStarted(); } } catch (error) { - if (error instanceof DOMException && error.name === 'NotAllowedError' && !forceMuted) { + if (error instanceof DOMException && error.name === 'NotAllowedError') { await tryForceMutedPlay(video); return; } - handleError(error, $t('errors.unable_to_play_video')); + // auto-play failed } finally { isLoading = false; } }; const tryForceMutedPlay = async (video: HTMLVideoElement) => { + if (video.muted) { + return; + } + try { video.muted = true; await handleCanPlay(video); - } catch (error) { - handleError(error, $t('errors.unable_to_play_video')); + } catch { + // muted auto-play failed } }; @@ -134,18 +134,14 @@ onswipe={onSwipe} oncanplay={(e) => handleCanPlay(e.currentTarget)} onended={onVideoEnded} - onvolumechange={(e) => { - if (!forceMuted) { - $videoViewerMuted = e.currentTarget.muted; - } - }} + onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)} onseeking={() => (isScrubbing = true)} onseeked={() => (isScrubbing = false)} onplaying={(e) => { e.currentTarget.focus(); }} onclose={() => onClose()} - muted={forceMuted || $videoViewerMuted} + muted={$videoViewerMuted} bind:volume={$videoViewerVolume} poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })} src={assetFileUrl} diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index e4b590b8ea..e01f2dc4f6 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -197,7 +197,7 @@
((loaded = true), (thumbError = errored))} /> {#if asset.isVideo} -
+
{:else if asset.isImage && asset.livePhotoVideoId} -
+
+
+
+
diff --git a/web/src/lib/components/shared-components/number-range-input.svelte b/web/src/lib/components/shared-components/number-range-input.svelte index 6ee993cf88..95a9a12a98 100644 --- a/web/src/lib/components/shared-components/number-range-input.svelte +++ b/web/src/lib/components/shared-components/number-range-input.svelte @@ -25,7 +25,8 @@ }: Props = $props(); const oninput = () => { - if (!value) { + // value can be 0 + if (value === undefined) { return; } diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index 428dafebe3..985400a033 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -91,9 +91,9 @@
{#if uploadAsset.state === UploadState.STARTED} -
+
-

+

{#if uploadAsset.message} {uploadAsset.message} {:else} diff --git a/web/src/lib/components/utilities-page/geolocation/geolocation.svelte b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte new file mode 100644 index 0000000000..0efde6df7e --- /dev/null +++ b/web/src/lib/components/utilities-page/geolocation/geolocation.svelte @@ -0,0 +1,104 @@ + + +

+
+ { + if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) { + onLocation({ latitude: asset.exifInfo?.latitude, longitude: asset.exifInfo?.longitude }); + } else { + onSelectAsset(asset); + } + }} + onSelect={() => onSelectAsset(asset)} + onMouseEvent={() => onMouseEvent(asset)} + selected={assetInteraction.hasSelectedAsset(asset.id)} + selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} + thumbnailSize={boxWidth} + readonly={hasGps} + /> + + {#if hasGps} +
+ {$t('gps')} +
+ {:else} +
+ {$t('gps_missing')} +
+ {/if} +
+ +
+

+ {new Date(asset.localDateTime).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+

+ {new Date(asset.localDateTime).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: 'UTC', + })} +

+ {#if hasGps} +

+ {asset.exifInfo?.country} +

+

+ {asset.exifInfo?.city} +

+ {/if} +
+
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index 5484ce4ea0..97e205fcd1 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,29 +1,23 @@

{$t('organize_your_library').toUpperCase()}

- - - - {$t('review_duplicates')} - - - - - {$t('review_large_files')} - + {#each links as link (link.href)} + + + {link.label} + + {/each}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index f2de6d5deb..a4cdb656b4 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -52,6 +52,7 @@ export enum AppRoute { UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', LARGE_FILES = '/utilities/large-files', + GEOLOCATION = '/utilities/geolocation', FOLDERS = '/folders', TAGS = '/tags', diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 2e31fa9bc1..172cd07a02 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -419,14 +419,22 @@ export class TimelineManager { if (!this.isInitialized) { await this.initTask.waitUntilCompletion(); } + let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {}; if (monthGroup) { return monthGroup; } - const asset = toTimelineAsset(await getAssetInfo({ ...authManager.params, id })); + + const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null); + if (!response) { + return; + } + + const asset = toTimelineAsset(response); if (!asset || this.isExcluded(asset)) { return; } + monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false }); if (monthGroup?.findAssetById({ id })) { return monthGroup; diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 650d136843..529ef4b137 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -7,7 +7,8 @@ } from '$lib/components/shared-components/album-selection/album-selection-utils'; import { albumViewSettings } from '$lib/stores/preferences.store'; import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; - import { Button, Modal, ModalBody } from '@immich/ui'; + import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui'; + import { mdiKeyboardReturn } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import AlbumListItem from '../components/asset-viewer/album-list-item.svelte'; @@ -74,9 +75,9 @@ }; const handleMultiSubmit = () => { - const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!)); - if (albums.size > 0) { - onClose([...albums]); + const selectedAlbums = new Set(albums.filter(({ id }) => multiSelectedAlbumIds.includes(id))); + if (selectedAlbums.size > 0) { + onClose([...selectedAlbums]); } else { onClose(); } @@ -133,7 +134,7 @@ await onEnter(); break; } - case 'm': { + case 'Control': { e.preventDefault(); handleMultiSelect(); break; @@ -199,4 +200,22 @@ > {/if} + +
+
+
+ + + + {$t('to_select')} +
+
+ + CTRL + + {$t('to_multi_select')} +
+
+
+
diff --git a/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte b/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte new file mode 100644 index 0000000000..7bad707447 --- /dev/null +++ b/web/src/lib/modals/GeolocationUpdateConfirmModal.svelte @@ -0,0 +1,33 @@ + + + + +

+ {$t('update_location_action_prompt', { + values: { + count: assetCount, + }, + })} +

+ +

- {$t('latitude')}: {location.latitude}

+

- {$t('longitude')}: {location.longitude}

+
+ + + + + + +
diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/lib/modals/UserCreateModal.svelte index 3d2085f7ca..a47ae7e94b 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/lib/modals/UserCreateModal.svelte @@ -122,7 +122,7 @@ - + {#if quotaSizeWarning} {$t('errors.quota_higher_than_disk_size')} {/if} diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index 20c473f0d3..8238c6c5d8 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -83,6 +83,7 @@ name="quotaSize" placeholder={$t('unlimited')} type="number" + step="1" min="0" bind:value={quotaSize} /> diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 8ced866f6d..09205a2fc9 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -74,6 +74,10 @@ class ApiError extends Error { } } +export const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +}; + export const uploadRequest = async (options: UploadRequestOptions): Promise<{ data: T; status: number }> => { const { onUploadProgress: onProgress, data, url } = options; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 40eb9c9733..822820911e 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -9,7 +9,7 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { preferences } from '$lib/stores/user.store'; -import { downloadRequest, withError } from '$lib/utils'; +import { downloadRequest, sleep, withError } from '$lib/utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; @@ -278,7 +278,12 @@ export const downloadFile = async (asset: AssetResponseDto) => { const queryParams = asQueryString(authManager.params); - for (const { filename, id } of assets) { + for (const [i, { filename, id }] of assets.entries()) { + if (i !== 0) { + // play nice with Safari + await sleep(500); + } + try { notificationController.show({ type: NotificationType.Info, diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index 90db980e2a..bca57863a9 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,5 +1,5 @@ import { writable } from 'svelte/store'; -import { getAlbumDateRange, timeToSeconds } from './date-time'; +import { buildDateRangeFromYearMonthAndDay, getAlbumDateRange, timeToSeconds } from './date-time'; describe('converting time to seconds', () => { it('parses hh:mm:ss correctly', () => { @@ -11,15 +11,41 @@ describe('converting time to seconds', () => { }); it('parses h:m:s.S correctly', () => { - expect(timeToSeconds('1:2:3.4')).toBeCloseTo(3723.4); + expect(timeToSeconds('1:2:3.4')).toBe(0); // Non-standard format, Luxon returns NaN }); it('parses hhh:mm:ss.SSS correctly', () => { - expect(timeToSeconds('100:02:03.456')).toBeCloseTo(360_123.456); + expect(timeToSeconds('100:02:03.456')).toBe(0); // Non-standard format, Luxon returns NaN }); it('ignores ignores double milliseconds hh:mm:ss.SSS.SSSSSS', () => { - expect(timeToSeconds('01:02:03.456.123456')).toBeCloseTo(3723.456); + expect(timeToSeconds('01:02:03.456.123456')).toBe(0); // Non-standard format, Luxon returns NaN + }); + + // Test edge cases that can cause crashes + it('handles "0" string input', () => { + expect(timeToSeconds('0')).toBe(0); + }); + + it('handles empty string input', () => { + expect(timeToSeconds('')).toBe(0); + }); + + it('parses HH:MM format correctly', () => { + expect(timeToSeconds('01:02')).toBe(3720); // 1 hour 2 minutes = 3720 seconds + }); + + it('handles malformed time strings', () => { + expect(timeToSeconds('invalid')).toBe(0); + }); + + it('parses single hour format correctly', () => { + expect(timeToSeconds('01')).toBe(3600); // Luxon interprets "01" as 1 hour + }); + + it('handles time strings with invalid numbers', () => { + expect(timeToSeconds('aa:bb:cc')).toBe(0); + expect(timeToSeconds('01:bb:03')).toBe(0); }); }); @@ -49,3 +75,24 @@ describe('getAlbumDate', () => { expect(getAlbumDateRange({ startDate: '2021-01-01T00:00:00+05:00' })).toEqual('Jan 1, 2021'); }); }); + +describe('buildDateRangeFromYearMonthAndDay', () => { + it('should build correct date range for a specific day', () => { + const result = buildDateRangeFromYearMonthAndDay(2023, 1, 8); + + expect(result.from).toContain('2023-01-08T00:00:00'); + expect(result.to).toContain('2023-01-09T00:00:00'); + }); + + it('should build correct date range for a month', () => { + const result = buildDateRangeFromYearMonthAndDay(2023, 2); + expect(result.from).toContain('2023-02-01T00:00:00'); + expect(result.to).toContain('2023-03-01T00:00:00'); + }); + + it('should build correct date range for a year', () => { + const result = buildDateRangeFromYearMonthAndDay(2023); + expect(result.from).toContain('2023-01-01T00:00:00'); + expect(result.to).toContain('2024-01-01T00:00:00'); + }); +}); diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index 16236ba135..bf87d041cc 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -7,14 +7,14 @@ import { get } from 'svelte/store'; * Convert time like `01:02:03.456` to seconds. */ export function timeToSeconds(time: string) { - const parts = time.split(':'); - parts[2] = parts[2].split('.').slice(0, 2).join('.'); + if (!time || time === '0') { + return 0; + } - const [hours, minutes, seconds] = parts.map(Number); + const seconds = Duration.fromISOTime(time).as('seconds'); - return Duration.fromObject({ hours, minutes, seconds }).as('seconds'); + return Number.isNaN(seconds) ? 0 : seconds; } - export function parseUtcDate(date: string) { return DateTime.fromISO(date, { zone: 'UTC' }).toUTC(); } @@ -85,3 +85,33 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string */ export const asLocalTimeISO = (date: DateTime) => (date.setZone('utc', { keepLocalTime: true }) as DateTime).toISO(); + +/** + * Creates a date range for filtering assets based on year, month, and day parameters + */ +export const buildDateRangeFromYearMonthAndDay = (year: number, month?: number, day?: number) => { + const baseDate = DateTime.fromObject({ + year, + month: month || 1, + day: day || 1, + }); + + let from: DateTime; + let to: DateTime; + + if (day) { + from = baseDate.startOf('day'); + to = baseDate.plus({ days: 1 }).startOf('day'); + } else if (month) { + from = baseDate.startOf('month'); + to = baseDate.plus({ months: 1 }).startOf('month'); + } else { + from = baseDate.startOf('year'); + to = baseDate.plus({ years: 1 }).startOf('year'); + } + + return { + from: from.toISO() || undefined, + to: to.toISO() || undefined, + }; +}; diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index 642c8165df..c3fe051f12 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -145,3 +145,16 @@ export const clearQueryParam = async (queryParam: string, url: URL) => { await goto(url, { keepFocus: true }); } }; + +export const getQueryValue = (queryKey: string) => { + const url = globalThis.location.href; + const urlObject = new URL(url); + return urlObject.searchParams.get(queryKey); +}; + +export const setQueryValue = async (queryKey: string, queryValue: string) => { + const url = globalThis.location.href; + const urlObject = new URL(url); + urlObject.searchParams.set(queryKey, queryValue); + await goto(urlObject, { keepFocus: true }); +}; diff --git a/web/src/lib/utils/string-utils.ts b/web/src/lib/utils/string-utils.ts index 0170c34737..3795af40c7 100644 --- a/web/src/lib/utils/string-utils.ts +++ b/web/src/lib/utils/string-utils.ts @@ -5,3 +5,13 @@ export const removeAccents = (str: string) => { export const normalizeSearchString = (str: string) => { return removeAccents(str.toLocaleLowerCase()); }; + +export const buildDateString = (year: number, month?: number, day?: number) => { + return [ + year.toString(), + month && !Number.isNaN(month) ? month.toString() : undefined, + day && !Number.isNaN(day) ? day.toString() : undefined, + ] + .filter((date) => date !== undefined) + .join('-'); +}; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 4370d0ec7c..eba030e2d9 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -403,6 +403,7 @@ const handleShareLink = async () => { const sharedLink = await modalManager.show(SharedLinkCreateModal, { albumId: album.id }); if (sharedLink) { + await refreshAlbum(); await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); } }; @@ -411,7 +412,7 @@ const changed = await modalManager.show(AlbumUsersModal, { album }); if (changed) { - album = await getAlbumInfo({ id: album.id, withoutAssets: true }); + await refreshAlbum(); } }; diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte index 86c76a461f..adc0f679cb 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,6 +11,7 @@ import { AppRoute } from '$lib/constants'; import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte'; import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { locale } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { stackAssets } from '$lib/utils/asset-utils'; @@ -60,6 +61,7 @@ }; let duplicates = $state(data.duplicates); + const { isViewing: showAssetViewer } = assetViewingStore; const correctDuplicatesIndex = (index: number) => { return Math.max(0, Math.min(index, duplicates.length - 1)); @@ -189,9 +191,21 @@ const handlePrevious = async () => { await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0)); }; + const handlePreviousShortcut = async () => { + if ($showAssetViewer) { + return; + } + await handlePrevious(); + }; const handleNext = async () => { await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1)); }; + const handleNextShortcut = async () => { + if ($showAssetViewer) { + return; + } + await handleNext(); + }; const handleLast = async () => { await correctDuplicatesIndexAndGo(duplicates.length - 1); }; @@ -203,8 +217,8 @@ diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte new file mode 100644 index 0000000000..c251146b45 --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -0,0 +1,321 @@ + + + + + + {#snippet buttons()} +
+ {#if filteredAssets.length > 0} + + {/if} +
+

{$t('selected_gps_coordinates')}

+ {location.latitude.toFixed(3)}, {location.longitude.toFixed(3)} +
+ + + +
+ {/snippet} + +
+
+ +
+ +
+ + +
+
+ + {#if isLoading} +
+ +
+ {/if} + + {#if filteredAssets && filteredAssets.length > 0} +
+ {#each filteredAssets as asset (asset.id)} + handleSelectAssets(asset)} + onMouseEvent={(asset) => assetMouseEventHandler(asset)} + onLocation={(selected) => { + location = selected; + locationUpdated = true; + setTimeout(() => { + locationUpdated = false; + }, 1000); + }} + /> + {/each} +
+ {:else} +
+ {#if partialDate == null} + + {:else if showOnlyAssetsWithoutLocation && filteredAssets.length === 0 && assets.length > 0} + + {:else} + + {/if} +
+ {/if} +
diff --git a/web/src/routes/(user)/utilities/geolocation/+page.ts b/web/src/routes/(user)/utilities/geolocation/+page.ts new file mode 100644 index 0000000000..f5c227a7ef --- /dev/null +++ b/web/src/routes/(user)/utilities/geolocation/+page.ts @@ -0,0 +1,17 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getQueryValue } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const partialDate = getQueryValue('date'); + const $t = await getFormatter(); + + return { + partialDate, + meta: { + title: $t('manage_geolocation'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 71958d9d9f..d2311a4204 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -36,6 +36,8 @@ close: $t('close'), show_password: $t('show_password'), hide_password: $t('hide_password'), + confirm: $t('confirm'), + cancel: $t('cancel'), }); }); diff --git a/web/src/routes/auth/register/+page.svelte b/web/src/routes/auth/register/+page.svelte index 4ec1b9718e..3eb046e80f 100644 --- a/web/src/routes/auth/register/+page.svelte +++ b/web/src/routes/auth/register/+page.svelte @@ -13,6 +13,7 @@ let password = $state(''); let confirmPassword = $state(''); let name = $state(''); + let loading = $state(false); let errorMessage = $derived( password === confirmPassword || confirmPassword.length === 0 ? '' : $t('password_does_not_match'), ); @@ -27,10 +28,11 @@ const onSubmit = async (event: Event) => { event.preventDefault(); - if (!valid) { + if (!valid || loading) { return; } + loading = true; errorMessage = ''; try { @@ -40,6 +42,8 @@ } catch (error) { handleError(error, $t('errors.unable_to_create_admin_account')); errorMessage = $t('errors.unable_to_create_admin_account'); + } finally { + loading = false; } }; @@ -70,6 +74,8 @@ {/if} - + diff --git a/web/src/service-worker/broadcast-channel.ts b/web/src/service-worker/broadcast-channel.ts index 12f835c52f..ae6f1e1be6 100644 --- a/web/src/service-worker/broadcast-channel.ts +++ b/web/src/service-worker/broadcast-channel.ts @@ -1,4 +1,4 @@ -import { cancelLoad, getCachedOrFetch } from './fetch-event'; +import { handleCancel, handlePreload } from './request'; export const installBroadcastChannelListener = () => { const broadcast = new BroadcastChannel('immich'); @@ -7,12 +7,19 @@ export const installBroadcastChannelListener = () => { if (!event.data) { return; } - const urlstring = event.data.url; - const url = new URL(urlstring, event.origin); - if (event.data.type === 'cancel') { - cancelLoad(url.toString()); - } else if (event.data.type === 'preload') { - getCachedOrFetch(url); + + const url = new URL(event.data.url, event.origin); + + switch (event.data.type) { + case 'preload': { + handlePreload(url); + break; + } + + case 'cancel': { + handleCancel(url); + break; + } } }; }; diff --git a/web/src/service-worker/cache.ts b/web/src/service-worker/cache.ts index 29d6073d5f..f91d8366ea 100644 --- a/web/src/service-worker/cache.ts +++ b/web/src/service-worker/cache.ts @@ -1,104 +1,42 @@ -import { build, files, version } from '$service-worker'; +import { version } from '$service-worker'; -const useCache = true; const CACHE = `cache-${version}`; -export const APP_RESOURCES = [ - ...build, // the app itself - ...files, // everything in `static` -]; - -let cache: Cache | undefined; -export async function getCache() { - if (cache) { - return cache; +let _cache: Cache | undefined; +const getCache = async () => { + if (_cache) { + return _cache; } - cache = await caches.open(CACHE); - return cache; -} + _cache = await caches.open(CACHE); + return _cache; +}; -export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; -export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; +export const get = async (key: string) => { + const cache = await getCache(); + if (!cache) { + return; + } -export async function deleteOldCaches() { + return cache.match(key); +}; + +export const put = async (key: string, response: Response) => { + if (response.status !== 200) { + return; + } + + const cache = await getCache(); + if (!cache) { + return; + } + + cache.put(key, response.clone()); +}; + +export const prune = async () => { for (const key of await caches.keys()) { if (key !== CACHE) { await caches.delete(key); } } -} - -const pendingRequests = new Map(); -const canceledRequests = new Set(); - -export async function cancelLoad(urlString: string) { - const pending = pendingRequests.get(urlString); - if (pending) { - canceledRequests.add(urlString); - pending.abort(); - pendingRequests.delete(urlString); - } -} - -export async function getCachedOrFetch(request: URL | Request | string) { - const response = await checkCache(request); - if (response) { - return response; - } - - const urlString = getCacheKey(request); - const cancelToken = new AbortController(); - - try { - pendingRequests.set(urlString, cancelToken); - const response = await fetch(request, { - signal: cancelToken.signal, - }); - - checkResponse(response); - await setCached(response, urlString); - return response; - } catch (error) { - if (canceledRequests.has(urlString)) { - canceledRequests.delete(urlString); - return new Response(undefined, { - status: 499, - statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself', - }); - } - throw error; - } finally { - pendingRequests.delete(urlString); - } -} - -export async function checkCache(url: URL | Request | string) { - if (!useCache) { - return; - } - const cache = await getCache(); - return await cache.match(url); -} - -export async function setCached(response: Response, cacheKey: URL | Request | string) { - if (cache && response.status === 200) { - const cache = await getCache(); - cache.put(cacheKey, response.clone()); - } -} - -function checkResponse(response: Response) { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); - } -} - -export function getCacheKey(request: URL | Request | string) { - if (isURL(request)) { - return request.toString(); - } else if (isRequest(request)) { - return request.url; - } else { - return request; - } -} +}; diff --git a/web/src/service-worker/fetch-event.ts b/web/src/service-worker/fetch-event.ts deleted file mode 100644 index b88eb3f0e5..0000000000 --- a/web/src/service-worker/fetch-event.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { version } from '$service-worker'; -import { APP_RESOURCES, checkCache, getCacheKey, setCached } from './cache'; - -const CACHE = `cache-${version}`; - -export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; -export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; - -export async function deleteOldCaches() { - for (const key of await caches.keys()) { - if (key !== CACHE) { - await caches.delete(key); - } - } -} - -const pendingLoads = new Map(); - -export async function cancelLoad(urlString: string) { - const pending = pendingLoads.get(urlString); - if (pending) { - pending.abort(); - pendingLoads.delete(urlString); - } -} - -export async function getCachedOrFetch(request: URL | Request | string) { - const response = await checkCache(request); - if (response) { - return response; - } - - try { - return await fetchWithCancellation(request); - } catch { - return new Response(undefined, { - status: 499, - statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself', - }); - } -} - -async function fetchWithCancellation(request: URL | Request | string) { - const cacheKey = getCacheKey(request); - const cancelToken = new AbortController(); - - try { - pendingLoads.set(cacheKey, cancelToken); - const response = await fetch(request, { - signal: cancelToken.signal, - }); - - checkResponse(response); - setCached(response, cacheKey); - return response; - } finally { - pendingLoads.delete(cacheKey); - } -} - -function checkResponse(response: Response) { - if (!(response instanceof Response)) { - throw new TypeError('Fetch did not return a valid Response object'); - } -} - -function isIgnoredFileType(pathname: string): boolean { - return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname); -} - -function isIgnoredPath(pathname: string): boolean { - return ( - /^\/(src|api)(\/.*)?$/.test(pathname) || /node_modules/.test(pathname) || /^\/@(vite|id)(\/.*)?$/.test(pathname) - ); -} - -function isAssetRequest(pathname: string): boolean { - return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname); -} - -export function handleFetchEvent(event: FetchEvent): void { - if (event.request.method !== 'GET') { - return; - } - - const url = new URL(event.request.url); - - // Only handle requests to the same origin - if (url.origin !== self.location.origin) { - return; - } - - // Do not cache app resources - if (APP_RESOURCES.includes(url.pathname)) { - return; - } - - // Cache requests for thumbnails - if (isAssetRequest(url.pathname)) { - event.respondWith(getCachedOrFetch(event.request)); - return; - } - - // Do not cache ignored file types or paths - if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) { - return; - } - - // At this point, the only remaining requests for top level routes - // so serve the Svelte SPA fallback page - const slash = new URL('/', url.origin); - event.respondWith(getCachedOrFetch(slash)); -} diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts index 54ada639ec..28336aca6a 100644 --- a/web/src/service-worker/index.ts +++ b/web/src/service-worker/index.ts @@ -3,14 +3,16 @@ /// /// import { installBroadcastChannelListener } from './broadcast-channel'; -import { deleteOldCaches } from './cache'; -import { handleFetchEvent } from './fetch-event'; +import { prune } from './cache'; +import { handleRequest } from './request'; + +const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/; const sw = globalThis as unknown as ServiceWorkerGlobalScope; const handleActivate = (event: ExtendableEvent) => { event.waitUntil(sw.clients.claim()); - event.waitUntil(deleteOldCaches()); + event.waitUntil(prune()); }; const handleInstall = (event: ExtendableEvent) => { @@ -18,7 +20,20 @@ const handleInstall = (event: ExtendableEvent) => { // do not preload app resources }; +const handleFetch = (event: FetchEvent): void => { + if (event.request.method !== 'GET') { + return; + } + + // Cache requests for thumbnails + const url = new URL(event.request.url); + if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) { + event.respondWith(handleRequest(event.request)); + return; + } +}; + sw.addEventListener('install', handleInstall, { passive: true }); sw.addEventListener('activate', handleActivate, { passive: true }); -sw.addEventListener('fetch', handleFetchEvent, { passive: true }); +sw.addEventListener('fetch', handleFetch, { passive: true }); installBroadcastChannelListener(); diff --git a/web/src/service-worker/request.ts b/web/src/service-worker/request.ts new file mode 100644 index 0000000000..aeb63be899 --- /dev/null +++ b/web/src/service-worker/request.ts @@ -0,0 +1,73 @@ +import { get, put } from './cache'; + +const pendingRequests = new Map(); + +const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined; +const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined; + +const assertResponse = (response: Response) => { + if (!(response instanceof Response)) { + throw new TypeError('Fetch did not return a valid Response object'); + } +}; + +const getCacheKey = (request: URL | Request) => { + if (isURL(request)) { + return request.toString(); + } + + if (isRequest(request)) { + return request.url; + } + + throw new Error(`Invalid request: ${request}`); +}; + +export const handlePreload = async (request: URL | Request) => { + try { + return await handleRequest(request); + } catch (error) { + console.error(`Preload failed: ${error}`); + } +}; + +export const handleRequest = async (request: URL | Request) => { + const cacheKey = getCacheKey(request); + const cachedResponse = await get(cacheKey); + if (cachedResponse) { + return cachedResponse; + } + + try { + const cancelToken = new AbortController(); + pendingRequests.set(cacheKey, cancelToken); + const response = await fetch(request, { signal: cancelToken.signal }); + + assertResponse(response); + put(cacheKey, response); + + return response; + } catch (error) { + if (error.name === 'AbortError') { + // dummy response avoids network errors in the console for these requests + return new Response(undefined, { status: 204 }); + } + + console.log('Not an abort error', error); + + throw error; + } finally { + pendingRequests.delete(cacheKey); + } +}; + +export const handleCancel = (url: URL) => { + const cacheKey = getCacheKey(url); + const pendingRequest = pendingRequests.get(cacheKey); + if (!pendingRequest) { + return; + } + + pendingRequest.abort(); + pendingRequests.delete(cacheKey); +}; diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index c2f03f9c6a..8d328ddcc7 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -6,6 +6,7 @@ import { Sync } from 'factory.ts'; export const assetFactory = Sync.makeFactory({ id: Sync.each(() => faker.string.uuid()), + createdAt: Sync.each(() => faker.date.past().toISOString()), deviceAssetId: Sync.each(() => faker.string.uuid()), ownerId: Sync.each(() => faker.string.uuid()), deviceId: '',