Merge branch 'main' of https://github.com/immich-app/immich into chore/originals-in-asset-files

chore/originals-in-asset-files
Jonathan Jogenfors 2025-12-02 20:58:50 +01:00
commit 173904e387
102 changed files with 1537 additions and 856 deletions

View File

@ -1,6 +1,6 @@
[tools] [tools]
terragrunt = "0.91.2" terragrunt = "0.93.10"
opentofu = "1.10.6" opentofu = "1.10.7"
[tasks."tg:fmt"] [tasks."tg:fmt"]
run = "terragrunt hclfmt" run = "terragrunt hclfmt"

View File

@ -135,7 +135,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1

View File

@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

View File

@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc., The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
the job may not have run automatically the first time. the job may not have run automatically the first time.
### How can I hide photos from the timeline? ### How can I hide a photo or video from the timeline?
You can _archive_ them. You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
### How can I backup data from Immich? ### How can I backup data from Immich?

View File

@ -18,6 +18,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_: Before you can run the tests, you need to run the following commands _once_:
- `pnpm install` (in `e2e/`) - `pnpm install` (in `e2e/`)
- `pnpm run build` (in `cli/`)
- `make open-api` (in the project root `/`) - `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via: Once the test environment is running, the e2e tests can be run via:

View File

@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
## Ports ## Ports
| Variable | Description | Default | | Variable | Description | Default | Containers |
| :------------ | :------------- | :----------------------------------------: | | :------------ | :------------- | :----------------------------------------: | :----------------------- |
| `IMMICH_HOST` | Listening host | `0.0.0.0` | | `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | | `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
## Database ## Database
@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_SSL_MODE` | Database SSL mode | | server | | `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server | | `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | | `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server | | `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`. \*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.

View File

@ -2192,6 +2192,7 @@
"view_album": "View Album", "view_album": "View Album",
"view_all": "View All", "view_all": "View All",
"view_all_users": "View all users", "view_all_users": "View all users",
"view_asset_owners": "View asset owners",
"view_details": "View Details", "view_details": "View Details",
"view_in_timeline": "View in timeline", "view_in_timeline": "View in timeline",
"view_link": "View link", "view_link": "View link",

View File

@ -82,6 +82,7 @@ class TextDetector(InferenceModel):
ratio = float(self.max_resolution) / img.height ratio = float(self.max_resolution) / img.height
else: else:
ratio = float(self.max_resolution) / img.width ratio = float(self.max_resolution) / img.width
ratio = min(ratio, 1.0)
resize_h = int(img.height * ratio) resize_h = int(img.height * ratio)
resize_w = int(img.width * ratio) resize_w = int(img.width * ratio)

View File

@ -2206,7 +2206,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.4" version = "2.12.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-types" }, { name = "annotated-types" },
@ -2214,9 +2214,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
] ]
[[package]] [[package]]

View File

@ -4,8 +4,8 @@ experimental_monorepo_root = true
node = "24.11.1" node = "24.11.1"
flutter = "3.35.7" flutter = "3.35.7"
pnpm = "10.22.0" pnpm = "10.22.0"
terragrunt = "0.91.2" terragrunt = "0.93.10"
opentofu = "1.10.6" opentofu = "1.10.7"
java = "25.0.1" java = "25.0.1"
[tools."github:CQLabs/homebrew-dcm"] [tools."github:CQLabs/homebrew-dcm"]

View File

@ -0,0 +1,32 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
// Timeline Events
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ScrollToTopEvent extends Event {
const ScrollToTopEvent();
}
class ScrollToDateEvent extends Event {
final DateTime date;
const ScrollToDateEvent(this.date);
}
// Asset Viewer Events
class ViewerOpenBottomSheetEvent extends Event {
final bool activitiesMode;
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
}
class ViewerReloadAssetEvent extends Event {
const ViewerReloadAssetEvent();
}
// Multi-Select Events
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}

View File

@ -71,6 +71,7 @@ enum StoreKey<T> {
readonlyModeEnabled<bool>._(138), readonlyModeEnabled<bool>._(138),
autoPlayVideo<bool>._(139), autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
// Experimental stuff // Experimental stuff
photoManagerCustomFilter<bool>._(1000), photoManagerCustomFilter<bool>._(1000),

View File

@ -1,5 +1,3 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy { day, month, auto, none } enum GroupAssetsBy { day, month, auto, none }
enum HeaderType { none, month, day, monthAndDay } enum HeaderType { none, month, day, monthAndDay }
@ -31,17 +29,3 @@ class TimeBucket extends Bucket {
@override @override
int get hashCode => super.hashCode ^ date.hashCode; int get hashCode => super.hashCode ^ date.hashCode;
} }
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ScrollToTopEvent extends Event {
const ScrollToTopEvent();
}
class ScrollToDateEvent extends Event {
final DateTime date;
const ScrollToDateEvent(this.date);
}

View File

@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class RemoteAlbumService { class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository; final DriftRemoteAlbumRepository _repository;
@ -32,16 +33,16 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> sortAlbums( Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { AlbumSortMode sortMode, {
bool isReverse = false, bool isReverse = false,
}) async { }) async {
final List<RemoteAlbum> sorted = switch (sortMode) { final List<RemoteAlbum> sorted = switch (sortMode) {
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt), AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name), AlbumSortMode.title => albums.sortedBy((album) => album.name),
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums), AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums), AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
}; };
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
@ -211,16 +212,3 @@ class RemoteAlbumService {
return sorted.reversed.toList(); return sorted.reversed.toList();
} }
} }
enum RemoteAlbumSortMode {
title("library_page_sort_title"),
assetCount("library_page_sort_asset_count"),
lastModified("library_page_sort_last_modified"),
created("library_page_sort_created"),
mostRecent("sort_newest"),
mostOldest("sort_oldest");
final String key;
const RemoteAlbumSortMode(this.key);
}

View File

@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';

View File

@ -1,5 +1,5 @@
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:timezone/timezone.dart'; import 'package:immich_mobile/utils/timezone.dart';
extension TZExtension on Asset { extension TZExtension on Asset {
/// Returns the created time of the asset from the exif info (if available) or from /// Returns the created time of the asset from the exif info (if available) or from
@ -7,24 +7,11 @@ extension TZExtension on Asset {
/// the timezone offset in [Duration] /// the timezone offset in [Duration]
(DateTime, Duration) getTZAdjustedTimeAndOffset() { (DateTime, Duration) getTZAdjustedTimeAndOffset() {
DateTime dt = fileCreatedAt.toLocal(); DateTime dt = fileCreatedAt.toLocal();
if (exifInfo?.dateTimeOriginal != null) { if (exifInfo?.dateTimeOriginal != null) {
dt = exifInfo!.dateTimeOriginal!; return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone);
if (exifInfo?.timeZone != null) {
dt = dt.toUtc();
try {
final location = getLocation(exifInfo!.timeZone!);
dt = TZDateTime.from(dt, location);
} on LocationNotFoundException {
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
final m = re.firstMatch(exifInfo!.timeZone!);
if (m != null) {
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
dt = dt.add(duration);
return (dt, duration);
}
}
}
} }
return (dt, dt.timeZoneOffset); return (dt, dt.timeZoneOffset);
} }
} }

View File

@ -3,7 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';

View File

@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget {
context.locale; context.locale;
return Scaffold( return Scaffold(
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()), appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()), body: context.isMobile ? const _MobileLayout() : const _TabletLayout(),
); );
} }
} }
@ -89,11 +89,7 @@ class _MobileLayout extends StatelessWidget {
], ],
) )
.toList(); .toList();
return ListView( return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(top: 10.0, bottom: 16),
children: [...settings],
);
} }
} }

View File

@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@RoutePage() @RoutePage()

View File

@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@ -9,8 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
// used to allow performing unarchive action from different sources (without duplicating code) // used to allow performing unarchive action from different sources (without duplicating code)
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {

View File

@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@ -17,6 +16,9 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart'; import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
@ -45,14 +47,28 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
List<RemoteAlbum> shownAlbums = []; List<RemoteAlbum> shownAlbums = [];
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all); AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true); AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Load albums when component mounts
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final appSettings = ref.read(appSettingsServiceProvider);
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
final albumSortMode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == savedSortMode,
orElse: () => AlbumSortMode.lastModified,
);
setState(() {
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
isGrid = savedIsGrid;
});
ref.read(remoteAlbumProvider.notifier).refresh(); ref.read(remoteAlbumProvider.notifier).refresh();
}); });
@ -82,6 +98,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
setState(() { setState(() {
isGrid = !isGrid; isGrid = !isGrid;
}); });
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
} }
void changeFilter(QuickFilterMode mode) { void changeFilter(QuickFilterMode mode) {
@ -97,6 +114,10 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
this.sort = sort; this.sort = sort;
}); });
final appSettings = ref.read(appSettingsServiceProvider);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
await sortAlbums(); await sortAlbums();
} }
@ -181,6 +202,8 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
onToggleViewMode: toggleViewMode, onToggleViewMode: toggleViewMode,
onSortChanged: changeSort, onSortChanged: changeSort,
controller: menuController, controller: menuController,
currentSortMode: sort.mode,
currentIsReverse: sort.isReverse,
), ),
isGrid isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected) ? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
@ -192,21 +215,46 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
} }
class _SortButton extends ConsumerStatefulWidget { class _SortButton extends ConsumerStatefulWidget {
const _SortButton(this.onSortChanged, {this.controller}); const _SortButton(
this.onSortChanged, {
required this.initialSortMode,
required this.initialIsReverse,
this.controller,
});
final Future<void> Function(AlbumSort) onSortChanged; final Future<void> Function(AlbumSort) onSortChanged;
final MenuController? controller; final MenuController? controller;
final AlbumSortMode initialSortMode;
final bool initialIsReverse;
@override @override
ConsumerState<_SortButton> createState() => _SortButtonState(); ConsumerState<_SortButton> createState() => _SortButtonState();
} }
class _SortButtonState extends ConsumerState<_SortButton> { class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified; late AlbumSortMode albumSortOption;
bool albumSortIsReverse = true; late bool albumSortIsReverse;
bool isSorting = false; bool isSorting = false;
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async { @override
void initState() {
super.initState();
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
}
@override
void didUpdateWidget(_SortButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) {
setState(() {
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
});
}
}
Future<void> onMenuTapped(AlbumSortMode sortMode) async {
final selected = albumSortOption == sortMode; final selected = albumSortOption == sortMode;
// Switch direction // Switch direction
if (selected) { if (selected) {
@ -240,7 +288,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)), padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
), ),
consumeOutsideTap: true, consumeOutsideTap: true,
menuChildren: RemoteAlbumSortMode.values menuChildren: AlbumSortMode.values
.map( .map(
(sortMode) => MenuItemButton( (sortMode) => MenuItemButton(
leadingIcon: albumSortOption == sortMode leadingIcon: albumSortOption == sortMode
@ -269,7 +317,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
), ),
), ),
child: Text( child: Text(
sortMode.key.t(context: context), sortMode.label.t(context: context),
style: context.textTheme.titleSmall?.copyWith( style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: albumSortOption == sortMode color: albumSortOption == sortMode
@ -298,7 +346,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
: const Icon(Icons.keyboard_arrow_up_rounded), : const Icon(Icons.keyboard_arrow_up_rounded),
), ),
Text( Text(
albumSortOption.key.t(context: context), albumSortOption.label.t(context: context),
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225), color: context.colorScheme.onSurface.withAlpha(225),
@ -465,6 +513,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
required this.isGrid, required this.isGrid,
required this.onToggleViewMode, required this.onToggleViewMode,
required this.onSortChanged, required this.onSortChanged,
required this.currentSortMode,
required this.currentIsReverse,
this.controller, this.controller,
}); });
@ -472,6 +522,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
final VoidCallback onToggleViewMode; final VoidCallback onToggleViewMode;
final MenuController? controller; final MenuController? controller;
final Future<void> Function(AlbumSort) onSortChanged; final Future<void> Function(AlbumSort) onSortChanged;
final AlbumSortMode currentSortMode;
final bool currentIsReverse;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -481,7 +533,12 @@ class _QuickSortAndViewMode extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_SortButton(onSortChanged, controller: controller), _SortButton(
onSortChanged,
controller: controller,
initialSortMode: currentSortMode,
initialIsReverse: currentIsReverse,
),
IconButton( IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24), icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode, onPressed: onToggleViewMode,

View File

@ -7,7 +7,7 @@ import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@ -1,17 +1,7 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
class ViewerOpenBottomSheetEvent extends Event {
final bool activitiesMode;
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
}
class ViewerReloadAssetEvent extends Event {
const ViewerReloadAssetEvent();
}
class AssetViewerState { class AssetViewerState {
final int backgroundOpacity; final int backgroundOpacity;
final bool showingBottomSheet; final bool showingBottomSheet;

View File

@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@ -29,6 +30,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/action_button.utils.dart'; import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = ''; const _kSeparator = '';
@ -85,13 +87,21 @@ class AssetDetailBottomSheet extends ConsumerWidget {
class _AssetDetailBottomSheet extends ConsumerWidget { class _AssetDetailBottomSheet extends ConsumerWidget {
const _AssetDetailBottomSheet(); const _AssetDetailBottomSheet();
String _getDateTime(BuildContext ctx, BaseAsset asset) { String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
final dateTime = asset.createdAt.toLocal(); DateTime dateTime = asset.createdAt.toLocal();
Duration timeZoneOffset = dateTime.timeZoneOffset;
// Use EXIF timezone information if available (matching web app behavior)
if (exifInfo?.dateTimeOriginal != null) {
(dateTime, timeZoneOffset) = applyTimezoneOffset(
dateTime: exifInfo!.dateTimeOriginal!,
timeZone: exifInfo.timeZone,
);
}
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
final timezone = dateTime.timeZoneOffset.isNegative final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'
: 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}';
return '$date$_kSeparator$time $timezone'; return '$date$_kSeparator$time $timezone';
} }
@ -269,7 +279,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
children: [ children: [
// Asset Date and Time // Asset Date and Time
SheetTile( SheetTile(
title: _getDateTime(context, asset), title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@ -143,11 +143,13 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Flexible(
"enable_backup".t(context: context), child: Text(
style: context.textTheme.titleMedium?.copyWith( "enable_backup".t(context: context),
fontWeight: FontWeight.w600, style: context.textTheme.titleMedium?.copyWith(
color: context.primaryColor, fontWeight: FontWeight.w600,
color: context.primaryColor,
),
), ),
), ),
], ],

View File

@ -3,8 +3,8 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';

View File

@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -70,7 +71,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<List<RemoteAlbum>> sortAlbums( Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { AlbumSortMode sortMode, {
bool isReverse = false, bool isReverse = false,
}) async { }) async {
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse); return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);

View File

@ -2,7 +2,6 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>( final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>(
@ -10,11 +9,6 @@ final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectSta
dependencies: [timelineServiceProvider], dependencies: [timelineServiceProvider],
); );
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
class MultiSelectState { class MultiSelectState {
final Set<BaseAsset> selectedAssets; final Set<BaseAsset> selectedAssets;
final Set<BaseAsset> lockedSelectionAssets; final Set<BaseAsset> lockedSelectionAssets;

View File

@ -245,23 +245,15 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft, transitionsBuilder: TransitionsBuilders.slideLeft,
), ),
CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn), AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute( AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]),
page: TrashRoute.page, AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: SharedLinkRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute( CustomRoute(
page: ActivitiesRoute.page, page: ActivitiesRoute.page,

View File

@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
@ -175,9 +176,17 @@ class ActionService {
} }
final exifData = await _remoteAssetRepository.getExif(assetId); final exifData = await _remoteAssetRepository.getExif(assetId);
initialDate = asset.createdAt.toLocal();
offset = initialDate.timeZoneOffset; // Use EXIF timezone information if available (matching web app and display behavior)
timeZone = exifData?.timeZone; DateTime dt = asset.createdAt.toLocal();
offset = dt.timeZoneOffset;
if (exifData?.dateTimeOriginal != null) {
timeZone = exifData!.timeZone;
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
}
initialDate = dt;
} }
final dateTime = await showDateTimePicker( final dateTime = await showDateTimePicker(

View File

@ -51,9 +51,10 @@ enum AppSettingsEnum<T> {
enableBackup<bool>(StoreKey.enableBackup, null, false), enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false), useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false), backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30), backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@ -1,5 +1,5 @@
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class AlbumFilter { class AlbumFilter {
String? userId; String? userId;
@ -14,12 +14,12 @@ class AlbumFilter {
} }
class AlbumSort { class AlbumSort {
RemoteAlbumSortMode mode; AlbumSortMode mode;
bool isReverse; bool isReverse;
AlbumSort({required this.mode, this.isReverse = false}); AlbumSort({required this.mode, this.isReverse = false});
AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) { AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) {
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse); return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
} }
} }

View File

@ -0,0 +1,35 @@
import 'package:timezone/timezone.dart';
/// Applies timezone conversion to a DateTime using EXIF timezone information.
///
/// This function handles two timezone formats:
/// 1. Named timezone locations (e.g., "Asia/Hong_Kong")
/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00")
///
/// Returns a tuple of (adjusted DateTime, timezone offset Duration)
(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) {
DateTime dt = dateTime.toUtc();
if (timeZone == null) {
return (dt, dt.timeZoneOffset);
}
try {
// Try to get timezone location from database
final location = getLocation(timeZone);
dt = TZDateTime.from(dt, location);
return (dt, dt.timeZoneOffset);
} on LocationNotFoundException {
// Handle UTC offset format (e.g., "UTC+08:00")
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
final m = re.firstMatch(timeZone);
if (m != null) {
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
dt = dt.add(duration);
return (dt, duration);
}
}
// If timezone is invalid, return UTC
return (dt, dt.timeZoneOffset);
}

View File

@ -193,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
InkWell( InkWell(
onTap: () { onTap: () {
context.pop(); context.pop();
launchUrl(Uri.parse('https://immich.app'), mode: LaunchMode.externalApplication); launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication);
}, },
child: Text("documentation", style: context.textTheme.bodySmall).tr(), child: Text("documentation", style: context.textTheme.bodySmall).tr(),
), ),

View File

@ -4,7 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@ -6,8 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@ -108,82 +108,80 @@ class SyncStatusAndActions extends HookConsumerWidget {
); );
} }
return Padding( return ListView(
padding: const EdgeInsets.only(top: 16, bottom: 32), padding: const EdgeInsets.only(top: 16, bottom: 96),
child: ListView( children: [
children: [ const _SyncStatsCounts(),
const _SyncStatsCounts(), const Divider(height: 1, indent: 16, endIndent: 16),
const Divider(height: 1, indent: 16, endIndent: 16), const SizedBox(height: 24),
const SizedBox(height: 24), _SectionHeaderText(text: "jobs".t(context: context)),
_SectionHeaderText(text: "jobs".t(context: context)), ListTile(
ListTile( title: Text(
title: Text( "sync_local".t(context: context),
"sync_local".t(context: context), style: const TextStyle(fontWeight: FontWeight.w500),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
onTap: () {
ref.read(backgroundSyncProvider).syncLocal(full: true);
},
), ),
ListTile( subtitle: Text("tap_to_run_job".t(context: context)),
title: Text( leading: const Icon(Icons.sync),
"sync_remote".t(context: context), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
style: const TextStyle(fontWeight: FontWeight.w500), onTap: () {
), ref.read(backgroundSyncProvider).syncLocal(full: true);
subtitle: Text("tap_to_run_job".t(context: context)), },
leading: const Icon(Icons.cloud_sync), ),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus), ListTile(
onTap: () { title: Text(
ref.read(backgroundSyncProvider).syncRemote(); "sync_remote".t(context: context),
}, style: const TextStyle(fontWeight: FontWeight.w500),
), ),
ListTile( subtitle: Text("tap_to_run_job".t(context: context)),
title: Text( leading: const Icon(Icons.cloud_sync),
"hash_asset".t(context: context), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus),
style: const TextStyle(fontWeight: FontWeight.w500), onTap: () {
), ref.read(backgroundSyncProvider).syncRemote();
leading: const Icon(Icons.tag), },
subtitle: Text("tap_to_run_job".t(context: context)), ),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus), ListTile(
onTap: () { title: Text(
ref.read(backgroundSyncProvider).hashAssets(); "hash_asset".t(context: context),
}, style: const TextStyle(fontWeight: FontWeight.w500),
), ),
const Divider(height: 1, indent: 16, endIndent: 16), leading: const Icon(Icons.tag),
const SizedBox(height: 24), subtitle: Text("tap_to_run_job".t(context: context)),
_SectionHeaderText(text: "actions".t(context: context)), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus),
ListTile( onTap: () {
title: Text( ref.read(backgroundSyncProvider).hashAssets();
"clear_file_cache".t(context: context), },
style: const TextStyle(fontWeight: FontWeight.w500), ),
), const Divider(height: 1, indent: 16, endIndent: 16),
leading: const Icon(Icons.playlist_remove_rounded), const SizedBox(height: 24),
onTap: clearFileCache, _SectionHeaderText(text: "actions".t(context: context)),
ListTile(
title: Text(
"clear_file_cache".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
), ),
ListTile( leading: const Icon(Icons.playlist_remove_rounded),
title: Text( onTap: clearFileCache,
"export_database".t(context: context), ),
style: const TextStyle(fontWeight: FontWeight.w500), ListTile(
), title: Text(
subtitle: Text("export_database_description".t(context: context)), "export_database".t(context: context),
leading: const Icon(Icons.download), style: const TextStyle(fontWeight: FontWeight.w500),
onTap: exportDatabase,
), ),
ListTile( subtitle: Text("export_database_description".t(context: context)),
title: Text( leading: const Icon(Icons.download),
"reset_sqlite".t(context: context), onTap: exportDatabase,
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500), ),
), ListTile(
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error), title: Text(
onTap: () async { "reset_sqlite".t(context: context),
await resetSqliteDb(context); style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
},
), ),
], leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
), onTap: () async {
await resetSqliteDb(context);
},
),
],
); );
} }
} }

View File

@ -86,7 +86,6 @@ class NetworkingSettings extends HookConsumerWidget {
return ListView( return ListView(
padding: const EdgeInsets.only(bottom: 96), padding: const EdgeInsets.only(bottom: 96),
physics: const ClampingScrollPhysics(),
children: <Widget>[ children: <Widget>[
Padding( Padding(
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8),

View File

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@ -76,42 +77,42 @@ void main() {
test('should sort correctly based on name', () async { test('should sort correctly based on name', () async {
final albums = [albumB, albumA]; final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.title); final result = await sut.sortAlbums(albums, AlbumSortMode.title);
expect(result, [albumA, albumB]); expect(result, [albumA, albumB]);
}); });
test('should sort correctly based on createdAt', () async { test('should sort correctly based on createdAt', () async {
final albums = [albumB, albumA]; final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.created); final result = await sut.sortAlbums(albums, AlbumSortMode.created);
expect(result, [albumA, albumB]); expect(result, [albumA, albumB]);
}); });
test('should sort correctly based on updatedAt', () async { test('should sort correctly based on updatedAt', () async {
final albums = [albumB, albumA]; final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.lastModified); final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified);
expect(result, [albumA, albumB]); expect(result, [albumA, albumB]);
}); });
test('should sort correctly based on assetCount', () async { test('should sort correctly based on assetCount', () async {
final albums = [albumB, albumA]; final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.assetCount); final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount);
expect(result, [albumA, albumB]); expect(result, [albumA, albumB]);
}); });
test('should sort correctly based on newestAssetTimestamp', () async { test('should sort correctly based on newestAssetTimestamp', () async {
final albums = [albumB, albumA]; final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostRecent); final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent);
expect(result, [albumA, albumB]); expect(result, [albumA, albumB]);
}); });
test('should sort correctly based on oldestAssetTimestamp', () async { test('should sort correctly based on oldestAssetTimestamp', () async {
final albums = [albumB, albumA]; final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostOldest); final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest);
expect(result, [albumB, albumA]); expect(result, [albumB, albumA]);
}); });
}); });

View File

@ -0,0 +1,278 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:timezone/data/latest.dart' as tz;
void main() {
setUpAll(() {
tz.initializeTimeZones();
});
group('applyTimezoneOffset', () {
group('with named timezone locations', () {
test('should convert UTC to Asia/Hong_Kong (+08:00)', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'Asia/Hong_Kong',
);
expect(adjustedTime.hour, 20); // 12:00 UTC + 8 hours = 20:00
expect(offset, const Duration(hours: 8));
});
test('should convert UTC to America/New_York (handles DST)', () {
// Summer time (EDT = UTC-4)
final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (summerTime, summerOffset) = applyTimezoneOffset(
dateTime: summerUtc,
timeZone: 'America/New_York',
);
expect(summerTime.hour, 8); // 12:00 UTC - 4 hours = 08:00
expect(summerOffset, const Duration(hours: -4));
// Winter time (EST = UTC-5)
final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0);
final (winterTime, winterOffset) = applyTimezoneOffset(
dateTime: winterUtc,
timeZone: 'America/New_York',
);
expect(winterTime.hour, 7); // 12:00 UTC - 5 hours = 07:00
expect(winterOffset, const Duration(hours: -5));
});
test('should convert UTC to Europe/London', () {
// Winter (GMT = UTC+0)
final winterUtc = DateTime.utc(2024, 1, 15, 12, 0, 0);
final (winterTime, winterOffset) = applyTimezoneOffset(
dateTime: winterUtc,
timeZone: 'Europe/London',
);
expect(winterTime.hour, 12);
expect(winterOffset, Duration.zero);
// Summer (BST = UTC+1)
final summerUtc = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (summerTime, summerOffset) = applyTimezoneOffset(
dateTime: summerUtc,
timeZone: 'Europe/London',
);
expect(summerTime.hour, 13);
expect(summerOffset, const Duration(hours: 1));
});
test('should handle timezone with 30-minute offset (Asia/Kolkata)', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'Asia/Kolkata',
);
expect(adjustedTime.hour, 17);
expect(adjustedTime.minute, 30); // 12:00 UTC + 5:30 = 17:30
expect(offset, const Duration(hours: 5, minutes: 30));
});
test('should handle timezone with 45-minute offset (Asia/Kathmandu)', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'Asia/Kathmandu',
);
expect(adjustedTime.hour, 17);
expect(adjustedTime.minute, 45); // 12:00 UTC + 5:45 = 17:45
expect(offset, const Duration(hours: 5, minutes: 45));
});
});
group('with UTC offset format', () {
test('should handle UTC+08:00 format', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'UTC+08:00',
);
expect(adjustedTime.hour, 20);
expect(offset, const Duration(hours: 8));
});
test('should handle UTC-05:00 format', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'UTC-05:00',
);
expect(adjustedTime.hour, 7);
expect(offset, const Duration(hours: -5));
});
test('should handle UTC+8 format (without minutes)', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'UTC+8',
);
expect(adjustedTime.hour, 20);
expect(offset, const Duration(hours: 8));
});
test('should handle UTC-5 format (without minutes)', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'UTC-5',
);
expect(adjustedTime.hour, 7);
expect(offset, const Duration(hours: -5));
});
test('should handle plain UTC format', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'UTC',
);
expect(adjustedTime.hour, 12);
expect(offset, Duration.zero);
});
test('should handle lowercase utc format', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'utc+08:00',
);
expect(adjustedTime.hour, 20);
expect(offset, const Duration(hours: 8));
});
test('should handle UTC+05:30 format (with minutes)', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'UTC+05:30',
);
expect(adjustedTime.hour, 17);
expect(adjustedTime.minute, 30);
expect(offset, const Duration(hours: 5, minutes: 30));
});
});
group('with null or invalid timezone', () {
test('should return UTC time when timezone is null', () {
final localTime = DateTime(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: localTime,
timeZone: null,
);
expect(adjustedTime.isUtc, true);
expect(offset, adjustedTime.timeZoneOffset);
});
test('should return UTC time when timezone is invalid', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'Invalid/Timezone',
);
expect(adjustedTime.isUtc, true);
expect(adjustedTime.hour, 12);
expect(offset, adjustedTime.timeZoneOffset);
});
test('should return UTC time when UTC offset format is malformed', () {
final utcTime = DateTime.utc(2024, 6, 15, 12, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'UTC++08',
);
expect(adjustedTime.isUtc, true);
expect(adjustedTime.hour, 12);
});
});
group('edge cases', () {
test('should handle date crossing midnight forward', () {
final utcTime = DateTime.utc(2024, 6, 15, 20, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'Asia/Tokyo', // UTC+9
);
expect(adjustedTime.day, 16); // Crosses to next day
expect(adjustedTime.hour, 5); // 20:00 UTC + 9 = 05:00 next day
expect(offset, const Duration(hours: 9));
});
test('should handle date crossing midnight backward', () {
final utcTime = DateTime.utc(2024, 6, 15, 3, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'America/Los_Angeles', // UTC-7 in summer
);
expect(adjustedTime.day, 14); // Crosses to previous day
expect(adjustedTime.hour, 20); // 03:00 UTC - 7 = 20:00 previous day
expect(offset, const Duration(hours: -7));
});
test('should handle year boundary crossing', () {
final utcTime = DateTime.utc(2024, 1, 1, 2, 0, 0);
final (adjustedTime, offset) = applyTimezoneOffset(
dateTime: utcTime,
timeZone: 'America/New_York', // UTC-5 in winter
);
expect(adjustedTime.year, 2023);
expect(adjustedTime.month, 12);
expect(adjustedTime.day, 31);
expect(adjustedTime.hour, 21); // 02:00 UTC - 5 = 21:00 Dec 31
});
test('should convert local time to UTC before applying timezone', () {
// Create a local time (not UTC)
final localTime = DateTime(2024, 6, 15, 12, 0, 0);
final (adjustedTime, _) = applyTimezoneOffset(
dateTime: localTime,
timeZone: 'Asia/Hong_Kong',
);
// The function converts to UTC first, then applies timezone
// So local 12:00 -> UTC (depends on local timezone) -> HK time
// We can verify it's working by checking it's a TZDateTime
expect(adjustedTime, isNotNull);
});
});
});
}

View File

@ -432,7 +432,7 @@ importers:
version: 33.5.0 version: 33.5.0
express: express:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.1.0 version: 5.2.0
fast-glob: fast-glob:
specifier: ^3.3.2 specifier: ^3.3.2
version: 3.3.3 version: 3.3.3
@ -492,7 +492,7 @@ importers:
version: 7.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) version: 7.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
nodemailer: nodemailer:
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.0.10 version: 7.0.11
openid-client: openid-client:
specifier: ^6.3.3 specifier: ^6.3.3
version: 6.8.1 version: 6.8.1
@ -718,7 +718,7 @@ importers:
version: link:../open-api/typescript-sdk version: link:../open-api/typescript-sdk
'@immich/ui': '@immich/ui':
specifier: ^0.49.2 specifier: ^0.49.2
version: 0.49.2(svelte@5.43.12) version: 0.49.2(svelte@5.45.2)
'@mapbox/mapbox-gl-rtl-text': '@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3 specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3) version: 0.2.3(mapbox-gl@1.13.3)
@ -751,7 +751,7 @@ importers:
version: 0.41.3 version: 0.41.3
'@zoom-image/svelte': '@zoom-image/svelte':
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.7(svelte@5.43.12) version: 0.3.7(svelte@5.45.2)
async-mutex: async-mutex:
specifier: ^0.5.0 specifier: ^0.5.0
version: 0.5.0 version: 0.5.0
@ -805,13 +805,13 @@ importers:
version: 5.2.2 version: 5.2.2
svelte-i18n: svelte-i18n:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1(svelte@5.43.12) version: 4.0.1(svelte@5.45.2)
svelte-maplibre: svelte-maplibre:
specifier: ^1.2.5 specifier: ^1.2.5
version: 1.2.5(svelte@5.43.12) version: 1.2.5(svelte@5.45.2)
svelte-persisted-store: svelte-persisted-store:
specifier: ^0.12.0 specifier: ^0.12.0
version: 0.12.0(svelte@5.43.12) version: 0.12.0(svelte@5.45.2)
tabbable: tabbable:
specifier: ^6.2.0 specifier: ^6.2.0
version: 6.3.0 version: 6.3.0
@ -833,16 +833,16 @@ importers:
version: 3.1.2 version: 3.1.2
'@sveltejs/adapter-static': '@sveltejs/adapter-static':
specifier: ^3.0.8 specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))) version: 3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))
'@sveltejs/enhanced-img': '@sveltejs/enhanced-img':
specifier: ^0.8.0 specifier: ^0.8.0
version: 0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) version: 0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.27.1 specifier: ^2.27.1
version: 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) version: 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: 6.2.1 specifier: 6.2.1
version: 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) version: 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.7 specifier: ^4.1.7
version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
@ -851,7 +851,7 @@ importers:
version: 6.9.1 version: 6.9.1
'@testing-library/svelte': '@testing-library/svelte':
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.2.9(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) version: 5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@testing-library/user-event': '@testing-library/user-event':
specifier: ^14.5.2 specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1) version: 14.6.1(@testing-library/dom@10.4.1)
@ -890,7 +890,7 @@ importers:
version: 6.0.2(eslint@9.39.1(jiti@2.6.1)) version: 6.0.2(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-svelte: eslint-plugin-svelte:
specifier: ^3.12.4 specifier: ^3.12.4
version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.12) version: 3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2)
eslint-plugin-unicorn: eslint-plugin-unicorn:
specifier: ^62.0.0 specifier: ^62.0.0
version: 62.0.0(eslint@9.39.1(jiti@2.6.1)) version: 62.0.0(eslint@9.39.1(jiti@2.6.1))
@ -911,19 +911,19 @@ importers:
version: 4.1.1(prettier@3.6.2) version: 4.1.1(prettier@3.6.2)
prettier-plugin-svelte: prettier-plugin-svelte:
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.4.0(prettier@3.6.2)(svelte@5.43.12) version: 3.4.0(prettier@3.6.2)(svelte@5.45.2)
rollup-plugin-visualizer: rollup-plugin-visualizer:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.5(rollup@4.53.3) version: 6.0.5(rollup@4.53.3)
svelte: svelte:
specifier: 5.43.12 specifier: 5.45.2
version: 5.43.12 version: 5.45.2
svelte-check: svelte-check:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.9.3) version: 4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3)
svelte-eslint-parser: svelte-eslint-parser:
specifier: ^1.3.3 specifier: ^1.3.3
version: 1.4.0(svelte@5.43.12) version: 1.4.0(svelte@5.45.2)
tailwindcss: tailwindcss:
specifier: ^4.1.7 specifier: ^4.1.7
version: 4.1.17 version: 4.1.17
@ -5537,8 +5537,8 @@ packages:
bl@4.1.0: bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@1.20.3: body-parser@1.20.4:
resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
body-parser@2.2.1: body-parser@2.2.1:
@ -5983,9 +5983,9 @@ packages:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
content-disposition@1.0.0: content-disposition@1.0.1:
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
engines: {node: '>= 0.6'} engines: {node: '>=18'}
content-type@1.0.5: content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
@ -6001,6 +6001,9 @@ packages:
cookie-signature@1.0.6: cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
cookie-signature@1.2.2: cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'} engines: {node: '>=6.6.0'}
@ -6009,10 +6012,6 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
cookie@0.7.1:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'}
cookie@0.7.2: cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -6762,8 +6761,8 @@ packages:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
esrap@2.1.0: esrap@2.2.0:
resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} resolution: {integrity: sha512-WBmtxe7R9C5mvL4n2le8nMUe4mD5V9oiK2vJpQ9I3y20ENPUomPcphBXE8D1x/Bm84oN1V+lOfgXxtqmxTp3Xg==}
esrecurse@4.3.0: esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
@ -6869,14 +6868,18 @@ packages:
exponential-backoff@3.1.3: exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
express@4.21.2: express@4.22.1:
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'} engines: {node: '>= 0.10.0'}
express@5.1.0: express@5.1.0:
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
express@5.2.0:
resolution: {integrity: sha512-XdpJDLxfztVY59X0zPI6sibRiGcxhTPXRD3IhJmjKf2jwMvkRGV1j7loB8U+heeamoU3XvihAaGRTR4aXXUN3A==}
engines: {node: '>= 18'}
exsolve@1.0.7: exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
@ -6981,13 +6984,13 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
finalhandler@1.3.1: finalhandler@1.3.2:
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
finalhandler@2.1.0: finalhandler@2.1.1:
resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 0.8'} engines: {node: '>= 18.0.0'}
find-cache-dir@4.0.0: find-cache-dir@4.0.0:
resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==}
@ -8851,8 +8854,8 @@ packages:
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@7.0.10: nodemailer@7.0.11:
resolution: {integrity: sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==} resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
nopt@1.0.10: nopt@1.0.10:
@ -9863,10 +9866,6 @@ packages:
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
hasBin: true hasBin: true
qs@6.13.0:
resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
engines: {node: '>=0.6'}
qs@6.14.0: qs@6.14.0:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@ -9909,8 +9908,8 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
raw-body@2.5.2: raw-body@2.5.3:
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
raw-body@3.0.2: raw-body@3.0.2:
@ -10331,6 +10330,10 @@ packages:
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
send@0.19.1:
resolution: {integrity: sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==}
engines: {node: '>= 0.8.0'}
send@1.2.0: send@1.2.0:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@ -10772,8 +10775,8 @@ packages:
peerDependencies: peerDependencies:
svelte: ^5.30.2 svelte: ^5.30.2
svelte@5.43.12: svelte@5.45.2:
resolution: {integrity: sha512-d1R+3pFa39LXoHCsxHmV//D2pSFZlEMlnxCVQ54TlrQv+4o5pewJO0/Pc5MUp+j71PJrOrPJHTvREZJHn+ymDQ==} resolution: {integrity: sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==}
engines: {node: '>=18'} engines: {node: '>=18'}
svg-parser@2.0.4: svg-parser@2.0.4:
@ -14695,19 +14698,19 @@ snapshots:
'@immich/justified-layout-wasm@0.4.3': {} '@immich/justified-layout-wasm@0.4.3': {}
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.43.12)': '@immich/svelte-markdown-preprocess@0.1.0(svelte@5.45.2)':
dependencies: dependencies:
svelte: 5.43.12 svelte: 5.45.2
'@immich/ui@0.49.2(svelte@5.43.12)': '@immich/ui@0.49.2(svelte@5.45.2)':
dependencies: dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.12) '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2)
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0
'@mdi/js': 7.4.47 '@mdi/js': 7.4.47
bits-ui: 2.9.8(@internationalized/date@3.10.0)(svelte@5.43.12) bits-ui: 2.9.8(@internationalized/date@3.10.0)(svelte@5.45.2)
luxon: 3.7.2 luxon: 3.7.2
simple-icons: 15.21.0 simple-icons: 15.21.0
svelte: 5.43.12 svelte: 5.45.2
svelte-highlight: 7.8.4 svelte-highlight: 7.8.4
tailwind-merge: 3.3.1 tailwind-merge: 3.3.1
tailwind-variants: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.17) tailwind-variants: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.17)
@ -16208,17 +16211,17 @@ snapshots:
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))': '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))':
dependencies: dependencies:
'@sveltejs/kit': 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/kit': 2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@sveltejs/enhanced-img@0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': '@sveltejs/enhanced-img@0.8.5(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(rollup@4.53.3)(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies: dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
magic-string: 0.30.21 magic-string: 0.30.21
sharp: 0.34.5 sharp: 0.34.5
svelte: 5.43.12 svelte: 5.45.2
svelte-parse-markup: 0.1.5(svelte@5.43.12) svelte-parse-markup: 0.1.5(svelte@5.45.2)
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-imagetools: 8.0.0(rollup@4.53.3) vite-imagetools: 8.0.0(rollup@4.53.3)
zimmerframe: 1.1.4 zimmerframe: 1.1.4
@ -16226,11 +16229,11 @@ snapshots:
- rollup - rollup
- supports-color - supports-color
'@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': '@sveltejs/kit@2.48.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies: dependencies:
'@standard-schema/spec': 1.0.0 '@standard-schema/spec': 1.0.0
'@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0) '@sveltejs/acorn-typescript': 1.0.7(acorn@8.15.0)
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@types/cookie': 0.6.0 '@types/cookie': 0.6.0
acorn: 8.15.0 acorn: 8.15.0
cookie: 0.6.0 cookie: 0.6.0
@ -16242,27 +16245,27 @@ snapshots:
sade: 1.8.1 sade: 1.8.1
set-cookie-parser: 2.7.2 set-cookie-parser: 2.7.2
sirv: 3.0.2 sirv: 3.0.2
svelte: 5.43.12 svelte: 5.45.2
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': '@sveltejs/vite-plugin-svelte-inspector@5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies: dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
debug: 4.4.3 debug: 4.4.3
svelte: 5.43.12 svelte: 5.45.2
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies: dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@sveltejs/vite-plugin-svelte-inspector': 5.0.0(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
debug: 4.4.3 debug: 4.4.3
deepmerge: 4.3.1 deepmerge: 4.3.1
magic-string: 0.30.21 magic-string: 0.30.21
svelte: 5.43.12 svelte: 5.45.2
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
transitivePeerDependencies: transitivePeerDependencies:
@ -16510,10 +16513,10 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
redent: 3.0.0 redent: 3.0.0
'@testing-library/svelte@5.2.9(svelte@5.43.12)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': '@testing-library/svelte@5.2.9(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies: dependencies:
'@testing-library/dom': 10.4.1 '@testing-library/dom': 10.4.1
svelte: 5.43.12 svelte: 5.45.2
optionalDependencies: optionalDependencies:
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
@ -17240,10 +17243,10 @@ snapshots:
dependencies: dependencies:
'@namnode/store': 0.1.0 '@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.7(svelte@5.43.12)': '@zoom-image/svelte@0.3.7(svelte@5.45.2)':
dependencies: dependencies:
'@zoom-image/core': 0.41.3 '@zoom-image/core': 0.41.3
svelte: 5.43.12 svelte: 5.45.2
abab@2.0.6: abab@2.0.6:
optional: true optional: true
@ -17606,15 +17609,15 @@ snapshots:
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bits-ui@2.9.8(@internationalized/date@3.10.0)(svelte@5.43.12): bits-ui@2.9.8(@internationalized/date@3.10.0)(svelte@5.45.2):
dependencies: dependencies:
'@floating-ui/core': 1.7.3 '@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4 '@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0
esm-env: 1.2.2 esm-env: 1.2.2
runed: 0.29.2(svelte@5.43.12) runed: 0.29.2(svelte@5.45.2)
svelte: 5.43.12 svelte: 5.45.2
svelte-toolbelt: 0.9.3(svelte@5.43.12) svelte-toolbelt: 0.9.3(svelte@5.45.2)
tabbable: 6.3.0 tabbable: 6.3.0
bl@4.1.0: bl@4.1.0:
@ -17623,18 +17626,18 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
body-parser@1.20.3: body-parser@1.20.4:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
content-type: 1.0.5 content-type: 1.0.5
debug: 2.6.9 debug: 2.6.9
depd: 2.0.0 depd: 2.0.0
destroy: 1.2.0 destroy: 1.2.0
http-errors: 2.0.0 http-errors: 2.0.1
iconv-lite: 0.4.24 iconv-lite: 0.4.24
on-finished: 2.4.1 on-finished: 2.4.1
qs: 6.13.0 qs: 6.14.0
raw-body: 2.5.2 raw-body: 2.5.3
type-is: 1.6.18 type-is: 1.6.18
unpipe: 1.0.0 unpipe: 1.0.0
transitivePeerDependencies: transitivePeerDependencies:
@ -18118,9 +18121,7 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
content-disposition@1.0.0: content-disposition@1.0.1: {}
dependencies:
safe-buffer: 5.2.1
content-type@1.0.5: {} content-type@1.0.5: {}
@ -18133,12 +18134,12 @@ snapshots:
cookie-signature@1.0.6: {} cookie-signature@1.0.6: {}
cookie-signature@1.0.7: {}
cookie-signature@1.2.2: {} cookie-signature@1.2.2: {}
cookie@0.6.0: {} cookie@0.6.0: {}
cookie@0.7.1: {}
cookie@0.7.2: {} cookie@0.7.2: {}
cookie@1.0.2: {} cookie@1.0.2: {}
@ -18921,7 +18922,7 @@ snapshots:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1)) eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.43.12): eslint-plugin-svelte@3.13.0(eslint@9.39.1(jiti@2.6.1))(svelte@5.45.2):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@ -18933,9 +18934,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.6) postcss-load-config: 3.1.4(postcss@8.5.6)
postcss-safe-parser: 7.0.1(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6)
semver: 7.7.3 semver: 7.7.3
svelte-eslint-parser: 1.4.0(svelte@5.43.12) svelte-eslint-parser: 1.4.0(svelte@5.45.2)
optionalDependencies: optionalDependencies:
svelte: 5.43.12 svelte: 5.45.2
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
@ -19037,7 +19038,7 @@ snapshots:
dependencies: dependencies:
estraverse: 5.3.0 estraverse: 5.3.0
esrap@2.1.0: esrap@2.2.0:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@ -19152,36 +19153,36 @@ snapshots:
exponential-backoff@3.1.3: {} exponential-backoff@3.1.3: {}
express@4.21.2: express@4.22.1:
dependencies: dependencies:
accepts: 1.3.8 accepts: 1.3.8
array-flatten: 1.1.1 array-flatten: 1.1.1
body-parser: 1.20.3 body-parser: 1.20.4
content-disposition: 0.5.4 content-disposition: 0.5.4
content-type: 1.0.5 content-type: 1.0.5
cookie: 0.7.1 cookie: 0.7.2
cookie-signature: 1.0.6 cookie-signature: 1.0.7
debug: 2.6.9 debug: 2.6.9
depd: 2.0.0 depd: 2.0.0
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
finalhandler: 1.3.1 finalhandler: 1.3.2
fresh: 0.5.2 fresh: 0.5.2
http-errors: 2.0.0 http-errors: 2.0.1
merge-descriptors: 1.0.3 merge-descriptors: 1.0.3
methods: 1.1.2 methods: 1.1.2
on-finished: 2.4.1 on-finished: 2.4.1
parseurl: 1.3.3 parseurl: 1.3.3
path-to-regexp: 0.1.12 path-to-regexp: 0.1.12
proxy-addr: 2.0.7 proxy-addr: 2.0.7
qs: 6.13.0 qs: 6.14.0
range-parser: 1.2.1 range-parser: 1.2.1
safe-buffer: 5.2.1 safe-buffer: 5.2.1
send: 0.19.0 send: 0.19.1
serve-static: 1.16.2 serve-static: 1.16.2
setprototypeof: 1.2.0 setprototypeof: 1.2.0
statuses: 2.0.1 statuses: 2.0.2
type-is: 1.6.18 type-is: 1.6.18
utils-merge: 1.0.1 utils-merge: 1.0.1
vary: 1.1.2 vary: 1.1.2
@ -19192,7 +19193,7 @@ snapshots:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
body-parser: 2.2.1 body-parser: 2.2.1
content-disposition: 1.0.0 content-disposition: 1.0.1
content-type: 1.0.5 content-type: 1.0.5
cookie: 0.7.2 cookie: 0.7.2
cookie-signature: 1.2.2 cookie-signature: 1.2.2
@ -19200,7 +19201,40 @@ snapshots:
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
etag: 1.8.1 etag: 1.8.1
finalhandler: 2.1.0 finalhandler: 2.1.1
fresh: 2.0.0
http-errors: 2.0.1
merge-descriptors: 2.0.0
mime-types: 3.0.2
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.7
qs: 6.14.0
range-parser: 1.2.1
router: 2.2.0
send: 1.2.0
serve-static: 2.2.0
statuses: 2.0.2
type-is: 2.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
express@5.2.0:
dependencies:
accepts: 2.0.0
body-parser: 2.2.1
content-disposition: 1.0.1
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.1
fresh: 2.0.0 fresh: 2.0.0
http-errors: 2.0.1 http-errors: 2.0.1
merge-descriptors: 2.0.0 merge-descriptors: 2.0.0
@ -19328,19 +19362,19 @@ snapshots:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
finalhandler@1.3.1: finalhandler@1.3.2:
dependencies: dependencies:
debug: 2.6.9 debug: 2.6.9
encodeurl: 2.0.0 encodeurl: 2.0.0
escape-html: 1.0.3 escape-html: 1.0.3
on-finished: 2.4.1 on-finished: 2.4.1
parseurl: 1.3.3 parseurl: 1.3.3
statuses: 2.0.1 statuses: 2.0.2
unpipe: 1.0.0 unpipe: 1.0.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
finalhandler@2.1.0: finalhandler@2.1.1:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
encodeurl: 2.0.0 encodeurl: 2.0.0
@ -21697,7 +21731,7 @@ snapshots:
node-releases@2.0.27: {} node-releases@2.0.27: {}
nodemailer@7.0.10: {} nodemailer@7.0.11: {}
nopt@1.0.10: nopt@1.0.10:
dependencies: dependencies:
@ -22623,10 +22657,10 @@ snapshots:
dependencies: dependencies:
prettier: 3.6.2 prettier: 3.6.2
prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.43.12): prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.45.2):
dependencies: dependencies:
prettier: 3.6.2 prettier: 3.6.2
svelte: 5.43.12 svelte: 5.45.2
prettier@3.6.2: {} prettier@3.6.2: {}
@ -22739,10 +22773,6 @@ snapshots:
pngjs: 5.0.0 pngjs: 5.0.0
yargs: 15.4.1 yargs: 15.4.1
qs@6.13.0:
dependencies:
side-channel: 1.1.0
qs@6.14.0: qs@6.14.0:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
@ -22775,10 +22805,10 @@ snapshots:
range-parser@1.2.1: {} range-parser@1.2.1: {}
raw-body@2.5.2: raw-body@2.5.3:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
http-errors: 2.0.0 http-errors: 2.0.1
iconv-lite: 0.4.24 iconv-lite: 0.4.24
unpipe: 1.0.0 unpipe: 1.0.0
@ -23232,10 +23262,10 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
runed@0.29.2(svelte@5.43.12): runed@0.29.2(svelte@5.45.2):
dependencies: dependencies:
esm-env: 1.2.2 esm-env: 1.2.2
svelte: 5.43.12 svelte: 5.45.2
rw@1.3.3: {} rw@1.3.3: {}
@ -23342,6 +23372,24 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
send@0.19.1:
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.0
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.1
transitivePeerDependencies:
- supports-color
send@1.2.0: send@1.2.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@ -23851,19 +23899,19 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.43.12)(typescript@5.9.3): svelte-check@4.3.4(picomatch@4.0.3)(svelte@5.45.2)(typescript@5.9.3):
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3 chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picocolors: 1.1.1 picocolors: 1.1.1
sade: 1.8.1 sade: 1.8.1
svelte: 5.43.12 svelte: 5.45.2
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- picomatch - picomatch
svelte-eslint-parser@1.4.0(svelte@5.43.12): svelte-eslint-parser@1.4.0(svelte@5.45.2):
dependencies: dependencies:
eslint-scope: 8.4.0 eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
@ -23872,7 +23920,7 @@ snapshots:
postcss-scss: 4.0.9(postcss@8.5.6) postcss-scss: 4.0.9(postcss@8.5.6)
postcss-selector-parser: 7.1.0 postcss-selector-parser: 7.1.0
optionalDependencies: optionalDependencies:
svelte: 5.43.12 svelte: 5.45.2
svelte-gestures@5.2.2: {} svelte-gestures@5.2.2: {}
@ -23880,7 +23928,7 @@ snapshots:
dependencies: dependencies:
highlight.js: 11.11.1 highlight.js: 11.11.1
svelte-i18n@4.0.1(svelte@5.43.12): svelte-i18n@4.0.1(svelte@5.45.2):
dependencies: dependencies:
cli-color: 2.0.4 cli-color: 2.0.4
deepmerge: 4.3.1 deepmerge: 4.3.1
@ -23888,34 +23936,34 @@ snapshots:
estree-walker: 2.0.2 estree-walker: 2.0.2
intl-messageformat: 10.7.18 intl-messageformat: 10.7.18
sade: 1.8.1 sade: 1.8.1
svelte: 5.43.12 svelte: 5.45.2
tiny-glob: 0.2.9 tiny-glob: 0.2.9
svelte-maplibre@1.2.5(svelte@5.43.12): svelte-maplibre@1.2.5(svelte@5.45.2):
dependencies: dependencies:
d3-geo: 3.1.1 d3-geo: 3.1.1
dequal: 2.0.3 dequal: 2.0.3
just-compare: 2.3.0 just-compare: 2.3.0
maplibre-gl: 5.13.0 maplibre-gl: 5.13.0
pmtiles: 3.2.1 pmtiles: 3.2.1
svelte: 5.43.12 svelte: 5.45.2
svelte-parse-markup@0.1.5(svelte@5.43.12): svelte-parse-markup@0.1.5(svelte@5.45.2):
dependencies: dependencies:
svelte: 5.43.12 svelte: 5.45.2
svelte-persisted-store@0.12.0(svelte@5.43.12): svelte-persisted-store@0.12.0(svelte@5.45.2):
dependencies: dependencies:
svelte: 5.43.12 svelte: 5.45.2
svelte-toolbelt@0.9.3(svelte@5.43.12): svelte-toolbelt@0.9.3(svelte@5.45.2):
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
runed: 0.29.2(svelte@5.43.12) runed: 0.29.2(svelte@5.45.2)
style-to-object: 1.0.11 style-to-object: 1.0.11
svelte: 5.43.12 svelte: 5.45.2
svelte@5.43.12: svelte@5.45.2:
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@ -23925,8 +23973,9 @@ snapshots:
aria-query: 5.3.2 aria-query: 5.3.2
axobject-query: 4.1.0 axobject-query: 4.1.0
clsx: 2.1.1 clsx: 2.1.1
devalue: 5.5.0
esm-env: 1.2.2 esm-env: 1.2.2
esrap: 2.1.0 esrap: 2.2.0
is-reference: 3.0.3 is-reference: 3.0.3
locate-character: 3.0.0 locate-character: 3.0.0
magic-string: 0.30.21 magic-string: 0.30.21
@ -24820,7 +24869,7 @@ snapshots:
colorette: 2.0.20 colorette: 2.0.20
compression: 1.8.1 compression: 1.8.1
connect-history-api-fallback: 2.0.0 connect-history-api-fallback: 2.0.0
express: 4.21.2 express: 4.22.1
graceful-fs: 4.2.11 graceful-fs: 4.2.11
http-proxy-middleware: 2.0.9(@types/express@4.17.25) http-proxy-middleware: 2.0.9(@types/express@4.17.25)
ipaddr.js: 2.2.0 ipaddr.js: 2.2.0

View File

@ -85,19 +85,6 @@ describe(AssetMediaController.name, () => {
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON'])); expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
}); });
it('should validate iCloudId is a string', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]),
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string']));
});
it('should require `deviceAssetId`', async () => { it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer()) const { status, body } = await request(ctx.getHttpServer())
.post('/assets') .post('/assets')

View File

@ -154,13 +154,6 @@ export type StorageAsset = {
encodedVideoPath: string | null; encodedVideoPath: string | null;
}; };
export type SidecarWriteAsset = {
id: string;
sidecarPath: string | null;
originalPath: string;
tags: Array<{ value: string }>;
};
export type Stack = { export type Stack = {
id: string; id: string;
primaryAssetId: string; primaryAssetId: string;

View File

@ -19,7 +19,6 @@ import {
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository'; import { AssetStats } from 'src/repositories/asset.repository';
import { AssetMetadata, AssetMetadataItem } from 'src/types';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class DeviceIdDto { export class DeviceIdDto {
@ -154,23 +153,12 @@ export class AssetMetadataUpsertDto {
items!: AssetMetadataUpsertItemDto[]; items!: AssetMetadataUpsertItemDto[];
} }
export class AssetMetadataUpsertItemDto implements AssetMetadataItem { export class AssetMetadataUpsertItemDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey; key!: AssetMetadataKey;
@IsObject() @IsObject()
@ValidateNested() value!: object;
@Type((options) => {
switch (options?.object.key) {
case AssetMetadataKey.MobileApp: {
return AssetMetadataMobileAppDto;
}
default: {
return Object;
}
}
})
value!: AssetMetadata[AssetMetadataKey];
} }
export class AssetMetadataMobileAppDto { export class AssetMetadataMobileAppDto {

View File

@ -484,21 +484,26 @@ select
"asset_exif"."fileSizeInByte", "asset_exif"."fileSizeInByte",
( (
select select
"asset_file"."path" coalesce(json_agg(agg), '[]')
from from
"asset_file" (
where select
"asset_file"."assetId" = "asset"."id" "asset_file"."id",
and "asset_file"."type" = $1 "asset_file"."path",
limit "asset_file"."type"
$2 from
) as "sidecarPath" "asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."deletedAt" is null "asset"."deletedAt" is null
and "asset"."id" = $3 and "asset"."id" = $2
-- AssetJobRepository.streamForStorageTemplateJob -- AssetJobRepository.streamForStorageTemplateJob
select select
@ -515,15 +520,20 @@ select
"asset_exif"."fileSizeInByte", "asset_exif"."fileSizeInByte",
( (
select select
"asset_file"."path" coalesce(json_agg(agg), '[]')
from from
"asset_file" (
where select
"asset_file"."assetId" = "asset"."id" "asset_file"."id",
and "asset_file"."type" = $1 "asset_file"."path",
limit "asset_file"."type"
$2 from
) as "sidecarPath" "asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"

View File

@ -216,6 +216,34 @@ from
limit limit
3 3
-- AssetRepository.getForCopy
select
"id",
"stackId",
"originalPath",
"isFavorite",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files"
from
"asset"
where
"id" = $1::uuid
limit
$2
-- AssetRepository.getById -- AssetRepository.getById
select select
"asset".* "asset".*

View File

@ -6,7 +6,6 @@ import { Asset, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetType, AssetVisibility } from 'src/enum'; import { AssetFileType, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { StorageAsset } from 'src/types';
import { import {
anyUuid, anyUuid,
asUuid, asUuid,
@ -324,15 +323,13 @@ export class AssetJobRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string): Promise<StorageAsset | undefined> { getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst() as Promise< return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
StorageAsset | undefined
>;
} }
@GenerateSql({ params: [], stream: true }) @GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() { streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().stream() as AsyncIterableIterator<StorageAsset>; return this.storageTemplateAssetQuery().stream();
} }
@GenerateSql({ params: [DummyValue.DATE], stream: true }) @GenerateSql({ params: [DummyValue.DATE], stream: true })

View File

@ -11,7 +11,6 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { AssetMetadataItem } from 'src/types';
import { import {
anyUuid, anyUuid,
asUuid, asUuid,
@ -228,7 +227,7 @@ export class AssetRepository {
.execute(); .execute();
} }
upsertMetadata(id: string, items: AssetMetadataItem[]) { upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) {
return this.db return this.db
.insertInto('asset_metadata') .insertInto('asset_metadata')
.values(items.map((item) => ({ assetId: id, ...item }))) .values(items.map((item) => ({ assetId: id, ...item })))
@ -256,8 +255,23 @@ export class AssetRepository {
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
} }
create(asset: Insertable<AssetTable>) { create(asset: Insertable<AssetTable>, files?: Insertable<AssetFileTable>[]) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); return this.db.transaction().execute(async (trx) => {
const createdAsset = await trx.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
if (files && files.length > 0) {
const values = files.map((f) => ({ ...f, assetId: createdAsset.id }));
await trx.insertInto('asset_file').values(values).returningAll().execute();
}
const assetWithFiles = await trx
.selectFrom('asset')
.selectAll('asset')
.select(withOriginals)
.where('asset.id', '=', asUuid(createdAsset.id))
.executeTakeFirstOrThrow();
return assetWithFiles;
});
} }
createAll(assets: Insertable<AssetTable>[]) { createAll(assets: Insertable<AssetTable>[]) {
@ -403,6 +417,16 @@ export class AssetRepository {
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute(); return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
} }
getForCopy(id: string) {
return this.db
.selectFrom('asset')
.select(['id', 'stackId', 'isFavorite'])
.select(withFiles)
.where('id', '=', asUuid(id))
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getById( getById(
id: string, id: string,
@ -488,6 +512,7 @@ export class AssetRepository {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.selectAll('asset') .selectAll('asset')
.select(withOriginals)
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('checksum', '=', checksum) .where('checksum', '=', checksum)
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null))) .$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
@ -854,12 +879,8 @@ export class AssetRepository {
.execute(); .execute();
} }
async deleteFile(file: Pick<Selectable<AssetFileTable>, 'assetId' | 'type'>): Promise<void> { async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
await this.db await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
.deleteFrom('asset_file')
.where('assetId', '=', asUuid(file.assetId))
.where('type', '=', file.type)
.execute();
} }
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> { async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {

View File

@ -45,12 +45,12 @@ export class OcrRepository {
textScore: DummyValue.NUMBER, textScore: DummyValue.NUMBER,
}, },
], ],
DummyValue.STRING,
], ],
}) })
upsert(assetId: string, ocrDataList: Insertable<AssetOcrTable>[]) { upsert(assetId: string, ocrDataList: Insertable<AssetOcrTable>[], searchText: string) {
let query = this.db.with('deleted_ocr', (db) => db.deleteFrom('asset_ocr').where('assetId', '=', assetId)); let query = this.db.with('deleted_ocr', (db) => db.deleteFrom('asset_ocr').where('assetId', '=', assetId));
if (ocrDataList.length > 0) { if (ocrDataList.length > 0) {
const searchText = ocrDataList.map((item) => item.text.trim()).join(' ');
(query as any) = query (query as any) = query
.with('inserted_ocr', (db) => db.insertInto('asset_ocr').values(ocrDataList)) .with('inserted_ocr', (db) => db.insertInto('asset_ocr').values(ocrDataList))
.with('inserted_search', (db) => .with('inserted_search', (db) =>

View File

@ -0,0 +1,31 @@
import { Kysely, sql } from 'kysely';
import { tokenizeForSearch } from 'src/utils/database';
export async function up(db: Kysely<any>): Promise<void> {
await sql`truncate ${sql.table('ocr_search')}`.execute(db);
let lastAssetId: string | undefined;
while (true) {
const rows = await db
.selectFrom('asset_ocr')
.select(['assetId', sql<string>`string_agg(text, ' ')`.as('text')])
.$if(lastAssetId !== undefined, (qb) => qb.where('assetId', '>', lastAssetId))
.groupBy('assetId')
.orderBy('assetId')
.limit(5000)
.execute();
if (rows.length === 0) {
break;
}
await db
.insertInto('ocr_search')
.values(rows.map(({ assetId, text }) => ({ assetId, text: tokenizeForSearch(text).join(' ') })))
.execute();
lastAssetId = rows.at(-1)!.assetId;
}
}
export async function down(): Promise<void> {}

View File

@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`INSERT INTO "asset_file" ("assetId", "path", "type")
SELECT
id, "sidecarPath", 'sidecar'
FROM "asset"
WHERE "sidecarPath" IS NOT NULL AND "sidecarPath" != '';`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "sidecarPath";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD "sidecarPath" character varying;`.execute(db);
await sql`
UPDATE "asset"
SET "sidecarPath" = "asset_file"."path"
FROM "asset_file"
WHERE "asset"."id" = "asset_file"."assetId" AND "asset_file"."type" = 'sidecar';
`.execute(db);
await sql`DELETE FROM "asset_file" WHERE "type" = 'sidecar';`.execute(db);
}

