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..539caa0dd1 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 'for path in /data /data/upload /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/.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..31a00ee6be 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,30 @@ 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 \ + ./server/dist \ + ./open-api/typescript-sdk/node_modules \ + ./.github/node_modules \ + ./node_modules \ + ./cli/node_modules + +# create empty directories and chown to current user +prepare-volumes: + @for dir in $(VOLUME_DIRS); do \ + mkdir -p $$dir; \ + done + @if [ -n "$(VOLUME_DIRS)" ]; then \ + chown -R $$(id -u):$$(id -g) $(VOLUME_DIRS); \ + fi + MODULES = e2e server web cli sdk docs .github # directory to package name mapping function diff --git a/cli/package.json b/cli/package.json index bfcae8bb8a..8962ad4645 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.82", + "version": "2.2.84", "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..439140e3f5 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 @@ -167,9 +189,31 @@ services: 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 '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/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/static/archived-versions.json b/docs/static/archived-versions.json index 5d51f454a1..b884b358f0 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,12 @@ [ + { + "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/package.json b/e2e/package.json index db1e7237a6..beddd8e49d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.139.2", + "version": "1.139.4", "description": "", "main": "index.js", "type": "module", diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 21431e33ac..1be349f808 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" => 3009, + "android.injected.version.name" => "1.139.4", } ) 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..563c4cda33 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -123,6 +123,8 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Sync; sourceTree = ""; }; @@ -667,7 +669,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -811,7 +813,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -841,7 +843,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -875,7 +877,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -918,7 +920,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -958,7 +960,7 @@ CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -997,7 +999,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1041,7 +1043,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -1082,7 +1084,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 215; + CURRENT_PROJECT_VERSION = 217; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 31ad10c35f..8c72f125f4 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.138.1 + 1.139.3 CFBundleSignature ???? CFBundleURLTypes @@ -105,7 +105,7 @@ CFBundleVersion - 215 + 217 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index d5841b5da2..60a5f24eef 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.139.4" ) increment_build_number( build_number: latest_testflight_build_number + 1, 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/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index fe62469461..f228f5de17 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -64,18 +64,48 @@ class RemoteImageRequest extends ImageRequest { if (_isCancelled) { return null; } - final bytes = Uint8List(response.contentLength); + + // Handle unknown content length from reverse proxy + final contentLength = response.contentLength; + final Uint8List bytes; int offset = 0; - final subscription = response.listen((List chunk) { - // this is important to break the response stream if the request is cancelled - if (_isCancelled) { - throw StateError('Cancelled request'); + + if (contentLength >= 0) { + // Known content length - use pre-allocated buffer + bytes = Uint8List(contentLength); + final subscription = response.listen((List chunk) { + // this is important to break the response stream if the request is cancelled + if (_isCancelled) { + throw StateError('Cancelled request'); + } + bytes.setAll(offset, chunk); + offset += chunk.length; + }, cancelOnError: true); + cacheManager?.putStreamedFile(url, response); + await subscription.asFuture(); + } else { + // Unknown content length - collect chunks dynamically + final chunks = >[]; + int totalLength = 0; + final subscription = response.listen((List chunk) { + // this is important to break the response stream if the request is cancelled + if (_isCancelled) { + throw StateError('Cancelled request'); + } + chunks.add(chunk); + totalLength += chunk.length; + }, cancelOnError: true); + cacheManager?.putStreamedFile(url, response); + await subscription.asFuture(); + + // Combine all chunks into a single buffer + bytes = Uint8List(totalLength); + for (final chunk in chunks) { + bytes.setAll(offset, chunk); + offset += chunk.length; } - bytes.setAll(offset, chunk); - offset += chunk.length; - }, cancelOnError: true); - cacheManager?.putStreamedFile(url, response); - await subscription.asFuture(); + } + return await ImmutableBuffer.fromUint8List(bytes); } 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/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/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 3764443566..8ef3ef9757 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -15,9 +15,6 @@ class MainTimelinePage extends ConsumerWidget { 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 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/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/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/openapi/README.md b/mobile/openapi/README.md index 9039ad6400..04600250b1 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.139.4 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen 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_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/pubspec.yaml b/mobile/pubspec.yaml index a069751921..7d40c80a26 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.139.4+3009 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..eb9b6ac5a9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9592,7 +9592,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.139.2", + "version": "1.139.4", "contact": {} }, "tags": [], @@ -10007,12 +10007,6 @@ }, "AlbumsAddAssetsResponseDto": { "properties": { - "albumSuccessCount": { - "type": "integer" - }, - "assetSuccessCount": { - "type": "integer" - }, "error": { "allOf": [ { @@ -10025,8 +10019,6 @@ } }, "required": [ - "albumSuccessCount", - "assetSuccessCount", "success" ], "type": "object" @@ -10728,6 +10720,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 +10861,7 @@ }, "required": [ "checksum", + "createdAt", "deviceAssetId", "deviceId", "duration", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index a840986448..5b0b693c85 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.139.4", "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..08fa714823 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.139.4 * 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; }; 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..5ac0a8f043 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.139.2", + "version": "1.139.4", "description": "", "author": "", "private": true, 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-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/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 712fb08a50..e2bc80eabe 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -277,7 +277,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/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.repository.ts b/server/src/repositories/asset.repository.ts index 61ccbf6541..6752d7bf62 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -566,7 +566,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/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/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index f3ba57d744..e22d486bba 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -778,9 +778,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)) @@ -792,7 +790,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', { @@ -805,14 +803,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' })) @@ -824,7 +826,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', { @@ -837,14 +839,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)) @@ -856,7 +862,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', { @@ -869,8 +875,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', recipientId: 'admin_id', @@ -896,18 +908,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)) @@ -919,7 +927,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', { @@ -927,22 +935,23 @@ 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', recipientId: 'user-id', }); 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)) @@ -954,7 +963,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', { @@ -967,8 +976,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']), @@ -976,23 +991,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', { @@ -1000,8 +1013,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 () => { @@ -1021,8 +1037,6 @@ describe(AlbumService.name, () => { }), ).resolves.toEqual({ success: false, - albumSuccessCount: 0, - assetSuccessCount: 0, error: BulkIdErrorReason.DUPLICATE, }); @@ -1046,9 +1060,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(); @@ -1076,9 +1088,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(); @@ -1099,9 +1109,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 32832f0dd3..d7b857d666 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -191,36 +191,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/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/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/web/package.json b/web/package.json index 64aa03b378..dffc246da2 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.139.2", + "version": "1.139.4", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte index 26e8fd9c7a..957cd8093a 100644 --- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte @@ -24,7 +24,7 @@