Merge branch 'immich-main' into feat/shared-album-owner-labels

This commit is contained in:
CJPeckover
2025-08-25 18:27:51 -04:00
67 changed files with 959 additions and 545 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,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:

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

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

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

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

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

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

@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String>(json, r'checksum')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
deviceId: mapValueOfType<String>(json, r'deviceId')!,
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
@@ -371,6 +379,7 @@ class AssetResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'checksum',
'createdAt',
'deviceAssetId',
'deviceId',
'duration',

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

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

View File

@@ -6,11 +6,15 @@ export * from './fetch-errors.js';
export interface InitOptions {
baseUrl: string;
apiKey: string;
headers?: Record<string, string>;
}
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<string, string>) => {
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`;

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

@@ -1,22 +1,23 @@
<p align="center">
<br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: AGPLv3"></a>
<br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Licenza: AGPLv3"></a>
<a href="https://discord.immich.app">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
<br/>
<br/>
</p>
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Accedi con url personalizzato">
</p>
<h3 align="center">Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video</h3>
<h3 align="center">Soluzione ad alte prestazioni per la gestione self-hosted di foto e video</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Main Screenshot">
<img src="../design/immich-screenshots.png" title="Screenshot Principale">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<a href="README_ca_ES.md">Català</a>
@@ -36,64 +37,97 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## 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 lapp 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 allinstallazione, 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 lapp 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 | | |
| Backup automatico quando l'app è in esecuzione | Sì | N/D |
| Selezione degli album per backup | | N/D |
| Download foto e video sul dispositivo | | Sì |
| Supporto multi utente | Sì | Sì |
| Album e album condivisi | | 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 | | N/D |
| Scroll virtuale | Sì | Sì |
| Supporto OAuth | | 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ì |
| 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 allapertura dellapp | Sì | N/D |
| Evita la duplicazione dei file | Sì | Sì |
| Backup selettivo di album | | 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ì |
| Supporto ai formati RAW | | 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ì | |
| 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).
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Stato traduzioni" />
</a>
## Attività del repository
![Attività](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Immagine analisi repobeats")
## Cronologia delle stelle
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Grafico storico delle stelle" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>
## Contributori
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

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

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

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

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

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

@@ -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".*`])

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

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

View File

@@ -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 = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case PersonRepository:
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
case StackRepository:
case SyncRepository:
case SyncCheckpointRepository:
@@ -391,7 +393,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
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,

View File

@@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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',
}),
});
});
});
});

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

@@ -25,7 +25,8 @@
}: Props = $props();
const oninput = () => {
if (!value) {
// value can be 0
if (value === undefined) {
return;
}

View File

@@ -133,7 +133,7 @@
await onEnter();
break;
}
case 'm': {
case 'Control': {
e.preventDefault();
handleMultiSelect();
break;

View File

@@ -74,6 +74,10 @@ class ApiError extends Error {
}
}
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
const { onUploadProgress: onProgress, data, url } = options;

View File

@@ -9,7 +9,7 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, withError } from '$lib/utils';
import { downloadRequest, sleep, withError } from '$lib/utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
@@ -278,7 +278,12 @@ export const downloadFile = async (asset: AssetResponseDto) => {
const queryParams = asQueryString(authManager.params);
for (const { filename, id } of assets) {
for (const [i, { filename, id }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
}
try {
notificationController.show({
type: NotificationType.Info,

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

View File

@@ -11,6 +11,7 @@
import { AppRoute } from '$lib/constants';
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { stackAssets } from '$lib/utils/asset-utils';
@@ -60,6 +61,7 @@
};
let duplicates = $state(data.duplicates);
const { isViewing: showAssetViewer } = assetViewingStore;
const correctDuplicatesIndex = (index: number) => {
return Math.max(0, Math.min(index, duplicates.length - 1));
@@ -189,9 +191,21 @@
const handlePrevious = async () => {
await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0));
};
const handlePreviousShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handlePrevious();
};
const handleNext = async () => {
await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1));
};
const handleNextShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handleNext();
};
const handleLast = async () => {
await correctDuplicatesIndexAndGo(duplicates.length - 1);
};
@@ -203,8 +217,8 @@
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
/>

View File

@@ -1,4 +1,4 @@
import { cancelLoad, getCachedOrFetch } from './fetch-event';
import { handleCancel, handlePreload } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
@@ -7,12 +7,19 @@ export const installBroadcastChannelListener = () => {
if (!event.data) {
return;
}
const urlstring = event.data.url;
const url = new URL(urlstring, event.origin);
if (event.data.type === 'cancel') {
cancelLoad(url.toString());
} else if (event.data.type === 'preload') {
getCachedOrFetch(url);
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
}
};
};

View File