View File

@ -12,7 +12,6 @@ import {
Timestamp, Timestamp,
UpdateDateColumn, UpdateDateColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
import { AssetMetadata, AssetMetadataItem } from 'src/types';
@UpdatedAtTrigger('asset_metadata_updated_at') @UpdatedAtTrigger('asset_metadata_updated_at')
@Table('asset_metadata') @Table('asset_metadata')
@ -22,7 +21,7 @@ import { AssetMetadata, AssetMetadataItem } from 'src/types';
referencingOldTableAs: 'old', referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0', when: 'pg_trigger_depth() = 0',
}) })
export class AssetMetadataTable<T extends keyof AssetMetadata = AssetMetadataKey> implements AssetMetadataItem<T> { export class AssetMetadataTable {
@ForeignKeyColumn(() => AssetTable, { @ForeignKeyColumn(() => AssetTable, {
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
onDelete: 'CASCADE', onDelete: 'CASCADE',
@ -33,10 +32,10 @@ export class AssetMetadataTable<T extends keyof AssetMetadata = AssetMetadataKey
assetId!: string; assetId!: string;
@PrimaryColumn({ type: 'character varying' }) @PrimaryColumn({ type: 'character varying' })
key!: T; key!: AssetMetadataKey;
@Column({ type: 'jsonb' }) @Column({ type: 'jsonb' })
value!: AssetMetadata[T]; value!: object;
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;

View File

@ -187,7 +187,6 @@ const existingAsset = Object.freeze({
const sidecarAsset = Object.freeze({ const sidecarAsset = Object.freeze({
...existingAsset, ...existingAsset,
sidecarPath: 'sidecar-path',
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'), checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
}) as MapAsset; }) as MapAsset;

View File

@ -366,7 +366,7 @@ export class AssetMediaService extends BaseService {
}); });
await (sidecarPath await (sidecarPath
? this.assetRepository.upsertFile({ assetId, path: sidecarPath, type: AssetFileType.Sidecar }) ? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar })); : this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));

View File

@ -207,8 +207,8 @@ export class AssetService extends BaseService {
}: AssetCopyDto, }: AssetCopyDto,
) { ) {
await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] }); await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] });
const sourceAsset = await this.assetRepository.getById(sourceId, { files: true }); const sourceAsset = await this.assetRepository.getForCopy(sourceId);
const targetAsset = await this.assetRepository.getById(targetId, { files: true }); const targetAsset = await this.assetRepository.getForCopy(targetId);
if (!sourceAsset || !targetAsset) { if (!sourceAsset || !targetAsset) {
throw new BadRequestException('Both assets must exist'); throw new BadRequestException('Both assets must exist');
@ -262,27 +262,20 @@ export class AssetService extends BaseService {
sourceAsset, sourceAsset,
targetAsset, targetAsset,
}: { }: {
sourceAsset: { files?: AssetFile[] }; sourceAsset: { files: AssetFile[] };
targetAsset: { id: string; files?: AssetFile[]; originalPath: string }; targetAsset: { id: string; files: AssetFile[]; originalPath: string };
}) { }) {
if (!sourceAsset.files) { const { sidecarFile: sourceFile } = getAssetFiles(sourceAsset.files);
if (!sourceFile?.path) {
return; return;
} }
const sourceSidecarPath = getAssetFiles(sourceAsset.files).sidecarFile?.path; const { sidecarFile: targetFile } = getAssetFiles(targetAsset.files ?? []);
if (targetFile?.path) {
if (!sourceSidecarPath) { await this.storageRepository.unlink(targetFile.path);
return;
} }
if (targetAsset.files) { await this.storageRepository.copyFile(sourceFile.path, `${targetAsset.originalPath}.xmp`);
const targetSidecar = getAssetFiles(targetAsset.files).sidecarFile;
if (targetSidecar) {
await this.storageRepository.unlink(targetSidecar.path);
}
}
await this.storageRepository.copyFile(sourceSidecarPath, `${targetAsset.originalPath}.xmp`);
await this.assetRepository.upsertFile({ await this.assetRepository.upsertFile({
assetId: targetAsset.id, assetId: targetAsset.id,
path: `${targetAsset.originalPath}.xmp`, path: `${targetAsset.originalPath}.xmp`,

View File

@ -223,7 +223,14 @@ export class LibraryService extends BaseService {
ownerId: dto.ownerId, ownerId: dto.ownerId,
name: dto.name ?? 'New External Library', name: dto.name ?? 'New External Library',
importPaths: dto.importPaths ?? [], importPaths: dto.importPaths ?? [],
exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*', '**/#recycle/**', '**/#snapshot/**'], exclusionPatterns: dto.exclusionPatterns ?? [
'**/@eaDir/**',
'**/._*',
'**/#recycle/**',
'**/#snapshot/**',
'**/.stversions/**',
'**/.stfolder/**',
],
}); });
return mapLibrary(library); return mapLibrary(library);
} }

