Compare commits

..

18 Commits

Author SHA1 Message Date
github-actions
b44d2a241d chore: version v1.139.4 2025-08-25 02:39:18 +00:00
Vietbao Tran
1af10ded74 fix: wait for watched files to finish being written (#17100) (#21180)
This makes the external library watcher wait for files in watched directories to finish being written before queuing jobs for each file.
2025-08-24 21:33:24 -05:00
xCJPECKOVERx
3f1e11afcc chore(server): Improve add to multiple albums via bulk checks and inserts (#21052)
* - add addAssetIdsToAlbums to album repo
- update albumService to determine all albums and assets with access and coalesce into one set of album_assets to insert

* - remove hasAsset check (unnecessary)

* - lint

* - cleanup

* - remove success counts from addAssetsToAlbums results
- Fix tests

* open-api

* await album update
2025-08-24 21:33:10 -05:00
shenlong
28dce2d0df fix: use composite cache key in user circle avatar (#21220)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-08-24 21:32:24 -05:00
Alex
605764f226 chore: post release tasks (#21191) 2025-08-24 21:31:56 -05:00
Min Idzelis
44e1c83c84 fix: isolate docker host/container filesystem for node_modules and build output (#21167) 2025-08-24 13:09:45 -05:00
Lorenzo Farnararo
0729887c9c fix(web): handle edge cases in timeToSeconds function to prevent crashes (#21019)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-08-23 22:42:37 +02:00
Nicholas
3bfa8b7575 fix: border around dark theme button on onboarding page (#20846)
fix border around dark theme button
2025-08-23 15:28:00 -05:00
Alex
3138048b96 fix: cannot load thumbnail from unknown content length (#21192)
* fix: cannot load thumbnail from unknown content length

* pr feedback

* pr feedback
2025-08-23 15:25:12 -05:00
github-actions
f8b41ea8aa chore: version v1.139.3 2025-08-23 16:37:46 +00:00
pojlFDlxCOvZ4Kg8y1l4
1d33ed6bed docs: update oauth.md - Authentik link leads to Page Not Found error (#21186)
Update oauth.md

Updated Authentik link
2025-08-23 16:30:41 +00:00
shenlong
2be1a58c5b fix: prefer local video if available (#21119)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-08-23 11:18:57 -05:00
Jason Rasmussen
03e7922589 fix: local offset hours (#21147) 2025-08-23 11:09:36 -05:00
Alex
801af34d9a fix: sync flow block oAuth login page navigation (#21187) 2025-08-23 16:09:00 +00:00
Alex
bedaa729e9 chore: post release tasks (#21140) 2025-08-23 11:06:13 -05:00
Alex
13c8a6e61d fix: parse correct metadata to userDto for SQlite store implmentation (#21154) 2025-08-23 11:02:24 -05:00
Alex
01edf6533b fix: shared album asset count query (#21157) 2025-08-23 10:46:40 -05:00
DevServices
30d0bea4df fix(web): add to multiple albums translation doesn't have plural formatting (#21087)
Co-authored-by: xCJPECKOVERx <cjpeckover@hotmail.ca>
2025-08-22 18:52:40 +02:00
47 changed files with 563 additions and 258 deletions

View File

@@ -5,7 +5,8 @@
"immich-server",
"redis",
"database",
"immich-machine-learning"
"immich-machine-learning",
"init"
],
"dockerComposeFile": [
"../docker/docker-compose.dev.yml",

View File

@@ -11,6 +11,18 @@ 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
- server-dist:/usr/src/app/server/dist
- 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 []
immich-machine-learning:

View File

@@ -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;
},

View File

@@ -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

View File

@@ -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",

View File

@@ -32,6 +32,18 @@ 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
- server-dist:/usr/src/app/server/dist
- 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 +96,18 @@ services:
- 24678:24678
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node-modules:/usr/src/app/server/node_modules
- server-dist:/usr/src/app/server/dist
- 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 +191,33 @@ 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
- server-dist:/usr/src/app/server/dist
- 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:
server-dist:
web-node_modules:
github-node_modules:
cli-node_modules:
docs-node_modules:
e2e-node_modules:
sdk-node_modules:
app-node_modules:
sveltekit:
coverage:

View File

@@ -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)

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.139.2",
"version": "1.139.4",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -500,7 +500,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}}",

View File

@@ -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')

View File

@@ -123,6 +123,8 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -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;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.138.1</string>
<string>1.139.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -105,7 +105,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>215</string>
<string>217</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -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,

View File

@@ -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<String, Object?> 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<String, Object?>?)?["enabled"] as bool? ?? false,
memoriesEnabled: (map["memories"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
peopleEnabled: (map["people"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
ratingsEnabled: (map["ratings"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
sharedLinksEnabled: (map["sharedLinks"] as Map<String, Object?>?)?["enabled"] as bool? ?? true,
tagsEnabled: (map["tags"] as Map<String, Object?>?)?["enabled"] as bool? ?? false,
userAvatarColor: AvatarColor.values.firstWhere(
(e) => e.value == map["avatar-Color"] as String?,
(e) => e.value == (map["avatar"] as Map<String, Object?>?)?["color"] as String?,
orElse: () => AvatarColor.primary,
),
showSupportBadge: map["purchase-ShowSupportBadge"] as bool? ?? true,
showSupportBadge: (map["purchase"] as Map<String, Object?>?)?["showSupportBadge"] as bool? ?? true,
);
}
@@ -213,7 +212,7 @@ class License {
factory License.fromMap(Map<String, Object?> 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,
);

View File

@@ -17,9 +17,14 @@ class AssetService {
_localAssetRepository = localAssetRepository,
_platform = const LocalPlatform();
Future<BaseAsset?> 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<BaseAsset?> 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<RemoteAsset?> getRemoteAsset(String id) {

View File

@@ -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<int> 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<int> 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 = <List<int>>[];
int totalLength = 0;
final subscription = response.listen((List<int> 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);
}

View File

@@ -9,7 +9,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftLocalAssetRepository(this._db) : super(_db);
Stream<LocalAsset?> watchAsset(String id) {
SingleOrNullSelectable<LocalAsset?> _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<LocalAsset?> get(String id) => _assetSelectable(id).getSingleOrNull();
Stream<LocalAsset?> watch(String id) => _assetSelectable(id).watchSingleOrNull();
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
if (hashes.isEmpty) {
return Future.value();

View File

@@ -17,7 +17,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
const DriftRemoteAlbumRepository(this._db) : super(_db);
Future<List<RemoteAlbum>> getAll({Set<SortRemoteAlbumsBy> 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<RemoteAlbum?> 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();

View File

@@ -55,24 +55,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
return _assetSelectable(id).getSingleOrNull();
}
Stream<RemoteAsset?> 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<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) {
return Future.value([]);

View File

@@ -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<UserDto?> get(String id) =>
_db.managers.userEntity.filter((user) => user.id.equals(id)).getSingleOrNull().then((user) => user?.toDto());
Future<UserDto?> 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<UserDto> upsert(UserDto user) async {
await _db.userEntity.insertOnConflictUpdate(
@@ -87,10 +97,35 @@ class DriftUserRepository extends DriftDatabaseRepository {
);
return user;
}
Future<List<UserDto>> getAll() async {
final users = await _db.userEntity.select().get();
final List<UserDto> 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<UserMetadata>? 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,
);
}
}

View File

@@ -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)),

View File

@@ -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

View File

@@ -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<bool>(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;
}
}

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return AlbumsAddAssetsResponseDto(
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
error: BulkIdErrorReason.fromJson(json[r'error']),
success: mapValueOfType<bool>(json, r'success')!,
);
@@ -124,8 +110,6 @@ class AlbumsAddAssetsResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumSuccessCount',
'assetSuccessCount',
'success',
};
}

View File

@@ -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'

View File

@@ -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"

View File

@@ -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",

View File

@@ -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
*/
@@ -389,8 +389,6 @@ export type AlbumsAddAssetsDto = {
assetIds: string[];
};
export type AlbumsAddAssetsResponseDto = {
albumSuccessCount: number;
assetSuccessCount: number;
error?: BulkIdErrorReason;
success: boolean;
};

144
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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

View File

@@ -3,7 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"deleteOutDir": false,
"webpack": false,
"plugins": [
{

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.139.2",
"version": "1.139.4",
"description": "",
"author": "",
"private": true,

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -321,6 +321,14 @@ export class AlbumRepository {
.execute();
}
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumsId: string; assetsId: string }[]): Promise<void> {
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

View File

@@ -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',

View File

@@ -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();

View File

@@ -191,36 +191,57 @@ export class AlbumService extends BaseService {
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
const results: AlbumsAddAssetsResponseDto = {
success: false,
albumSuccessCount: 0,
assetSuccessCount: 0,
error: BulkIdErrorReason.DUPLICATE,
};
const successfulAssetIds: Set<string> = 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;
}

View File

@@ -123,6 +123,10 @@ export class LibraryService extends BaseService {
{
usePolling: false,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 5000,
pollInterval: 1000,
},
},
{
onReady: () => _resolve(),

View File

@@ -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": {

View File

@@ -24,7 +24,7 @@
</button>
<button
type="button"
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
class="w-1/2 aspect-square bg-dark dark:bg-light rounded-3xl transition-all shadow-sm hover:shadow-xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
onclick={() => themeManager.setTheme(Theme.DARK)}
>
<div

View File

@@ -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);
});
});

View File

@@ -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();
}