@@ -1,104 +1,42 @@
import { build, files, version } from '$service-worker';
import { version } from '$service-worker';
const useCache = true;
const CACHE = `cache-${version}`;
export const APP_RESOURCES = [
...build, // the app itself
...files, // everything in `static`
];
let cache: Cache | undefined;
export async function getCache() {
if (cache) {
return cache;
let _cache: Cache | undefined;
const getCache = async () => {
if (_cache) {
return _cache;
}
cache = await caches.open(CACHE);
return cache;
}
_cache = await caches.open(CACHE);
return _cache;
};
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
export const get = async (key: string) => {
const cache = await getCache();
if (!cache) {
return;
}
export async function deleteOldCaches() {
return cache.match(key);
};
export const put = async (key: string, response: Response) => {
if (response.status !== 200) {
return;
}
const cache = await getCache();
if (!cache) {
return;
}
cache.put(key, response.clone());
};
export const prune = async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}
const pendingRequests = new Map<string, AbortController>();
const canceledRequests = new Set<string>();
export async function cancelLoad(urlString: string) {
const pending = pendingRequests.get(urlString);
if (pending) {
canceledRequests.add(urlString);
pending.abort();
pendingRequests.delete(urlString);
}
}
export async function getCachedOrFetch(request: URL | Request | string) {
const response = await checkCache(request);
if (response) {
return response;
}
const urlString = getCacheKey(request);
const cancelToken = new AbortController();
try {
pendingRequests.set(urlString, cancelToken);
const response = await fetch(request, {
signal: cancelToken.signal,
});
checkResponse(response);
await setCached(response, urlString);
return response;
} catch (error) {
if (canceledRequests.has(urlString)) {
canceledRequests.delete(urlString);
return new Response(undefined, {
status: 499,
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
});
}
throw error;
} finally {
pendingRequests.delete(urlString);
}
}
export async function checkCache(url: URL | Request | string) {
if (!useCache) {
return;
}
const cache = await getCache();
return await cache.match(url);
}
export async function setCached(response: Response, cacheKey: URL | Request | string) {
if (cache && response.status === 200) {
const cache = await getCache();
cache.put(cacheKey, response.clone());
}
}
function checkResponse(response: Response) {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
}
export function getCacheKey(request: URL | Request | string) {
if (isURL(request)) {
return request.toString();
} else if (isRequest(request)) {
return request.url;
} else {
return request;
}
}
};

View File

@@ -1,113 +0,0 @@
import { version } from '$service-worker';
import { APP_RESOURCES, checkCache, getCacheKey, setCached } from './cache';
const CACHE = `cache-${version}`;
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
export async function deleteOldCaches() {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
}
const pendingLoads = new Map<string, AbortController>();
export async function cancelLoad(urlString: string) {
const pending = pendingLoads.get(urlString);
if (pending) {
pending.abort();
pendingLoads.delete(urlString);
}
}
export async function getCachedOrFetch(request: URL | Request | string) {
const response = await checkCache(request);
if (response) {
return response;
}
try {
return await fetchWithCancellation(request);
} catch {
return new Response(undefined, {
status: 499,
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
});
}
}
async function fetchWithCancellation(request: URL | Request | string) {
const cacheKey = getCacheKey(request);
const cancelToken = new AbortController();
try {
pendingLoads.set(cacheKey, cancelToken);
const response = await fetch(request, {
signal: cancelToken.signal,
});
checkResponse(response);
setCached(response, cacheKey);
return response;
} finally {
pendingLoads.delete(cacheKey);
}
}
function checkResponse(response: Response) {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
}
function isIgnoredFileType(pathname: string): boolean {
return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname);
}
function isIgnoredPath(pathname: string): boolean {
return (
/^\/(src|api)(\/.*)?$/.test(pathname) || /node_modules/.test(pathname) || /^\/@(vite|id)(\/.*)?$/.test(pathname)
);
}
function isAssetRequest(pathname: string): boolean {
return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname);
}
export function handleFetchEvent(event: FetchEvent): void {
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
// Only handle requests to the same origin
if (url.origin !== self.location.origin) {
return;
}
// Do not cache app resources
if (APP_RESOURCES.includes(url.pathname)) {
return;
}
// Cache requests for thumbnails
if (isAssetRequest(url.pathname)) {
event.respondWith(getCachedOrFetch(event.request));
return;
}
// Do not cache ignored file types or paths
if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) {
return;
}
// At this point, the only remaining requests for top level routes
// so serve the Svelte SPA fallback page
const slash = new URL('/', url.origin);
event.respondWith(getCachedOrFetch(slash));
}

View File

@@ -3,14 +3,16 @@
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installBroadcastChannelListener } from './broadcast-channel';
import { deleteOldCaches } from './cache';
import { handleFetchEvent } from './fetch-event';
import { prune } from './cache';
import { handleRequest } from './request';
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
event.waitUntil(deleteOldCaches());
event.waitUntil(prune());
};
const handleInstall = (event: ExtendableEvent) => {
@@ -18,7 +20,20 @@ const handleInstall = (event: ExtendableEvent) => {
// do not preload app resources
};
const handleFetch = (event: FetchEvent): void => {
if (event.request.method !== 'GET') {
return;
}
// Cache requests for thumbnails
const url = new URL(event.request.url);
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
event.respondWith(handleRequest(event.request));
return;
}
};
sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetchEvent, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installBroadcastChannelListener();

View File

@@ -0,0 +1,73 @@
import { get, put } from './cache';
const pendingRequests = new Map<string, AbortController>();
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
const assertResponse = (response: Response) => {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
};
const getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
}
if (isRequest(request)) {
return request.url;
}
throw new Error(`Invalid request: ${request}`);
};
export const handlePreload = async (request: URL | Request) => {
try {
return await handleRequest(request);
} catch (error) {
console.error(`Preload failed: ${error}`);
}
};
export const handleRequest = async (request: URL | Request) => {
const cacheKey = getCacheKey(request);
const cachedResponse = await get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
try {
const cancelToken = new AbortController();
pendingRequests.set(cacheKey, cancelToken);
const response = await fetch(request, { signal: cancelToken.signal });
assertResponse(response);
put(cacheKey, response);
return response;
} catch (error) {
if (error.name === 'AbortError') {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
console.log('Not an abort error', error);
throw error;
} finally {
pendingRequests.delete(cacheKey);
}
};
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
pendingRequest.abort();
pendingRequests.delete(cacheKey);
};

View File

@@ -6,6 +6,7 @@ import { Sync } from 'factory.ts';
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
id: Sync.each(() => faker.string.uuid()),
createdAt: Sync.each(() => faker.date.past().toISOString()),
deviceAssetId: Sync.each(() => faker.string.uuid()),
ownerId: Sync.each(() => faker.string.uuid()),
deviceId: '',