View File

@ -167,6 +167,8 @@ export class MediaService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
const { originalFile } = getAssetFiles(asset.files);
let generated: { let generated: {
previewPath: string; previewPath: string;
thumbnailPath: string; thumbnailPath: string;
@ -174,9 +176,11 @@ export class MediaService extends BaseService {
thumbhash: Buffer; thumbhash: Buffer;
}; };
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
generated = await this.generateVideoThumbnails(asset); this.logger.verbose(`Thumbnail generation for video ${id} ${originalFile.path}`);
generated = await this.generateVideoThumbnails(asset, originalFile.path);
} else if (asset.type === AssetType.Image) { } else if (asset.type === AssetType.Image) {
generated = await this.generateImageThumbnails(asset); this.logger.verbose(`Thumbnail generation for image ${id} ${originalFile.path}`);
generated = await this.generateImageThumbnails(asset, originalFile.path);
} else { } else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.Skipped; return JobStatus.Skipped;
@ -421,13 +425,13 @@ export class MediaService extends BaseService {
}; };
} }
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { private async generateVideoThumbnails(asset: ThumbnailPathEntity, originalPath: string) {
const { image, ffmpeg } = await this.getConfig({ withCache: true }); const { image, ffmpeg } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format);
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format);
this.storageCore.ensureFolders(previewPath); this.storageCore.ensureFolders(previewPath);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(originalPath);
const mainVideoStream = this.getMainStream(videoStreams); const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) { if (!mainVideoStream) {
throw new Error(`No video streams found for asset ${asset.id}`); throw new Error(`No video streams found for asset ${asset.id}`);

View File

@ -6,7 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
const DAYS = 3; const DAYS = 3;
@ -15,15 +15,6 @@ export class MemoryService extends BaseService {
@OnJob({ name: JobName.MemoryGenerate, queue: QueueName.BackgroundTask }) @OnJob({ name: JobName.MemoryGenerate, queue: QueueName.BackgroundTask })
async onMemoriesCreate() { async onMemoriesCreate() {
const users = await this.userRepository.getList({ withDeleted: false }); const users = await this.userRepository.getList({ withDeleted: false });
const usersIds = await Promise.all(
users.map((user) =>
getMyPartnerIds({
userId: user.id,
repository: this.partnerRepository,
timelineEnabled: true,
}),
),
);
await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => { await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => {
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MemoriesState); const state = await this.systemMetadataRepository.get(SystemMetadataKey.MemoriesState);
@ -38,7 +29,7 @@ export class MemoryService extends BaseService {
} }
try { try {
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); await Promise.all(users.map((owner) => this.createOnThisDayMemories(owner.id, target)));
} catch (error) { } catch (error) {
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`); this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
} }
@ -51,10 +42,10 @@ export class MemoryService extends BaseService {
}); });
} }
private async createOnThisDayMemories(ownerId: string, userIds: string[], target: DateTime) { private async createOnThisDayMemories(ownerId: string, target: DateTime) {
const showAt = target.startOf('day').toISO(); const showAt = target.startOf('day').toISO();
const hideAt = target.endOf('day').toISO(); const hideAt = target.endOf('day').toISO();
const memories = await this.assetRepository.getByDayOfYear([ownerId, ...userIds], target); const memories = await this.assetRepository.getByDayOfYear([ownerId], target);
await Promise.all( await Promise.all(
memories.map(({ year, assets }) => memories.map(({ year, assets }) =>
this.memoryRepository.create( this.memoryRepository.create(

View File

@ -1651,7 +1651,7 @@ describe(MetadataService.name, () => {
dateTimeOriginal: date, dateTimeOriginal: date,
}), }),
).resolves.toBe(JobStatus.Success); ).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, { expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, {
Description: description, Description: description,
ImageDescription: description, ImageDescription: description,
DateTimeOriginal: date, DateTimeOriginal: date,

View File

@ -224,11 +224,7 @@ export class MetadataService extends BaseService {
return; return;
} }
const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; const { originalFile } = getAssetFiles(asset.files);
if (!originalFile) {
this.logger.warn(`Asset ${asset.id} has no original file, skipping metadata extraction`);
return;
}
const [exifTags, stats] = await Promise.all([ const [exifTags, stats] = await Promise.all([
this.getExifTags(asset), this.getExifTags(asset),
@ -307,11 +303,11 @@ export class MetadataService extends BaseService {
]; ];
if (this.isMotionPhoto(asset, exifTags)) { if (this.isMotionPhoto(asset, exifTags)) {
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); promises.push(this.applyMotionPhotos(asset, originalFile.path, exifTags, dates, stats));
} }
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
promises.push(this.applyTaggedFaces(asset, exifTags)); promises.push(this.applyTaggedFaces(asset, originalFile.path, exifTags));
} }
await Promise.all(promises); await Promise.all(promises);
@ -357,7 +353,7 @@ export class MetadataService extends BaseService {
} }
let sidecarPath = null; let sidecarPath = null;
for (const candidate of this.getSidecarCandidates(asset.files)) { for (const candidate of this.getSidecarCandidates(asset)) {
const exists = await this.storageRepository.checkFileExists(candidate, constants.R_OK); const exists = await this.storageRepository.checkFileExists(candidate, constants.R_OK);
if (!exists) { if (!exists) {
continue; continue;
@ -367,12 +363,12 @@ export class MetadataService extends BaseService {
break; break;
} }
const existingSidecar = asset.files?.find((file) => file.type === AssetFileType.Sidecar) ?? null; const { originalFile, sidecarFile } = getAssetFiles(asset.files);
const isChanged = sidecarPath !== existingSidecar?.path; const isChanged = sidecarPath !== sidecarFile?.path;
this.logger.debug( this.logger.debug(
`Sidecar check found old=${existingSidecar?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`, `Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${originalFile.path}`,
); );
if (!isChanged) { if (!isChanged) {
@ -406,14 +402,9 @@ export class MetadataService extends BaseService {
const tagsList = (asset.tags || []).map((tag) => tag.value); const tagsList = (asset.tags || []).map((tag) => tag.value);
const existingSidecar = asset.files?.find((file) => file.type === AssetFileType.Sidecar) ?? null; const { originalFile, sidecarFile } = getAssetFiles(asset.files);
const original = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null;
if (!original) { const sidecarPath = sidecarFile?.path || `${originalFile.path}.xmp`; // prefer file.jpg.xmp by default
throw new Error(`Asset ${asset.id} has no original file`);
}
const sidecarPath = existingSidecar?.path || `${original.path}.xmp`; // prefer file.jpg.xmp by default
const exif = _.omitBy( const exif = _.omitBy(
<Tags>{ <Tags>{
Description: description, Description: description,
@ -440,21 +431,16 @@ export class MetadataService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
private getSidecarCandidates(files: AssetFile[] | null) { private getSidecarCandidates({ id, files }: { id: string; files: AssetFile[] }) {
const original = files?.find((file) => file.type === AssetFileType.Original);
if (!original) {
return [];
}
const candidates: string[] = []; const candidates: string[] = [];
const existingSidecar = files?.find((file) => file.type === AssetFileType.Sidecar); const { originalFile, sidecarFile } = getAssetFiles(files);
if (existingSidecar) { if (sidecarFile?.path) {
candidates.push(existingSidecar.path); candidates.push(sidecarFile.path);
} }
const assetPath = parse(original.path); const assetPath = parse(originalFile.path);
candidates.push( candidates.push(
// IMG_123.jpg.xmp // IMG_123.jpg.xmp
@ -482,26 +468,8 @@ export class MetadataService extends BaseService {
return { width, height }; return { width, height };
} }
private async getExifTags(asset: { id; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> { private async getExifTags(asset: { files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null; const { originalFile, sidecarFile } = getAssetFiles(asset.files);
if (!originalFile) {
throw new Error(`Asset ${asset.id} has no original file`);
}
if (asset.type === AssetType.Image) {
const hasSidecar = asset.files?.some(({ type }) => type === AssetFileType.Sidecar);
if (!hasSidecar) {
return this.metadataRepository.readTags(originalFile.path);
}
}
if (asset.files && asset.files.length > 1) {
throw new Error(`Asset ${originalFile.path} has multiple sidecar files`);
}
const sidecarFile = asset.files ? getAssetFiles(asset.files).sidecarFile : undefined;
const [mediaTags, sidecarTags, videoTags] = await Promise.all([ const [mediaTags, sidecarTags, videoTags] = await Promise.all([
this.metadataRepository.readTags(originalFile.path), this.metadataRepository.readTags(originalFile.path),
@ -632,21 +600,29 @@ export class MetadataService extends BaseService {
if (!motionAsset) { if (!motionAsset) {
try { try {
const motionAssetId = this.cryptoRepository.randomUUID(); const motionAssetId = this.cryptoRepository.randomUUID();
motionAsset = await this.assetRepository.create({ motionAsset = await this.assetRepository.create(
id: motionAssetId, {
libraryId: asset.libraryId, id: motionAssetId,
type: AssetType.Video, libraryId: asset.libraryId,
fileCreatedAt: dates.dateTimeOriginal, type: AssetType.Video,
fileModifiedAt: stats.mtime, fileCreatedAt: dates.dateTimeOriginal,
localDateTime: dates.localDateTime, fileModifiedAt: stats.mtime,
checksum, localDateTime: dates.localDateTime,
ownerId: asset.ownerId, checksum,
files: [{ type: AssetFileType.Original, path: StorageCore.getAndroidMotionPath(asset, motionAssetId) }], ownerId: asset.ownerId,
originalFileName: `${parse(asset.originalFileName).name}.mp4`, originalFileName: `${parse(originalPath).name}.mp4`,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE', deviceAssetId: 'NONE',
deviceId: 'NONE', deviceId: 'NONE',
}); },
[
{
type: AssetFileType.Original,
assetId: motionAssetId,
path: StorageCore.getAndroidMotionPath(asset, motionAssetId),
},
],
);
isNewMotionAsset = true; isNewMotionAsset = true;
@ -660,16 +636,18 @@ export class MetadataService extends BaseService {
motionAsset = await this.assetRepository.getByChecksum(checksumQuery); motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
if (!motionAsset) { if (!motionAsset) {
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`); this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${originalPath}`);
return; return;
} }
} }
} }
const { originalFile: originalMotionFile } = getAssetFiles(motionAsset.files);
if (!isNewMotionAsset) { if (!isNewMotionAsset) {
this.logger.debugFn(() => { this.logger.debugFn(() => {
const base64Checksum = checksum.toString('base64'); const base64Checksum = checksum.toString('base64');
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`; return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${originalPath}`;
}); });
} }
@ -699,22 +677,18 @@ export class MetadataService extends BaseService {
} }
// write extracted motion video to disk, especially if the encoded-video folder has been deleted // write extracted motion video to disk, especially if the encoded-video folder has been deleted
const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); const existsOnDisk = await this.storageRepository.checkFileExists(originalMotionFile.path);
if (!existsOnDisk) { if (!existsOnDisk) {
this.storageCore.ensureFolders(motionAsset.originalPath); this.storageCore.ensureFolders(originalMotionFile.path);
await this.storageRepository.createFile(motionAsset.originalPath, video); await this.storageRepository.createFile(originalMotionFile.path, video);
this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); this.logger.log(`Wrote motion photo video to ${originalMotionFile.path}`);
await this.handleMetadataExtraction({ id: motionAsset.id }); await this.handleMetadataExtraction({ id: motionAsset.id });
await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } }); await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } });
} }
this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${originalPath}`);
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.error( this.logger.error(`Failed to extract motion video for ${asset.id}: ${originalPath}: ${error}`, error?.stack);
`Failed to extract motion video for ${asset.id}: ${asset.originalPath}: ${error}`,
error?.stack,
);
} }
} }
@ -799,7 +773,8 @@ export class MetadataService extends BaseService {
} }
private async applyTaggedFaces( private async applyTaggedFaces(
asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string }, asset: { id: string; ownerId: string; faces: AssetFace[] },
originalPath: string,
tags: ImmichTags, tags: ImmichTags,
) { ) {
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
@ -853,13 +828,11 @@ export class MetadataService extends BaseService {
const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id); const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id);
if (facesToRemove.length > 0) { if (facesToRemove.length > 0) {
this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${originalPath}`);
} }
if (facesToAdd.length > 0) { if (facesToAdd.length > 0) {
this.logger.debug( this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${originalPath}`);
`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${asset.originalPath}`,
);
} }
if (facesToRemove.length > 0 || facesToAdd.length > 0) { if (facesToRemove.length > 0 || facesToAdd.length > 0) {
@ -880,9 +853,7 @@ export class MetadataService extends BaseService {
const result = firstDateTime(exifTags); const result = firstDateTime(exifTags);
const tag = result?.tag; const tag = result?.tag;
const dateTime = result?.dateTime; const dateTime = result?.dateTime;
this.logger.verbose( this.logger.verbose(`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${originalPath}`);
`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`,
);
// timezone // timezone
let timeZone = exifTags.tz ?? null; let timeZone = exifTags.tz ?? null;
@ -893,11 +864,9 @@ export class MetadataService extends BaseService {
} }
if (timeZone) { if (timeZone) {
this.logger.verbose( this.logger.verbose(`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${originalPath}`);
`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`,
);
} else { } else {
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`No timezone information found for asset ${asset.id}: ${originalPath}`);
} }
let dateTimeOriginal = dateTime?.toDateTime(); let dateTimeOriginal = dateTime?.toDateTime();
@ -923,12 +892,12 @@ export class MetadataService extends BaseService {
), ),
); );
this.logger.debug( this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, `No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${originalPath}`,
); );
dateTimeOriginal = localDateTime = earliestDate; dateTimeOriginal = localDateTime = earliestDate;
} }
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`); this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${originalPath}`);
return { return {
timeZone, timeZone,

View File

@ -12,8 +12,21 @@ describe(OcrService.name, () => {
({ sut, mocks } = newTestService(OcrService)); ({ sut, mocks } = newTestService(OcrService));
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
mocks.assetJob.getForOcr.mockResolvedValue({
visibility: AssetVisibility.Timeline,
previewFile: assetStub.image.files[1].path,
});
}); });
const mockOcrResult = (...texts: string[]) => {
mocks.machineLearning.ocr.mockResolvedValue({
box: texts.flatMap((_, i) => Array.from({ length: 8 }, (_, j) => i * 10 + j)),
boxScore: texts.map(() => 0.9),
text: texts,
textScore: texts.map(() => 0.95),
});
};
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
@ -72,10 +85,6 @@ describe(OcrService.name, () => {
text: ['One Two Three', 'Four Five'], text: ['One Two Three', 'Four Five'],
textScore: [0.95, 0.85], textScore: [0.95, 0.85],
}); });
mocks.assetJob.getForOcr.mockResolvedValue({
visibility: AssetVisibility.Timeline,
previewFile: assetStub.image.files[1].path,
});
expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success);
@ -88,36 +97,40 @@ describe(OcrService.name, () => {
maxResolution: 736, maxResolution: 736,
}), }),
); );
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [ expect(mocks.ocr.upsert).toHaveBeenCalledWith(
{ assetStub.image.id,
assetId: assetStub.image.id, [
boxScore: 0.9, {
text: 'One Two Three', assetId: assetStub.image.id,
textScore: 0.95, boxScore: 0.9,
x1: 10, text: 'One Two Three',
y1: 20, textScore: 0.95,
x2: 30, x1: 10,
y2: 40, y1: 20,
x3: 50, x2: 30,
y3: 60, y2: 40,
x4: 70, x3: 50,
y4: 80, y3: 60,
}, x4: 70,
{ y4: 80,
assetId: assetStub.image.id, },
boxScore: 0.8, {
text: 'Four Five', assetId: assetStub.image.id,
textScore: 0.85, boxScore: 0.8,
x1: 90, text: 'Four Five',
y1: 100, textScore: 0.85,
x2: 110, x1: 90,
y2: 120, y1: 100,
x3: 130, x2: 110,
y3: 140, y2: 120,
x4: 150, x3: 130,
y4: 160, y3: 140,
}, x4: 150,
]); y4: 160,
},
],
'One Two Three Four Five',
);
}); });
it('should apply config settings', async () => { it('should apply config settings', async () => {
@ -133,11 +146,7 @@ describe(OcrService.name, () => {
}, },
}, },
}); });
mocks.machineLearning.ocr.mockResolvedValue({ box: [], boxScore: [], text: [], textScore: [] }); mockOcrResult();
mocks.assetJob.getForOcr.mockResolvedValue({
visibility: AssetVisibility.Timeline,
previewFile: assetStub.image.files[1].path,
});
expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success); expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success);
@ -150,7 +159,7 @@ describe(OcrService.name, () => {
maxResolution: 1500, maxResolution: 1500,
}), }),
); );
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, []); expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [], '');
}); });
it('should skip invisible assets', async () => { it('should skip invisible assets', async () => {
@ -173,5 +182,83 @@ describe(OcrService.name, () => {
expect(mocks.machineLearning.ocr).not.toHaveBeenCalled(); expect(mocks.machineLearning.ocr).not.toHaveBeenCalled();
expect(mocks.ocr.upsert).not.toHaveBeenCalled(); expect(mocks.ocr.upsert).not.toHaveBeenCalled();
}); });
describe('search tokenization', () => {
it('should generate bigrams for Chinese text', async () => {
mockOcrResult('機器學習');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習');
});
it('should generate bigrams for Japanese text', async () => {
mockOcrResult('テスト');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'テス スト');
});
it('should generate bigrams for Korean text', async () => {
mockOcrResult('한국어');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '한국 국어');
});
it('should pass through Latin text unchanged', async () => {
mockOcrResult('Hello World');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World');
});
it('should handle mixed CJK and Latin text', async () => {
mockOcrResult('機器學習Model');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習 Model');
});
it('should handle year followed by CJK', async () => {
mockOcrResult('2024年レポート');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(
assetStub.image.id,
expect.any(Array),
'2024 年レ レポ ポー ート',
);
});
it('should join multiple OCR boxes', async () => {
mockOcrResult('機器', 'Learning');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 Learning');
});
it('should normalize whitespace', async () => {
mockOcrResult(' Hello World ');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World');
});
it('should keep single CJK characters', async () => {
mockOcrResult('A', '中', 'B');
await sut.handleOcr({ id: assetStub.image.id });
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'A 中 B');
});
});
}); });
}); });

View File

@ -5,6 +5,7 @@ import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
import { OCR } from 'src/repositories/machine-learning.repository'; import { OCR } from 'src/repositories/machine-learning.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { tokenizeForSearch } from 'src/utils/database';
import { isOcrEnabled } from 'src/utils/misc'; import { isOcrEnabled } from 'src/utils/misc';
@Injectable() @Injectable()
@ -53,8 +54,8 @@ export class OcrService extends BaseService {
} }
const ocrResults = await this.machineLearningRepository.ocr(asset.previewFile, machineLearning.ocr); const ocrResults = await this.machineLearningRepository.ocr(asset.previewFile, machineLearning.ocr);
const { ocrDataList, searchText } = this.parseOcrResults(id, ocrResults);
await this.ocrRepository.upsert(id, this.parseOcrResults(id, ocrResults)); await this.ocrRepository.upsert(id, ocrDataList, searchText);
await this.assetRepository.upsertJobStatus({ assetId: id, ocrAt: new Date() }); await this.assetRepository.upsertJobStatus({ assetId: id, ocrAt: new Date() });
@ -64,7 +65,9 @@ export class OcrService extends BaseService {
private parseOcrResults(id: string, { box, boxScore, text, textScore }: OCR) { private parseOcrResults(id: string, { box, boxScore, text, textScore }: OCR) {
const ocrDataList = []; const ocrDataList = [];
const searchTokens = [];
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text.length; i++) {
const rawText = text[i];
const boxOffset = i * 8; const boxOffset = i * 8;
ocrDataList.push({ ocrDataList.push({
assetId: id, assetId: id,
@ -78,9 +81,11 @@ export class OcrService extends BaseService {
y4: box[boxOffset + 7], y4: box[boxOffset + 7],
boxScore: boxScore[i], boxScore: boxScore[i],
textScore: textScore[i], textScore: textScore[i],
text: text[i], text: rawText,
}); });
searchTokens.push(...tokenizeForSearch(rawText));
} }
return ocrDataList;
return { ocrDataList, searchText: searchTokens.join(' ') };
} }
} }

View File

@ -6,10 +6,20 @@ import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; import {
AssetFileType,
AssetPathType,
AssetType,
DatabaseLock,
JobName,
JobStatus,
QueueName,
StorageFolder,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf, StorageAsset } from 'src/types'; import { JobOf, StorageAsset } from 'src/types';
import { getAssetFile, getAssetFiles } from 'src/utils/asset.util';
import { getLivePhotoMotionFilename } from 'src/utils/file'; import { getLivePhotoMotionFilename } from 'src/utils/file';
const storageTokens = { const storageTokens = {
@ -137,7 +147,8 @@ export class StorageTemplateService extends BaseService {
const user = await this.userRepository.get(asset.ownerId, {}); const user = await this.userRepository.get(asset.ownerId, {});
const storageLabel = user?.storageLabel || null; const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id; const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, { storageLabel, filename }); const { originalFile } = getAssetFiles(asset.files);
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
// move motion part of live photo // move motion part of live photo
if (asset.livePhotoVideoId) { if (asset.livePhotoVideoId) {
@ -145,8 +156,12 @@ export class StorageTemplateService extends BaseService {
if (!livePhotoVideo) { if (!livePhotoVideo) {
return JobStatus.Failed; return JobStatus.Failed;
} }
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); const { originalFile: livePhotoOriginalFile } = getAssetFiles(livePhotoVideo.files);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); const motionFilename = getLivePhotoMotionFilename(filename, livePhotoOriginalFile.path);
await this.moveAsset(
{ originalPath: livePhotoOriginalFile.path, ...livePhotoVideo },
{ storageLabel, filename: motionFilename },
);
} }
return JobStatus.Success; return JobStatus.Success;
} }
@ -170,7 +185,8 @@ export class StorageTemplateService extends BaseService {
const user = users.find((user) => user.id === asset.ownerId); const user = users.find((user) => user.id === asset.ownerId);
const storageLabel = user?.storageLabel || null; const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id; const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, { storageLabel, filename }); const { originalFile } = getAssetFiles(asset.files);
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
} }
this.logger.debug('Cleaning up empty directories...'); this.logger.debug('Cleaning up empty directories...');
@ -196,7 +212,7 @@ export class StorageTemplateService extends BaseService {
} }
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => { return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
const { id, sidecarPath, originalPath, checksum, fileSizeInByte } = asset; const { id, originalPath, checksum, fileSizeInByte } = asset;
const oldPath = originalPath; const oldPath = originalPath;
const newPath = await this.getTemplatePath(asset, metadata); const newPath = await this.getTemplatePath(asset, metadata);
@ -213,6 +229,8 @@ export class StorageTemplateService extends BaseService {
newPath, newPath,
assetInfo: { sizeInBytes: fileSizeInByte, checksum }, assetInfo: { sizeInBytes: fileSizeInByte, checksum },
}); });
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path;
if (sidecarPath) { if (sidecarPath) {
await this.storageCore.moveFile({ await this.storageCore.moveFile({
entityId: id, entityId: id,

View File

@ -1,10 +1,9 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants'; import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset } from 'src/database'; import { Asset, AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
AssetMetadataKey,
AssetOrder, AssetOrder,
AssetType, AssetType,
DatabaseSslMode, DatabaseSslMode,
@ -476,8 +475,8 @@ export type StorageAsset = {
fileCreatedAt: Date; fileCreatedAt: Date;
originalPath: string; originalPath: string;
originalFileName: string; originalFileName: string;
sidecarPath: string | null;
fileSizeInByte: number | null; fileSizeInByte: number | null;
files: AssetFile[];
}; };
export type OnThisDayData = { year: number }; export type OnThisDayData = { year: number };
@ -563,12 +562,3 @@ export interface UserMetadata extends Record<UserMetadataKey, Record<string, any
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string }; [UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
[UserMetadataKey.Onboarding]: { isOnboarded: boolean }; [UserMetadataKey.Onboarding]: { isOnboarded: boolean };
} }
export type AssetMetadataItem<T extends keyof AssetMetadata = AssetMetadataKey> = {
key: T;
value: AssetMetadata[T];
};
export interface AssetMetadata extends Record<AssetMetadataKey, Record<string, any>> {
[AssetMetadataKey.MobileApp]: { iCloudId: string };
}

View File

@ -18,6 +18,13 @@ export const getAssetFile = (files: AssetFile[], type: AssetFileType | Generated
}; };
export const getAssetFiles = (files: AssetFile[]) => ({ export const getAssetFiles = (files: AssetFile[]) => ({
originalFile: (() => {
const file = getAssetFile(files, AssetFileType.Original);
if (!file?.path) {
throw new BadRequestException(`Asset has no original file`); // TODO: should we throw a specific error here that can be caught higher up?
}
return file;
})(),
fullsizeFile: getAssetFile(files, AssetFileType.FullSize), fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
previewFile: getAssetFile(files, AssetFileType.Preview), previewFile: getAssetFile(files, AssetFileType.Preview),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),

View File

@ -320,6 +320,46 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: stri
); );
} }
const isCJK = (c: number): boolean =>
(c >= 0x4e_00 && c <= 0x9f_ff) ||
(c >= 0xac_00 && c <= 0xd7_af) ||
(c >= 0x30_40 && c <= 0x30_9f) ||
(c >= 0x30_a0 && c <= 0x30_ff) ||
(c >= 0x34_00 && c <= 0x4d_bf);
export const tokenizeForSearch = (text: string): string[] => {
/* eslint-disable unicorn/prefer-code-point */
const tokens: string[] = [];
let i = 0;
while (i < text.length) {
const c = text.charCodeAt(i);
if (c <= 32) {
i++;
continue;
}
const start = i;
if (isCJK(c)) {
while (i < text.length && isCJK(text.charCodeAt(i))) {
i++;
}
if (i - start === 1) {
tokens.push(text[start]);
} else {
for (let k = start; k < i - 1; k++) {
tokens.push(text[k] + text[k + 1]);
}
}
} else {
while (i < text.length && text.charCodeAt(i) > 32 && !isCJK(text.charCodeAt(i))) {
i++;
}
tokens.push(text.slice(start, i));
}
}
return tokens;
};
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */ /** TODO: This should only be used for search-related queries, not as a general purpose query builder */
@ -405,7 +445,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.ocr, (qb) => .$if(!!options.ocr, (qb) =>
qb qb
.innerJoin('ocr_search', 'asset.id', 'ocr_search.assetId') .innerJoin('ocr_search', 'asset.id', 'ocr_search.assetId')
.where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${options.ocr!})`), .where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(options.ocr!).join(' ')})`),
) )
.$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!)) .$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))

View File

@ -64,7 +64,7 @@ export const assetStub = {
originalPath: '/original/path.jpg', originalPath: '/original/path.jpg',
originalFileName: 'IMG_123.jpg', originalFileName: 'IMG_123.jpg',
fileSizeInByte: 12_345, fileSizeInByte: 12_345,
sidecarPath: null, files: [],
...asset, ...asset,
}), }),
noResizePath: Object.freeze({ noResizePath: Object.freeze({
@ -553,6 +553,7 @@ export const assetStub = {
fileSizeInByte: 100_000, fileSizeInByte: 100_000,
timeZone: `America/New_York`, timeZone: `America/New_York`,
}, },
files: [] as AssetFile[],
libraryId: null, libraryId: null,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
@ -589,6 +590,7 @@ export const assetStub = {
fileSizeInByte: 25_000, fileSizeInByte: 25_000,
timeZone: `America/New_York`, timeZone: `America/New_York`,
}, },
files: [] as AssetFile[],
libraryId: null, libraryId: null,
faces: [] as AssetFace[], faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,

View File

@ -82,7 +82,11 @@ describe(MetadataService.name, () => {
process.env.TZ = serverTimeZone ?? undefined; process.env.TZ = serverTimeZone ?? undefined;
const { filePath } = await createTestFile(exifData); const { filePath } = await createTestFile(exifData);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as any); mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
id: 'asset-1',
originalPath: filePath,
files: [],
} as any);
await sut.handleMetadataExtraction({ id: 'asset-1' }); await sut.handleMetadataExtraction({ id: 'asset-1' });

View File

@ -10,6 +10,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
updateAllExif: vitest.fn(), updateAllExif: vitest.fn(),
updateDateTimeOriginal: vitest.fn().mockResolvedValue([]), updateDateTimeOriginal: vitest.fn().mockResolvedValue([]),
upsertJobStatus: vitest.fn(), upsertJobStatus: vitest.fn(),
getForCopy: vitest.fn(),
getByDayOfYear: vitest.fn(), getByDayOfYear: vitest.fn(),
getByIds: vitest.fn().mockResolvedValue([]), getByIds: vitest.fn().mockResolvedValue([]),
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]), getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),

View File

@ -8,7 +8,6 @@ import {
Memory, Memory,
Partner, Partner,
Session, Session,
SidecarWriteAsset,
User, User,
UserAdmin, UserAdmin,
} from 'src/database'; } from 'src/database';
@ -246,7 +245,6 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
originalFileName: 'IMG_123.jpg', originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`, originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(), ownerId: newUuid(),
sidecarPath: null,
stackId: null, stackId: null,
thumbhash: null, thumbhash: null,
type: AssetType.Image, type: AssetType.Image,
@ -321,9 +319,8 @@ const versionHistoryFactory = () => ({
version: '1.123.45', version: '1.123.45',
}); });
const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({ const assetSidecarWriteFactory = () => ({
id: newUuid(), id: newUuid(),
sidecarPath: '/path/to/original-path.jpg.xmp',
originalPath: '/path/to/original-path.jpg.xmp', originalPath: '/path/to/original-path.jpg.xmp',
tags: [], tags: [],
files: [ files: [
@ -333,7 +330,6 @@ const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({
type: AssetFileType.Sidecar, type: AssetFileType.Sidecar,
}, },
], ],
...asset,
}); });
const assetOcrFactory = ( const assetOcrFactory = (

View File

@ -97,7 +97,7 @@
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0", "rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.43.12", "svelte": "5.45.2",
"svelte-check": "^4.1.5", "svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3", "svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",

View File

@ -512,7 +512,7 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase() .toLowerCase()
.endsWith('.insp'))} .endsWith('.insp'))}
<ImagePanoramaViewer {asset} /> <ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && selectedEditType === 'crop'} {:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} /> <CropArea {asset} />
{:else} {:else}

View File

@ -7,11 +7,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { type Props = {
asset: AssetResponseDto; asset: AssetResponseDto;
} zoomToggle?: (() => void) | null;
};
const { asset }: Props = $props(); let { asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => { const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@ -24,6 +25,7 @@
<LoadingSpinner /> <LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]} {:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer <PhotoSphereViewer
bind:zoomToggle
panorama={data} panorama={data}
originalPanorama={isWebCompatibleImage(asset) originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id) ? getAssetOriginalUrl(asset.id)

View File

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { import {
EquirectangularAdapter, EquirectangularAdapter,
Viewer, Viewer,
@ -24,15 +26,23 @@
strokeLinejoin: 'round', strokeLinejoin: 'round',
}; };
interface Props { type Props = {
panorama: string | { source: string }; panorama: string | { source: string };
originalPanorama?: string | { source: string }; originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown]; adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean; navbar?: boolean;
} zoomToggle?: (() => void) | null;
};
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props(); let {
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
zoomToggle = $bindable(),
}: Props = $props();
let container: HTMLDivElement | undefined = $state(); let container: HTMLDivElement | undefined = $state();
let viewer: Viewer; let viewer: Viewer;
@ -93,6 +103,14 @@
} }
}); });
zoomToggle = () => {
if (!viewer) {
return;
}
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
};
let hasChangedResolution: boolean = false;
onMount(() => { onMount(() => {
if (!container) { if (!container) {
return; return;
@ -139,10 +157,15 @@
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin); const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100] // zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) { photoZoomState.set({
...$photoZoomState,
currentZoom: zoomLevel / 50,
});
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
// Replace the preview with the original // Replace the preview with the original
void resolutionPlugin.setResolution('original'); void resolutionPlugin.setResolution('original');
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); hasChangedResolution = true;
} }
}; };
@ -158,7 +181,13 @@
viewer.destroy(); viewer.destroy();
} }
boundingBoxesUnsubscribe(); boundingBoxesUnsubscribe();
// zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1.
photoZoomState.set({
...$photoZoomState,
currentZoom: 1,
});
}); });
</script> </script>
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div> <div class="h-full w-full mb-0" bind:this={container}></div>

View File

@ -4,7 +4,7 @@
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time'; import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetVisibility } from '@immich/sdk'; import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,
mdiCameraBurst, mdiCameraBurst,
@ -46,6 +46,7 @@
imageClass?: ClassValue; imageClass?: ClassValue;
brokenAssetClass?: ClassValue; brokenAssetClass?: ClassValue;
dimmed?: boolean; dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset) => void; onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void; onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@ -64,6 +65,7 @@
readonly = false, readonly = false,
showArchiveIcon = false, showArchiveIcon = false,
showStackedIcon = true, showStackedIcon = true,
albumUsers = [],
onClick = undefined, onClick = undefined,
onSelect = undefined, onSelect = undefined,
onMouseEvent = undefined, onMouseEvent = undefined,
@ -85,6 +87,8 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235); let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235); let height = $derived(thumbnailSize || thumbnailHeight || 235);
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
const onIconClickedHandler = (e?: MouseEvent) => { const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation(); e?.stopPropagation();
e?.preventDefault(); e?.preventDefault();
@ -268,6 +272,14 @@
</div> </div>
{/if} {/if}
{#if !!assetOwner}
<div class="absolute bottom-1 end-2 max-w-[50%]">
<p class="text-xs font-medium text-white drop-shadow-lg max-w-[100%] truncate">
{assetOwner.name}
</p>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive} {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}> <div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" /> <Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />

View File

@ -2,13 +2,14 @@
import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte'; import StorageTemplateSettings from '$lib/components/admin-settings/StorageTemplateSettings.svelte';
import FormatMessage from '$lib/elements/FormatMessage.svelte'; import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { Link } from '@immich/ui';
</script> </script>
<div class="flex flex-col"> <div class="flex flex-col">
<p> <p>
<FormatMessage key="admin.storage_template_onboarding_description_v2"> <FormatMessage key="admin.storage_template_onboarding_description_v2">
{#snippet children({ message })} {#snippet children({ message })}
<a class="underline" href="https://docs.immich.app/administration/storage-template">{message}</a> <Link href="https://docs.immich.app/administration/storage-template">{message}</Link>
{/snippet} {/snippet}
</FormatMessage> </FormatMessage>
</p> </p>

View File

@ -23,7 +23,7 @@
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation'; import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte'; import { onDestroy, onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
@ -49,6 +49,7 @@
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto | null; album?: AlbumResponseDto | null;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null; person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void;
@ -81,6 +82,7 @@
showArchiveIcon = false, showArchiveIcon = false,
isShared = false, isShared = false,
album = null, album = null,
albumUsers = [],
person = null, person = null,
isShowDeleteConfirmation = $bindable(false), isShowDeleteConfirmation = $bindable(false),
onSelect = () => {}, onSelect = () => {},
@ -702,6 +704,7 @@
showStackedIcon={withStacked} showStackedIcon={withStacked}
{showArchiveIcon} {showArchiveIcon}
{asset} {asset}
{albumUsers}
{groupIndex} {groupIndex}
onClick={(asset) => { onClick={(asset) => {
if (typeof onThumbnailClick === 'function') { if (typeof onThumbnailClick === 'function') {

View File

@ -80,10 +80,7 @@
const toggleArchive = async () => { const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive; const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility); const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => { timelineManager.update(ids, (asset) => (asset.visibility = visibility));
asset.visibility = visibility;
return { remove: false };
});
deselectAllAssets(); deselectAllAssets();
}; };

View File

@ -6,7 +6,7 @@ import { plainDateTimeCompare } from '$lib/utils/timeline-util';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { MonthGroup } from './month-group.svelte'; import type { MonthGroup } from './month-group.svelte';
import type { AssetOperation, Direction, MoveAsset, TimelineAsset } from './types'; import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
export class DayGroup { export class DayGroup {
@ -101,7 +101,7 @@ export class DayGroup {
return this.viewerAssets.map((viewerAsset) => viewerAsset.asset); return this.viewerAssets.map((viewerAsset) => viewerAsset.asset);
} }
runAssetOperation(ids: Set<string>, operation: AssetOperation) { runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) { if (ids.size === 0) {
return { return {
moveAssets: [] as MoveAsset[], moveAssets: [] as MoveAsset[],
@ -122,7 +122,8 @@ export class DayGroup {
const asset = this.viewerAssets[index].asset!; const asset = this.viewerAssets[index].asset!;
const oldTime = { ...asset.localDateTime }; const oldTime = { ...asset.localDateTime };
let { remove } = operation(asset); const callbackResult = callback(asset);
let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
const newTime = asset.localDateTime; const newTime = asset.localDateTime;
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) { if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime; const { year, month, day } = newTime;

View File

@ -1,104 +0,0 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
import { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte';
import type { AssetOperation, TimelineAsset } from '../types';
import { updateGeometry } from './layout-support.svelte';
import { getMonthGroupByDate } from './search-support.svelte';
export function addAssetsToMonthGroups(
timelineManager: TimelineManager,
assets: TimelineAsset[],
options: { order: AssetOrder },
) {
if (assets.length === 0) {
return;
}
const addContext = new GroupInsertionCache();
const updatedMonthGroups = new SvelteSet<MonthGroup>();
const monthCount = timelineManager.months.length;
for (const asset of assets) {
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
if (!month) {
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
month.isLoaded = true;
timelineManager.months.push(month);
}
month.addTimelineAsset(asset, addContext);
updatedMonthGroups.add(month);
}
if (timelineManager.months.length !== monthCount) {
timelineManager.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(options.order);
}
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of addContext.updatedBuckets) {
month.sortDayGroups();
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
timelineManager.updateIntersections();
}
export function runAssetOperation(
timelineManager: TimelineManager,
ids: Set<string>,
operation: AssetOperation,
options: { order: AssetOrder },
) {
if (ids.size === 0) {
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
}
const changedMonthGroups = new SvelteSet<MonthGroup>();
let idsToProcess = new SvelteSet(ids);
const idsProcessed = new SvelteSet<string>();
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
for (const month of timelineManager.months) {
if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets);
}
idsToProcess = setDifference(idsToProcess, processedIds);
for (const id of processedIds) {
idsProcessed.add(id);
}
if (changedGeometry) {
changedMonthGroups.add(month);
}
}
}
if (combinedMoveAssets.length > 0) {
addAssetsToMonthGroups(
timelineManager,
combinedMoveAssets.flat().map((a) => a.asset),
options,
);
}
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(timelineManager, month, { invalidateHeight: true });
}
if (changedGeometry) {
timelineManager.updateIntersections();
}
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
}

View File

@ -21,7 +21,7 @@ import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte';
import type { TimelineManager } from './timeline-manager.svelte'; import type { TimelineManager } from './timeline-manager.svelte';
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types'; import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup { export class MonthGroup {
@ -50,12 +50,13 @@ export class MonthGroup {
readonly yearMonth: TimelineYearMonth; readonly yearMonth: TimelineYearMonth;
constructor( constructor(
store: TimelineManager, timelineManager: TimelineManager,
yearMonth: TimelineYearMonth, yearMonth: TimelineYearMonth,
initialCount: number, initialCount: number,
loaded: boolean,
order: AssetOrder = AssetOrder.Desc, order: AssetOrder = AssetOrder.Desc,
) { ) {
this.timelineManager = store; this.timelineManager = timelineManager;
this.#initialCount = initialCount; this.#initialCount = initialCount;
this.#sortOrder = order; this.#sortOrder = order;
@ -72,6 +73,9 @@ export class MonthGroup {
}, },
this.#handleLoadError, this.#handleLoadError,
); );
if (loaded) {
this.isLoaded = true;
}
} }
set intersecting(newValue: boolean) { set intersecting(newValue: boolean) {
@ -112,7 +116,7 @@ export class MonthGroup {
return this.dayGroups.sort((a, b) => b.day - a.day); return this.dayGroups.sort((a, b) => b.day - a.day);
} }
runAssetOperation(ids: Set<string>, operation: AssetOperation) { runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) { if (ids.size === 0) {
return { return {
moveAssets: [] as MoveAsset[], moveAssets: [] as MoveAsset[],
@ -130,7 +134,7 @@ export class MonthGroup {
while (index--) { while (index--) {
if (idsToProcess.size > 0) { if (idsToProcess.size > 0) {
const group = dayGroups[index]; const group = dayGroups[index];
const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation); const { moveAssets, processedIds, changedGeometry } = group.runAssetCallback(ids, callback);
if (moveAssets.length > 0) { if (moveAssets.length > 0) {
combinedMoveAssets.push(moveAssets); combinedMoveAssets.push(moveAssets);
} }

View File

@ -278,10 +278,11 @@ describe('TimelineManager', () => {
}); });
it('updates existing asset', () => { it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build()); const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
timelineManager.upsertAssets([asset]); timelineManager.upsertAssets([asset]);
timelineManager.upsertAssets([asset]); expect(updateAssetsSpy).toBeCalledWith([asset]);
expect(timelineManager.assetCount).toEqual(1); expect(timelineManager.assetCount).toEqual(1);
}); });
@ -691,4 +692,42 @@ describe('TimelineManager', () => {
expect(discoveredAssets.size).toBe(assetCount); expect(discoveredAssets.size).toBe(assetCount);
}); });
}); });
describe('showAssetOwners', () => {
const LS_KEY = 'album-show-asset-owners';
beforeEach(() => {
// ensure clean state
globalThis.localStorage?.removeItem(LS_KEY);
});
it('defaults to false', () => {
const timelineManager = new TimelineManager();
expect(timelineManager.showAssetOwners).toBe(false);
});
it('setShowAssetOwners updates value', () => {
const timelineManager = new TimelineManager();
timelineManager.setShowAssetOwners(true);
expect(timelineManager.showAssetOwners).toBe(true);
timelineManager.setShowAssetOwners(false);
expect(timelineManager.showAssetOwners).toBe(false);
});
it('toggleShowAssetOwners flips value', () => {
const timelineManager = new TimelineManager();
expect(timelineManager.showAssetOwners).toBe(false);
timelineManager.toggleShowAssetOwners();
expect(timelineManager.showAssetOwners).toBe(true);
timelineManager.toggleShowAssetOwners();
expect(timelineManager.showAssetOwners).toBe(false);
});
it('persists across instances via localStorage', () => {
const a = new TimelineManager();
a.setShowAssetOwners(true);
const b = new TimelineManager();
expect(b.showAssetOwners).toBe(true);
});
});
}); });

View File

@ -1,12 +1,9 @@
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.svelte';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte'; import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte'; import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
import {
addAssetsToMonthGroups,
runAssetOperation,
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
import { import {
findClosestGroupForDate, findClosestGroupForDate,
findMonthGroupForAsset as findMonthGroupForAssetUtil, findMonthGroupForAsset as findMonthGroupForAssetUtil,
@ -17,17 +14,23 @@ import {
} from '$lib/managers/timeline-manager/internal/search-support.svelte'; } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte'; import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
setDifference,
toTimelineAsset,
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
import { isMismatched, updateObject } from './internal/utils.svelte'; import { isMismatched, updateObject } from './internal/utils.svelte';
import { MonthGroup } from './month-group.svelte'; import { MonthGroup } from './month-group.svelte';
import type { import type {
AssetDescriptor, AssetDescriptor,
AssetOperation,
Direction, Direction,
MoveAsset,
ScrubberMonth, ScrubberMonth,
TimelineAsset, TimelineAsset,
TimelineManagerOptions, TimelineManagerOptions,
@ -88,6 +91,19 @@ export class TimelineManager extends VirtualScrollManager {
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS; #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
#updatingIntersections = false; #updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state(); #scrollableElement: HTMLElement | undefined = $state();
#showAssetOwners = new PersistedLocalStorage<boolean>('album-show-asset-owners', false);
get showAssetOwners() {
return this.#showAssetOwners.current;
}
setShowAssetOwners(value: boolean) {
this.#showAssetOwners.current = value;
}
toggleShowAssetOwners() {
this.#showAssetOwners.current = !this.#showAssetOwners.current;
}
constructor() { constructor() {
super(); super();
@ -218,6 +234,7 @@ export class TimelineManager extends VirtualScrollManager {
this, this,
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }, { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
timeBucket.count, timeBucket.count,
false,
this.#options.order, this.#options.order,
); );
}); });
@ -323,7 +340,7 @@ export class TimelineManager extends VirtualScrollManager {
upsertAssets(assets: TimelineAsset[]) { upsertAssets(assets: TimelineAsset[]) {
const notUpdated = this.#updateAssets(assets); const notUpdated = this.#updateAssets(assets);
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset)); const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
addAssetsToMonthGroups(this, [...notExcluded], { order: this.#options.order ?? AssetOrder.Desc }); this.addAssetsUpsertSegments([...notExcluded]);
} }
async findMonthGroupForAsset(id: string) { async findMonthGroupForAsset(id: string) {
@ -400,38 +417,107 @@ export class TimelineManager extends VirtualScrollManager {
return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset; return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
} }
updateAssetOperation(ids: string[], operation: AssetOperation) { /**
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc }); * Executes callback on assets, handling moves between groups and removals due to filter criteria.
} */
update(ids: string[], callback: (asset: TimelineAsset) => void) {
#updateAssets(assets: TimelineAsset[]) { // eslint-disable-next-line svelte/prefer-svelte-reactivity
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset])); return this.#runAssetCallback(new Set(ids), callback);
const { unprocessedIds } = runAssetOperation(
this,
new SvelteSet(lookup.keys()),
(asset) => {
updateObject(asset, lookup.get(asset.id));
return { remove: false };
},
{ order: this.#options.order ?? AssetOrder.Desc },
);
const result: TimelineAsset[] = [];
for (const id of unprocessedIds.values()) {
result.push(lookup.get(id)!);
}
return result;
} }
removeAssets(ids: string[]) { removeAssets(ids: string[]) {
const { unprocessedIds } = runAssetOperation( // eslint-disable-next-line svelte/prefer-svelte-reactivity
this, const result = this.#runAssetCallback(new Set(ids), () => ({ remove: true }));
new SvelteSet(ids), return [...result.notUpdated];
() => { }
return { remove: true };
}, protected upsertSegmentForAsset(asset: TimelineAsset) {
{ order: this.#options.order ?? AssetOrder.Desc }, let month = getMonthGroupByDate(this, asset.localDateTime);
);
return [...unprocessedIds]; if (!month) {
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
this.months.push(month);
}
return month;
}
/**
* Adds assets to existing segments, creating new segments as needed.
*
* This is an internal method that assumes the provided assets are not already
* present in the timeline. For updating existing assets, use updateAssetOperation().
*/
protected addAssetsUpsertSegments(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const context = new GroupInsertionCache();
const monthCount = this.months.length;
for (const asset of assets) {
this.upsertSegmentForAsset(asset).addTimelineAsset(asset, context);
}
if (this.months.length !== monthCount) {
this.postCreateSegments();
}
this.postUpsert(context);
}
#updateAssets(assets: TimelineAsset[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const cache = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const idsToUpdate = new Set(cache.keys());
const result = this.#runAssetCallback(idsToUpdate, (asset) => void updateObject(asset, cache.get(asset.id)));
const notUpdated: TimelineAsset[] = [];
for (const assetId of result.notUpdated) {
notUpdated.push(cache.get(assetId)!);
}
return notUpdated;
}
#runAssetCallback(ids: Set<string>, callback: (asset: TimelineAsset) => void | { remove?: boolean }) {
if (ids.size === 0) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
return { updated: new Set<string>(), notUpdated: ids, changedGeometry: false };
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const changedMonthGroups = new Set<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
let notUpdated = new Set(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<string>();
const assetsToMoveSegments: MoveAsset[][] = [];
for (const month of this.months) {
if (notUpdated.size === 0) {
break;
}
const result = month.runAssetCallback(notUpdated, callback);
if (result.moveAssets.length > 0) {
assetsToMoveSegments.push(result.moveAssets);
}
if (result.changedGeometry) {
changedMonthGroups.add(month);
}
notUpdated = setDifference(notUpdated, result.processedIds);
for (const id of result.processedIds) {
updated.add(id);
}
}
const assetsToAdd = [];
for (const segment of assetsToMoveSegments) {
for (const moveAsset of segment) {
assetsToAdd.push(moveAsset.asset);
}
}
this.addAssetsUpsertSegments(assetsToAdd);
const changedGeometry = changedMonthGroups.size > 0;
for (const month of changedMonthGroups) {
updateGeometry(this, month, { invalidateHeight: true });
}
if (changedGeometry) {
this.updateIntersections();
}
return { updated, notUpdated, changedGeometry };
} }
override refreshLayout() { override refreshLayout() {
@ -493,4 +579,28 @@ export class TimelineManager extends VirtualScrollManager {
getAssetOrder() { getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc; return this.#options.order ?? AssetOrder.Desc;
} }
protected postCreateSegments(): void {
this.months.sort((a, b) => {
return a.yearMonth.year === b.yearMonth.year
? b.yearMonth.month - a.yearMonth.month
: b.yearMonth.year - a.yearMonth.year;
});
}
protected postUpsert(context: GroupInsertionCache): void {
for (const group of context.existingDayGroups) {
group.sortAssets(this.#options.order);
}
for (const monthGroup of context.bucketsWithNewDayGroups) {
monthGroup.sortDayGroups();
}
for (const month of context.updatedBuckets) {
month.sortDayGroups();
updateGeometry(this, month, { invalidateHeight: true });
}
this.updateIntersections();
}
} }

View File

@ -37,8 +37,6 @@ export type TimelineAsset = {
longitude?: number | null; longitude?: number | null;
}; };
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate }; export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
export interface Viewport { export interface Viewport {

View File

@ -14,7 +14,7 @@
const { onClose, baseTag }: Props = $props(); const { onClose, baseTag }: Props = $props();
let tagValue = $state(baseTag?.value ? `${baseTag.value}/` : ''); let tagValue = $state(baseTag?.path ? `${baseTag.path}/` : '');
const createTag = async () => { const createTag = async () => {
const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } }); const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } });

View File

@ -79,14 +79,15 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
*/ */
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) { export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
if (stack != undefined) { if (stack != undefined) {
timelineManager.updateAssetOperation([stack.primaryAssetId], (asset) => { timelineManager.update(
asset.stack = { [stack.primaryAssetId],
id: stack.id, (asset) =>
primaryAssetId: stack.primaryAssetId, (asset.stack = {
assetCount: stack.assets.length, id: stack.id,
}; primaryAssetId: stack.primaryAssetId,
return { remove: false }; assetCount: stack.assets.length,
}); }),
);
timelineManager.removeAssets(toDeleteIds); timelineManager.removeAssets(toDeleteIds);
} }
@ -101,7 +102,7 @@ export function updateStackedAssetInTimeline(timelineManager: TimelineManager, {
* @param assets - The array of asset response DTOs to update in the timeline manager. * @param assets - The array of asset response DTOs to update in the timeline manager.
*/ */
export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) { export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
timelineManager.updateAssetOperation( timelineManager.update(
assets.map((asset) => asset.id), assets.map((asset) => asset.id),
(asset) => { (asset) => {
asset.stack = null; asset.stack = null;

View File

@ -62,8 +62,16 @@ export class TreeNode extends Map<string, TreeNode> {
const child = this.values().next().value!; const child = this.values().next().value!;
child.value = joinPaths(this.value, child.value); child.value = joinPaths(this.value, child.value);
child.parent = this.parent; child.parent = this.parent;
this.parent.delete(this.value);
this.parent.set(child.value, child); const entries = Array.from(this.parent.entries());
this.parent.clear();
for (const [key, value] of entries) {
if (key === this.value) {
this.parent.set(child.value, child);
} else {
this.parent.set(key, value);
}
}
} }
for (const child of this.values()) { for (const child of this.values()) {

View File

@ -66,6 +66,8 @@
} from '@immich/sdk'; } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui'; import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import { import {
mdiAccountEye,
mdiAccountEyeOutline,
mdiArrowLeft, mdiArrowLeft,
mdiCogOutline, mdiCogOutline,
mdiDeleteOutline, mdiDeleteOutline,
@ -101,6 +103,9 @@
let isShowActivity = $state(false); let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order); let albumOrder: AssetOrder | undefined = $state(data.album.order);
let timelineManager = $state<TimelineManager>() as TimelineManager;
let showAlbumUsers = $derived(timelineManager?.showAssetOwners ?? false);
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction(); const timelineInteraction = new AssetInteraction();
@ -290,13 +295,17 @@
let album = $derived(data.album); let album = $derived(data.album);
let albumId = $derived(album.id); let albumId = $derived(album.id);
const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor));
const albumUsers = $derived(
showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [],
);
$effect(() => { $effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) { if (!album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false; isShowActivity = false;
} }
}); });
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = $derived.by(() => { const options = $derived.by(() => {
if (viewMode === AlbumPageViewMode.SELECT_ASSETS) { if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
return { return {
@ -418,6 +427,7 @@
<Timeline <Timeline
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true} enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album} {album}
{albumUsers}
bind:timelineManager bind:timelineManager
{options} {options}
assetInteraction={currentAssetIntersection} assetInteraction={currentAssetIntersection}
@ -547,11 +557,7 @@
{#if assetInteraction.isAllUserOwned} {#if assetInteraction.isAllUserOwned}
<FavoriteAction <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite} removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction> ></FavoriteAction>
{/if} {/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
@ -570,11 +576,7 @@
<ArchiveAction <ArchiveAction
menuItem menuItem
unarchive={assetInteraction.isAllArchived} unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) => onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/> />
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} /> <SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if} {/if}
@ -657,6 +659,13 @@
color="secondary" color="secondary"
offset={{ x: 175, y: 25 }} offset={{ x: 175, y: 25 }}
> >
{#if containsEditors}
<MenuOption
icon={showAlbumUsers ? mdiAccountEye : mdiAccountEyeOutline}
text={$t('view_asset_owners')}
onClick={() => timelineManager.toggleShowAssetOwners()}
/>
{/if}
{#if album.assetCount > 0} {#if album.assetCount > 0}
<MenuOption <MenuOption
icon={mdiImageOutline} icon={mdiImageOutline}

View File

@ -66,11 +66,7 @@
> >
<ArchiveAction <ArchiveAction
unarchive unarchive
onArchive={(ids, visibility) => onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/> />
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} /> <SelectAllAssets {timelineManager} {assetInteraction} />
@ -80,11 +76,7 @@
</ButtonContextMenu> </ButtonContextMenu>
<FavoriteAction <FavoriteAction
removeFavorite={assetInteraction.isAllFavorite} removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
/> />
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}> <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem /> <DownloadAction menuItem />

View File

@ -85,11 +85,7 @@
<ArchiveAction <ArchiveAction
menuItem menuItem
unarchive={assetInteraction.isAllArchived} unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) => onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
})}
/> />
{#if $preferences.tags.enabled} {#if $preferences.tags.enabled}
<TagAction menuItem /> <TagAction menuItem />

Some files were not shown because too many files have changed in this diff Show More