Compare commits

..

5 Commits

Author SHA1 Message Date
Alex Tran ad996d1b88 wip: fav button 2026-04-26 22:05:56 -05:00
Alex Tran 08a73c0978 fix: tests 2026-04-26 13:06:17 -05:00
Alex 976ce39fe2 Merge branch 'main' into feat/favorite-albums 2026-04-26 09:18:12 -05:00
Alex a162230a75 chore: regenerate mobile openapi sdk 2026-04-23 04:15:14 +00:00
Alex 166f36e5bf feat(server,web): favorite albums per user 2026-04-23 03:33:32 +00:00
142 changed files with 1987 additions and 2053 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tools]
terragrunt = "1.0.2"
terragrunt = "1.0.1"
opentofu = "1.11.6"
[tasks."tg:fmt"]
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1 -1
View File
@@ -39,7 +39,7 @@ You can learn how to set up Tailscale together with Immich with the [tutorial vi
### Cons
- The Tailscale client usually needs to run as root on your devices and it increases the attack surface slightly compared to a minimal Wireguard server. e.g., an [RCE vulnerability](https://github.com/tailscale/tailscale/security/advisories/GHSA-vqp6-rc3h-83cp) was discovered in the Windows Tailscale client in November 2022.
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
- Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) that permits up to 3 users and up to 100 devices.
- Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy
+39
View File
@@ -2,43 +2,82 @@ import { expect } from 'vitest';
export const errorDto = {
unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password',
correlationId: expect.any(String),
},
invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token',
correlationId: expect.any(String),
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
correlationId: expect.any(String),
},
passwordRequired: {
error: 'Unauthorized',
statusCode: 401,
message: 'Password required',
correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
correlationId: expect.any(String),
}),
noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
},
incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password',
correlationId: expect.any(String),
},
alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin',
correlationId: expect.any(String),
},
invalidEmail: {
error: 'Bad Request',
statusCode: 400,
message: ['email must be an email'],
correlationId: expect.any(String),
},
};
+4 -1
View File
@@ -332,7 +332,9 @@ describe(`/oauth`, () => {
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(500);
expect(body).toMatchObject({
error: 'Internal Server Error',
message: 'Failed to finish oauth',
statusCode: 500,
});
});
@@ -493,10 +495,11 @@ describe(`/oauth`, () => {
});
it('should reject OAuth discovery over HTTP', async () => {
const { status } = await request(app)
const { status, body } = await request(app)
.post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(500);
expect(body).toMatchObject({ statusCode: 500 });
});
});
});
+1 -4
View File
@@ -183,10 +183,7 @@ async def predict(
text: str | None = Form(default=None),
) -> Any:
if image is not None:
decoded = await run(lambda: decode_pil(image))
if decoded.width == 0 or decoded.height == 0:
raise HTTPException(400, "Image has zero width or height")
inputs: Image | str = decoded
inputs: Image | str = await run(lambda: decode_pil(image))
elif text is not None:
inputs = text
else:
-13
View File
@@ -1198,19 +1198,6 @@ class TestLoad:
mock_model.model_format = ModelFormat.ONNX
@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
response = deployed_app.post(
"http://localhost:3003/predict",
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
files={"image": b"fake image bytes"},
)
assert response.status_code == 400
assert "zero" in response.json()["detail"].lower()
def test_root_endpoint(deployed_app: TestClient) -> None:
response = deployed_app.get("http://localhost:3003")
+3 -3
View File
@@ -15,9 +15,9 @@ config_roots = [
[tools]
node = "24.15.0"
flutter = "3.41.7"
pnpm = "10.33.1"
terragrunt = "1.0.2"
flutter = "3.41.6"
pnpm = "10.33.0"
terragrunt = "1.0.1"
opentofu = "1.11.6"
java = "21.0.2"
+1 -1
View File
@@ -1,5 +1,5 @@
app_identifier "app.alextran.immich" # The bundle identifier of your app
apple_id "altran@futo.org" # Your Apple email address
apple_id "alex.tran1502@gmail.com" # Your Apple email address
# For more information about the Appfile, see:
+41 -41
View File
@@ -17,11 +17,10 @@ default_platform(:ios)
platform :ios do
# Constants
TEAM_ID = "2W7AC6T8T5"
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
TEAM_ID = "2F67MQ8R79"
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
# Helper method to get App Store Connect API key
def get_api_key
app_store_connect_api_key(
@@ -45,45 +44,47 @@ def get_version_from_pubspec
end
# Helper method to configure code signing for all targets
def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app)
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: base_bundle_id,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
profile_name: profile_name_main,
targets: ["Runner"]
)
# ShareExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{base_bundle_id}.ShareExtension",
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
profile_name: profile_name_share,
targets: ["ShareExtension"]
)
# WidgetExtension
update_code_signing_settings(
use_automatic_signing: false,
path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{base_bundle_id}.Widget",
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
profile_name: profile_name_widget,
targets: ["WidgetExtension"]
)
end
# Helper method to build and upload to TestFlight
def build_and_upload(
api_key:,
base_bundle_id:,
bundle_id_suffix: "",
configuration: "Release",
distribute_external: true,
version_number: nil,
@@ -91,8 +92,9 @@ end
profile_name_share:,
profile_name_widget:
)
app_identifier = base_bundle_id
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
# Set version number if provided
if version_number
increment_version_number(version_number: version_number)
@@ -136,31 +138,31 @@ end
desc "iOS Development Build to TestFlight (requires separate bundle ID)"
lane :gha_testflight_dev do
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing(
base_bundle_id: DEV_BUNDLE_ID,
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build and upload
build_and_upload(
api_key: api_key,
base_bundle_id: DEV_BUNDLE_ID,
bundle_id_suffix: "development",
configuration: "Profile",
distribute_external: false,
profile_name_main: main_profile_name,
@@ -187,7 +189,6 @@ end
# Configure code signing for production bundle IDs
configure_code_signing(
base_bundle_id: BASE_BUNDLE_ID,
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
@@ -196,7 +197,6 @@ end
# Build and upload with version number
build_and_upload(
api_key: api_key,
base_bundle_id: BASE_BUNDLE_ID,
version_number: get_version_from_pubspec,
distribute_external: false,
profile_name_main: main_profile_name,
@@ -243,30 +243,30 @@ end
desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do
# Use the same build process as the dev TestFlight lane, just skip the upload
# This ensures PR builds validate the same way as dev TestFlight builds
# Use the same build process as production, just skip the upload
# This ensures PR builds validate the same way as production builds
api_key = get_api_key
# Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs
configure_code_signing(
base_bundle_id: DEV_BUNDLE_ID,
bundle_id_suffix: "development",
profile_name_main: main_profile_name,
profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name
)
# Build the app (same as gha_testflight_dev but without upload)
build_app(
scheme: "Runner",
@@ -277,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: {
provisioningProfiles: {
DEV_BUNDLE_ID => main_profile_name,
"#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
"#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
},
signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY
@@ -3,7 +3,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -192,22 +191,17 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
await _runWithManageMediaPermission(
logContext: "Trashed Assets",
action: () async {
await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id));
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
await _applyRemoteRestoreToLocal();
},
);
} else {
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
return;
case SyncEntityType.assetDeleteV1:
await _runWithManageMediaPermission(
logContext: "Deleted Assets",
action: () async {
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId));
},
);
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -388,32 +382,28 @@ class SyncStreamService {
}
}
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) {
await _trashLocalAssets(localAssetsToTrash);
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
}
}
}
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
@@ -423,21 +413,4 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _runWithManageMediaPermission({
required String logContext,
required Future<void> Function() action,
}) async {
if (!CurrentPlatform.isAndroid || !Store.get(StoreKey.manageLocalMediaAndroid, false)) {
return;
}
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await action();
}
}
@@ -109,40 +109,31 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return {};
}
final result = <String, List<LocalAsset>>{};
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice),
_db.localAssetEntity.checksum.isIn(slice),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
return result;
}
@@ -7,7 +7,6 @@ import 'package:flutter/material.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/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
@@ -364,8 +363,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
BaseAsset displayAsset = asset;
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex);
}
@@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class AssetStackRow extends ConsumerWidget {
final List<RemoteAsset> stack;
@@ -17,11 +15,6 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink();
}
final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
if (hideAssetStack) {
return const SizedBox.shrink();
}
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
@@ -21,7 +21,6 @@ class ThumbnailTile extends ConsumerStatefulWidget {
this.showStorageIndicator = false,
this.lockSelection = false,
this.heroOffset,
this.showStackIndicator = false,
super.key,
});
@@ -31,7 +30,6 @@ class ThumbnailTile extends ConsumerStatefulWidget {
final bool showStorageIndicator;
final bool lockSelection;
final int? heroOffset;
final bool showStackIndicator;
@override
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
@@ -141,14 +139,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
duration: Durations.short4,
child: Align(
alignment: Alignment.topRight,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_AssetTypeIcons(asset: asset),
if (widget.showStackIndicator) _StackIndicator(asset: asset),
],
),
child: _AssetTypeIcons(asset: asset),
),
),
if (storageIndicator && asset != null)
@@ -295,8 +286,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override
Widget build(BuildContext context) {
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -304,6 +295,11 @@ class _AssetTypeIcons extends StatelessWidget {
children: [
if (asset.isVideo)
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)),
if (hasStack)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
),
if (isLivePhoto)
const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
@@ -316,24 +312,6 @@ class _AssetTypeIcons extends StatelessWidget {
}
}
class _StackIndicator extends StatelessWidget {
final BaseAsset asset;
const _StackIndicator({required this.asset});
@override
Widget build(BuildContext context) {
if (asset is! RemoteAsset || (asset as RemoteAsset).stackId == null) {
return const SizedBox.shrink();
}
return const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
);
}
}
class _UploadProgressOverlay extends StatelessWidget {
final double progress;
@@ -244,7 +244,6 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
return RepaintBoundary(
child: GestureDetector(
@@ -254,7 +253,6 @@ class _AssetTileWidget extends ConsumerWidget {
asset,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
showStackIndicator: showStackIndicator,
heroOffset: heroOffset,
),
),
@@ -148,7 +148,6 @@ enum ActionButtonType {
context.selectedCount == 1,
ActionButtonType.unstack =>
context.isOwner && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
+12 -3
View File
@@ -508,9 +508,12 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] favorite:
/// Filter to only albums favorited by the authenticated user
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async {
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? favorite, bool? shared, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -524,6 +527,9 @@ class AlbumsApi {
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (favorite != null) {
queryParams.addAll(_queryParams('', 'favorite', favorite));
}
if (shared != null) {
queryParams.addAll(_queryParams('', 'shared', shared));
}
@@ -551,10 +557,13 @@ class AlbumsApi {
/// * [String] assetId:
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] favorite:
/// Filter to only albums favorited by the authenticated user
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, );
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? favorite, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, favorite: favorite, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+6 -6
View File
@@ -183,15 +183,15 @@ class PeopleApi {
/// * [String] closestPersonId:
/// Closest person ID for similarity search
///
/// * [int] page:
/// * [num] page:
/// Page number for pagination
///
/// * [int] size:
/// * [num] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/people';
@@ -244,15 +244,15 @@ class PeopleApi {
/// * [String] closestPersonId:
/// Closest person ID for similarity search
///
/// * [int] page:
/// * [num] page:
/// Page number for pagination
///
/// * [int] size:
/// * [num] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+6 -6
View File
@@ -404,10 +404,10 @@ class SearchApi {
/// * [List<String>] personIds:
/// Filter by person IDs
///
/// * [int] rating:
/// * [num] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [int] size:
/// * [num] size:
/// Number of results to return
///
/// * [String] state:
@@ -443,7 +443,7 @@ class SearchApi {
///
/// * [bool] withExif:
/// Include EXIF data in response
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/search/large-assets';
@@ -619,10 +619,10 @@ class SearchApi {
/// * [List<String>] personIds:
/// Filter by person IDs
///
/// * [int] rating:
/// * [num] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [int] size:
/// * [num] size:
/// Number of results to return
///
/// * [String] state:
@@ -658,7 +658,7 @@ class SearchApi {
///
/// * [bool] withExif:
/// Include EXIF data in response
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, ocr: ocr, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+10 -1
View File
@@ -24,6 +24,7 @@ class AlbumResponseDto {
required this.hasSharedLink,
required this.id,
required this.isActivityEnabled,
required this.isFavorite,
this.lastModifiedAssetTimestamp,
this.order,
required this.shared,
@@ -72,6 +73,9 @@ class AlbumResponseDto {
/// Activity feed enabled
bool isActivityEnabled;
/// Whether the authenticated user has favorited this album
bool isFavorite;
/// Last modified asset timestamp
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -117,6 +121,7 @@ class AlbumResponseDto {
other.hasSharedLink == hasSharedLink &&
other.id == id &&
other.isActivityEnabled == isActivityEnabled &&
other.isFavorite == isFavorite &&
other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp &&
other.order == order &&
other.shared == shared &&
@@ -137,6 +142,7 @@ class AlbumResponseDto {
(hasSharedLink.hashCode) +
(id.hashCode) +
(isActivityEnabled.hashCode) +
(isFavorite.hashCode) +
(lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) +
(order == null ? 0 : order!.hashCode) +
(shared.hashCode) +
@@ -144,7 +150,7 @@ class AlbumResponseDto {
(updatedAt.hashCode);
@override
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
String toString() => 'AlbumResponseDto[albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, contributorCounts=$contributorCounts, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, id=$id, isActivityEnabled=$isActivityEnabled, isFavorite=$isFavorite, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -167,6 +173,7 @@ class AlbumResponseDto {
json[r'hasSharedLink'] = this.hasSharedLink;
json[r'id'] = this.id;
json[r'isActivityEnabled'] = this.isActivityEnabled;
json[r'isFavorite'] = this.isFavorite;
if (this.lastModifiedAssetTimestamp != null) {
json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
} else {
@@ -207,6 +214,7 @@ class AlbumResponseDto {
hasSharedLink: mapValueOfType<bool>(json, r'hasSharedLink')!,
id: mapValueOfType<String>(json, r'id')!,
isActivityEnabled: mapValueOfType<bool>(json, r'isActivityEnabled')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
order: AssetOrder.fromJson(json[r'order']),
shared: mapValueOfType<bool>(json, r'shared')!,
@@ -268,6 +276,7 @@ class AlbumResponseDto {
'hasSharedLink',
'id',
'isActivityEnabled',
'isFavorite',
'shared',
'updatedAt',
};
+2 -5
View File
@@ -37,15 +37,12 @@ class AssetBulkUpdateDto {
/// Relative time offset in seconds
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? dateTimeRelative;
num? dateTimeRelative;
/// Asset description
///
@@ -216,7 +213,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable
@@ -24,26 +24,22 @@ class AssetEditActionItemDtoParameters {
/// Height of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int height;
num height;
/// Width of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int width;
num width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int x;
num x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int y;
num y;
/// Rotation angle in degrees
num angle;
@@ -92,10 +88,10 @@ class AssetEditActionItemDtoParameters {
final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters(
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!,
);
+8 -6
View File
@@ -80,8 +80,7 @@ class AssetResponseDto {
/// Asset height
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? height;
num? height;
/// Asset ID
String id;
@@ -166,8 +165,7 @@ class AssetResponseDto {
/// Asset width
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? width;
num? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -348,7 +346,9 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: mapValueOfType<int>(json, r'height'),
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
@@ -372,7 +372,9 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: mapValueOfType<int>(json, r'width'),
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
);
}
return null;
+8 -12
View File
@@ -22,26 +22,22 @@ class CropParameters {
/// Height of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int height;
num height;
/// Width of the crop
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int width;
num width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int x;
num x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int y;
num y;
@override
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
@@ -79,10 +75,10 @@ class CropParameters {
final json = value.cast<String, dynamic>();
return CropParameters(
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
);
}
return null;
+2 -3
View File
@@ -27,8 +27,7 @@ class DatabaseBackupConfig {
/// Keep last amount
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
int keepLastAmount;
num keepLastAmount;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
@@ -65,7 +64,7 @@ class DatabaseBackupConfig {
return DatabaseBackupConfig(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
keepLastAmount: num.parse('${json[r'keepLastAmount']}'),
);
}
return null;
+2 -5
View File
@@ -22,10 +22,7 @@ class DatabaseBackupDto {
String filename;
/// Backup file size
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int filesize;
num filesize;
/// Backup timezone
String timezone;
@@ -64,7 +61,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: mapValueOfType<int>(json, r'filesize')!,
filesize: num.parse('${json[r'filesize']}'),
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}
+16 -16
View File
@@ -52,14 +52,12 @@ class ExifResponseDto {
/// Image height in pixels
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? exifImageHeight;
num? exifImageHeight;
/// Image width in pixels
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? exifImageWidth;
num? exifImageWidth;
/// Exposure time
String? exposureTime;
@@ -77,10 +75,7 @@ class ExifResponseDto {
num? focalLength;
/// ISO sensitivity
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? iso;
num? iso;
/// GPS latitude
num? latitude;
@@ -107,10 +102,7 @@ class ExifResponseDto {
String? projectionType;
/// Rating
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? rating;
num? rating;
/// State/province name
String? state;
@@ -300,8 +292,12 @@ class ExifResponseDto {
country: mapValueOfType<String>(json, r'country'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
description: mapValueOfType<String>(json, r'description'),
exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
exifImageHeight: json[r'exifImageHeight'] == null
? null
: num.parse('${json[r'exifImageHeight']}'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse('${json[r'exifImageWidth']}'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
fNumber: json[r'fNumber'] == null
? null
@@ -310,7 +306,9 @@ class ExifResponseDto {
focalLength: json[r'focalLength'] == null
? null
: num.parse('${json[r'focalLength']}'),
iso: mapValueOfType<int>(json, r'iso'),
iso: json[r'iso'] == null
? null
: num.parse('${json[r'iso']}'),
latitude: json[r'latitude'] == null
? null
: num.parse('${json[r'latitude']}'),
@@ -323,7 +321,9 @@ class ExifResponseDto {
modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'),
projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: mapValueOfType<int>(json, r'rating'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
);
@@ -21,13 +21,9 @@ class MachineLearningAvailabilityChecksDto {
/// Enabled
bool enabled;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int interval;
num interval;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int timeout;
num timeout;
@override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
@@ -63,8 +59,8 @@ class MachineLearningAvailabilityChecksDto {
return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: mapValueOfType<int>(json, r'interval')!,
timeout: mapValueOfType<int>(json, r'timeout')!,
interval: num.parse('${json[r'interval']}'),
timeout: num.parse('${json[r'timeout']}'),
);
}
return null;
@@ -20,10 +20,7 @@ class MaintenanceDetectInstallStorageFolderDto {
});
/// Number of files in the folder
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int files;
num files;
StorageFolder folder;
@@ -69,7 +66,7 @@ class MaintenanceDetectInstallStorageFolderDto {
final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto(
files: mapValueOfType<int>(json, r'files')!,
files: num.parse('${json[r'files']}'),
folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!,
@@ -32,15 +32,13 @@ class MaintenanceStatusResponseDto {
///
String? error;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? progress;
num? progress;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -104,7 +102,7 @@ class MaintenanceStatusResponseDto {
action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'),
progress: mapValueOfType<int>(json, r'progress'),
progress: num.parse('${json[r'progress']}'),
task: mapValueOfType<String>(json, r'task'),
);
}
+8 -7
View File
@@ -215,14 +215,13 @@ class MetadataSearchDto {
/// Page number
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? page;
num? page;
/// Filter by person IDs
List<String> personIds;
@@ -240,7 +239,7 @@ class MetadataSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Number of results to return
///
@@ -252,7 +251,7 @@ class MetadataSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? size;
num? size;
/// Filter by state/province name
String? state;
@@ -725,13 +724,15 @@ class MetadataSearchDto {
order: AssetOrder.fromJson(json[r'order']),
originalFileName: mapValueOfType<String>(json, r'originalFileName'),
originalPath: mapValueOfType<String>(json, r'originalPath'),
page: mapValueOfType<int>(json, r'page'),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+6 -4
View File
@@ -147,7 +147,7 @@ class RandomSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Number of results to return
///
@@ -159,7 +159,7 @@ class RandomSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? size;
num? size;
/// Filter by state/province name
String? state;
@@ -549,8 +549,10 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+2 -3
View File
@@ -39,14 +39,13 @@ class SessionCreateDto {
/// Session duration in seconds
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? duration;
num? duration;
@override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
@@ -95,7 +94,7 @@ class SessionCreateDto {
return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: mapValueOfType<int>(json, r'duration'),
duration: num.parse('${json[r'duration']}'),
);
}
return null;
+8 -7
View File
@@ -154,14 +154,13 @@ class SmartSearchDto {
/// Page number
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? page;
num? page;
/// Filter by person IDs
List<String> personIds;
@@ -188,7 +187,7 @@ class SmartSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Number of results to return
///
@@ -200,7 +199,7 @@ class SmartSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
int? size;
num? size;
/// Filter by state/province name
String? state;
@@ -584,14 +583,16 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'),
page: mapValueOfType<int>(json, r'page'),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+4 -2
View File
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
int? rating;
num? rating;
/// Filter by state/province name
String? state;
@@ -479,7 +479,9 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: mapValueOfType<int>(json, r'rating'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+10 -1
View File
@@ -14,6 +14,7 @@ class SyncAlbumUserV1 {
/// Returns a new [SyncAlbumUserV1] instance.
SyncAlbumUserV1({
required this.albumId,
required this.isFavorite,
required this.role,
required this.userId,
});
@@ -21,6 +22,9 @@ class SyncAlbumUserV1 {
/// Album ID
String albumId;
/// Favorite flag
bool isFavorite;
AlbumUserRole role;
/// User ID
@@ -29,6 +33,7 @@ class SyncAlbumUserV1 {
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAlbumUserV1 &&
other.albumId == albumId &&
other.isFavorite == isFavorite &&
other.role == role &&
other.userId == userId;
@@ -36,15 +41,17 @@ class SyncAlbumUserV1 {
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumId.hashCode) +
(isFavorite.hashCode) +
(role.hashCode) +
(userId.hashCode);
@override
String toString() => 'SyncAlbumUserV1[albumId=$albumId, role=$role, userId=$userId]';
String toString() => 'SyncAlbumUserV1[albumId=$albumId, isFavorite=$isFavorite, role=$role, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumId'] = this.albumId;
json[r'isFavorite'] = this.isFavorite;
json[r'role'] = this.role;
json[r'userId'] = this.userId;
return json;
@@ -60,6 +67,7 @@ class SyncAlbumUserV1 {
return SyncAlbumUserV1(
albumId: mapValueOfType<String>(json, r'albumId')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
role: AlbumUserRole.fromJson(json[r'role'])!,
userId: mapValueOfType<String>(json, r'userId')!,
);
@@ -110,6 +118,7 @@ class SyncAlbumUserV1 {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'albumId',
'isFavorite',
'role',
'userId',
};
+4 -3
View File
@@ -57,8 +57,7 @@ class SystemConfigOAuthDto {
/// Default storage quota
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int? defaultStorageQuota;
num? defaultStorageQuota;
/// Enabled
bool enabled;
@@ -201,7 +200,9 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: mapValueOfType<int>(json, r'defaultStorageQuota'),
defaultStorageQuota: json[r'defaultStorageQuota'] == null
? null
: num.parse('${json[r'defaultStorageQuota']}'),
enabled: mapValueOfType<bool>(json, r'enabled')!,
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
///
/// Minimum value: 0
/// Maximum value: 65535
int port;
num port;
/// Whether to use secure connection (TLS/SSL)
bool secure;
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
host: mapValueOfType<String>(json, r'host')!,
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!,
port: mapValueOfType<int>(json, r'port')!,
port: num.parse('${json[r'port']}'),
secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!,
);
+33 -6
View File
@@ -13,26 +13,53 @@ part of openapi.api;
class UpdateAlbumUserDto {
/// Returns a new [UpdateAlbumUserDto] instance.
UpdateAlbumUserDto({
required this.role,
this.isFavorite,
this.role,
});
AlbumUserRole role;
/// Mark album as favorite for the user (only the user themselves can update)
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AlbumUserRole? role;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateAlbumUserDto &&
other.isFavorite == isFavorite &&
other.role == role;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(role.hashCode);
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(role == null ? 0 : role!.hashCode);
@override
String toString() => 'UpdateAlbumUserDto[role=$role]';
String toString() => 'UpdateAlbumUserDto[isFavorite=$isFavorite, role=$role]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.role != null) {
json[r'role'] = this.role;
} else {
// json[r'role'] = null;
}
return json;
}
@@ -45,7 +72,8 @@ class UpdateAlbumUserDto {
final json = value.cast<String, dynamic>();
return UpdateAlbumUserDto(
role: AlbumUserRole.fromJson(json[r'role'])!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
role: AlbumUserRole.fromJson(json[r'role']),
);
}
return null;
@@ -93,7 +121,6 @@ class UpdateAlbumUserDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'role',
};
}
+2 -5
View File
@@ -26,10 +26,7 @@ class WorkflowActionResponseDto {
String id;
/// Action order
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
num order;
/// Plugin action ID
String pluginActionId;
@@ -82,7 +79,7 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!,
order: num.parse('${json[r'order']}'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
+2 -5
View File
@@ -26,10 +26,7 @@ class WorkflowFilterResponseDto {
String id;
/// Filter order
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
num order;
/// Plugin filter ID
String pluginFilterId;
@@ -82,7 +79,7 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: mapValueOfType<int>(json, r'order')!,
order: num.parse('${json[r'order']}'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
@@ -419,8 +419,8 @@ void main() {
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
return assetsByAlbum;
});
@@ -482,18 +482,12 @@ void main() {
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
return {};
});
test("does not request local deletions for permanent remote delete events", () async {
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
+58 -72
View File
@@ -1648,6 +1648,15 @@
"type": "string"
}
},
{
"name": "favorite",
"required": false,
"in": "query",
"description": "Filter to only albums favorited by the authenticated user",
"schema": {
"type": "boolean"
}
},
{
"name": "shared",
"required": false,
@@ -7964,9 +7973,8 @@
"description": "Page number for pagination",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"default": 1,
"type": "integer"
"type": "number"
}
},
{
@@ -7978,7 +7986,7 @@
"minimum": 1,
"maximum": 1000,
"default": 500,
"type": "integer"
"type": "number"
}
},
{
@@ -9373,7 +9381,7 @@
],
"x-immich-state": "Stable",
"schema": {
"type": "integer",
"type": "number",
"minimum": -1,
"maximum": 5,
"nullable": true
@@ -9387,7 +9395,7 @@
"schema": {
"minimum": 1,
"maximum": 1000,
"type": "integer"
"type": "number"
}
},
{
@@ -15325,6 +15333,10 @@
"description": "Activity feed enabled",
"type": "boolean"
},
"isFavorite": {
"description": "Whether the authenticated user has favorited this album",
"type": "boolean"
},
"lastModifiedAssetTimestamp": {
"description": "Last modified asset timestamp",
"format": "date-time",
@@ -15358,6 +15370,7 @@
"hasSharedLink",
"id",
"isActivityEnabled",
"isFavorite",
"shared",
"updatedAt"
],
@@ -15637,9 +15650,7 @@
},
"dateTimeRelative": {
"description": "Relative time offset in seconds",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"description": {
"description": "Asset description",
@@ -16653,10 +16664,9 @@
},
"height": {
"description": "Asset height",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"id": {
"description": "Asset ID",
@@ -16799,10 +16809,9 @@
},
"width": {
"description": "Asset width",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -17219,27 +17228,23 @@
"properties": {
"height": {
"description": "Height of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"width": {
"description": "Width of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"x": {
"description": "Top-Left X coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
"type": "number"
},
"y": {
"description": "Top-Left Y coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -17263,9 +17268,8 @@
},
"keepLastAmount": {
"description": "Keep last amount",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -17298,9 +17302,7 @@
},
"filesize": {
"description": "Backup file size",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"timezone": {
"description": "Backup timezone",
@@ -17639,18 +17641,16 @@
"exifImageHeight": {
"default": null,
"description": "Image height in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"exifImageWidth": {
"default": null,
"description": "Image width in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"exposureTime": {
"default": null,
@@ -17681,10 +17681,8 @@
"iso": {
"default": null,
"description": "ISO sensitivity",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
"type": "number"
},
"latitude": {
"default": null,
@@ -17738,10 +17736,8 @@
"rating": {
"default": null,
"description": "Rating",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "integer"
"type": "number"
},
"state": {
"default": null,
@@ -18168,14 +18164,10 @@
"type": "boolean"
},
"interval": {
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"timeout": {
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
}
},
"required": [
@@ -18225,9 +18217,7 @@
"properties": {
"files": {
"description": "Number of files in the folder",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"folder": {
"$ref": "#/components/schemas/StorageFolder"
@@ -18270,9 +18260,7 @@
"type": "string"
},
"progress": {
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"task": {
"type": "string"
@@ -18749,9 +18737,8 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"personIds": {
"description": "Filter by person IDs",
@@ -18771,7 +18758,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -18793,7 +18780,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "integer"
"type": "number"
},
"state": {
"description": "Filter by state/province name",
@@ -20624,7 +20611,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -20646,7 +20633,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "integer"
"type": "number"
},
"state": {
"description": "Filter by state/province name",
@@ -21464,9 +21451,8 @@
},
"duration": {
"description": "Session duration in seconds",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
}
},
"type": "object"
@@ -21980,9 +21966,8 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "integer"
"type": "number"
},
"personIds": {
"description": "Filter by person IDs",
@@ -22008,7 +21993,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -22030,7 +22015,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "integer"
"type": "number"
},
"state": {
"description": "Filter by state/province name",
@@ -22268,7 +22253,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "integer",
"type": "number",
"x-immich-history": [
{
"version": "v1",
@@ -22481,6 +22466,10 @@
"description": "Album ID",
"type": "string"
},
"isFavorite": {
"description": "Favorite flag",
"type": "boolean"
},
"role": {
"$ref": "#/components/schemas/AlbumUserRole"
},
@@ -22491,6 +22480,7 @@
},
"required": [
"albumId",
"isFavorite",
"role",
"userId"
],
@@ -24400,10 +24390,9 @@
},
"defaultStorageQuota": {
"description": "Default storage quota",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "integer"
"type": "number"
},
"enabled": {
"description": "Enabled",
@@ -24578,7 +24567,7 @@
"description": "SMTP server port",
"maximum": 65535,
"minimum": 0,
"type": "integer"
"type": "number"
},
"secure": {
"description": "Whether to use secure connection (TLS/SSL)",
@@ -25232,13 +25221,14 @@
},
"UpdateAlbumUserDto": {
"properties": {
"isFavorite": {
"description": "Mark album as favorite for the user (only the user themselves can update)",
"type": "boolean"
},
"role": {
"$ref": "#/components/schemas/AlbumUserRole"
}
},
"required": [
"role"
],
"type": "object"
},
"UpdateAssetDto": {
@@ -25996,9 +25986,7 @@
},
"order": {
"description": "Action order",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"pluginActionId": {
"description": "Plugin action ID",
@@ -26097,9 +26085,7 @@
},
"order": {
"description": "Filter order",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
"type": "number"
},
"pluginFilterId": {
"description": "Plugin filter ID",
+10 -2
View File
@@ -474,6 +474,8 @@ export type AlbumResponseDto = {
id: string;
/** Activity feed enabled */
isActivityEnabled: boolean;
/** Whether the authenticated user has favorited this album */
isFavorite: boolean;
/** Last modified asset timestamp */
lastModifiedAssetTimestamp?: string;
order?: AssetOrder;
@@ -556,7 +558,9 @@ export type MapMarkerResponseDto = {
state: string | null;
};
export type UpdateAlbumUserDto = {
role: AlbumUserRole;
/** Mark album as favorite for the user (only the user themselves can update) */
isFavorite?: boolean;
role?: AlbumUserRole;
};
export type AlbumUserAddDto = {
/** Album user role */
@@ -2856,6 +2860,8 @@ export type SyncAlbumUserDeleteV1 = {
export type SyncAlbumUserV1 = {
/** Album ID */
albumId: string;
/** Favorite flag */
isFavorite: boolean;
role: AlbumUserRole;
/** User ID */
userId: string;
@@ -3619,8 +3625,9 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/**
* List all albums
*/
export function getAllAlbums({ assetId, shared }: {
export function getAllAlbums({ assetId, favorite, shared }: {
assetId?: string;
favorite?: boolean;
shared?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
@@ -3628,6 +3635,7 @@ export function getAllAlbums({ assetId, shared }: {
data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({
assetId,
favorite,
shared
}))}`, {
...opts
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.7.5",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"engines": {
"pnpm": ">=10.0.0"
}
+998 -960
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -46,15 +46,15 @@
"@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/swagger": "11.2.6",
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.67.0",
"@opentelemetry/instrumentation-ioredis": "^0.62.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0",
"@opentelemetry/instrumentation-pg": "^0.66.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.215.0",
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
});
it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
});
+7 -1
View File
@@ -35,6 +35,7 @@ export type AuthUser = {
export type AlbumUser = {
user: ShallowDehydrateObject<User>;
role: AlbumUserRole;
isFavorite: boolean;
};
export type AssetFile = {
@@ -395,7 +396,12 @@ export const columns = {
'asset.height',
'asset.isEdited',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncAlbumUser: [
'album_user.albumId as albumId',
'album_user.userId as userId',
'album_user.role',
'album_user.isFavorite',
],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
+13 -2
View File
@@ -69,6 +69,7 @@ const GetAlbumsSchema = z
.optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'),
favorite: stringToBool.optional().describe('Filter to only albums favorited by the authenticated user'),
})
.meta({ id: 'GetAlbumsDto' });
@@ -82,7 +83,11 @@ const AlbumStatisticsResponseSchema = z
const UpdateAlbumUserSchema = z
.object({
role: AlbumUserRoleSchema,
role: AlbumUserRoleSchema.optional(),
isFavorite: z
.boolean()
.optional()
.describe('Mark album as favorite for the user (only the user themselves can update)'),
})
.meta({ id: 'UpdateAlbumUserDto' });
@@ -118,6 +123,7 @@ export const AlbumResponseSchema = z
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
),
hasSharedLink: z.boolean().describe('Has shared link'),
isFavorite: z.boolean().describe('Whether the authenticated user has favorited this album'),
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
@@ -161,8 +167,9 @@ export type MapAlbumDto = {
order: AssetOrder;
};
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>, authUserId?: string): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = [];
let isFavorite = false;
if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) {
@@ -171,6 +178,9 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
user,
role: albumUser.role,
});
if (authUserId && user.id === authUserId) {
isFavorite = albumUser.isFavorite ?? false;
}
}
}
@@ -196,6 +206,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
isFavorite,
startDate: asDateString(startDate),
endDate: asDateString(endDate),
assetCount: entity.assets?.length || 0,
+2 -2
View File
@@ -50,8 +50,8 @@ const SanitizedAssetResponseSchema = z
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
width: z.number().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'),
})
.meta({ id: 'SanitizedAssetResponseDto' });
+1 -1
View File
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
});
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z
.object({
filename: z.string().describe('Backup filename'),
filesize: z.int().describe('Backup file size'),
filesize: z.number().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'),
})
.meta({ id: 'DatabaseBackupDto' });
+4 -4
View File
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z
.object({
x: z.int().min(0).describe('Top-Left X coordinate of crop'),
y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
width: z.int().min(1).describe('Width of the crop'),
height: z.int().min(1).describe('Height of the crop'),
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'),
})
.meta({ id: 'CropParameters' });
+4 -4
View File
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
.object({
make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -20,7 +20,7 @@ export const ExifResponseSchema = z
lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.int().nullish().default(null).describe('ISO sensitivity'),
iso: z.number().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'),
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.int().nullish().default(null).describe('Rating'),
rating: z.number().nullish().default(null).describe('Rating'),
})
.describe('EXIF response')
.meta({ id: 'ExifResponseDto' });
+2 -2
View File
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({
active: z.boolean(),
action: MaintenanceActionSchema,
progress: z.int().optional(),
progress: z.number().optional(),
task: z.string().optional(),
error: z.string().optional(),
})
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'),
files: z.int().describe('Number of files in the folder'),
files: z.number().describe('Number of files in the folder'),
})
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
+2 -2
View File
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
})
.meta({ id: 'PersonSearchDto' });
+5 -5
View File
@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z
.int()
.number()
.min(-1)
.max(5)
.nullish()
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
});
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.int().min(1).optional().describe('Page number'),
page: z.number().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'),
page: z.int().min(1).optional().describe('Page number'),
page: z.number().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z
.object({
duration: z.int().min(1).optional().describe('Session duration in seconds'),
duration: z.number().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'),
})
+1
View File
@@ -195,6 +195,7 @@ const SyncAlbumUserV1Schema = z
albumId: z.string().describe('Album ID'),
userId: z.string().describe('User ID'),
role: AlbumUserRoleSchema,
isFavorite: z.boolean().describe('Favorite flag'),
})
.meta({ id: 'SyncAlbumUserV1' });
+5 -5
View File
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({
enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema,
keepLastAmount: z.int().min(1).describe('Keep last amount'),
keepLastAmount: z.number().min(1).describe('Keep last amount'),
})
.meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z
.object({
enabled: configBool.describe('Enabled'),
timeout: z.int(),
interval: z.int(),
timeout: z.number(),
interval: z.number(),
})
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'),
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled'),
issuerUrl: z
.string()
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'),
port: z.int().min(0).max(65_535).describe('SMTP server port'),
port: z.number().min(0).max(65_535).describe('SMTP server port'),
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'),
+2 -2
View File
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(),
order: z.int().describe('Filter order'),
order: z.number().describe('Filter order'),
})
.meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(),
order: z.int().describe('Action order'),
order: z.number().describe('Action order'),
})
.meta({ id: 'WorkflowActionResponseDto' });
+1 -7
View File
@@ -22,7 +22,7 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
CorrelationId = 'X-Correlation-ID',
Cid = 'x-immich-cid',
}
export enum ImmichQuery {
@@ -445,12 +445,6 @@ export enum VideoCodec {
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
export enum VideoSegmentCodec {
Av1 = 'av1',
Hevc = 'hevc',
H264 = 'h264',
}
export enum AudioCodec {
Mp3 = 'mp3',
Aac = 'aac',
@@ -2,7 +2,6 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod';
@@ -17,13 +16,18 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
}
catch(error: Error, host: ArgumentsHost) {
this.handleError(host.switchToHttp().getResponse<Response>(), error);
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
}
handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error);
if (!res.headersSent) {
res.header(ImmichHeader.CorrelationId, this.cls.getId()).status(status).json(body);
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
}
@@ -32,24 +36,26 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof HttpException) {
const status = error.getStatus();
const response = error.getResponse();
const body: Record<string, unknown> =
typeof response === 'string' ? { message: response } : { ...(response as object) };
let body = error.getResponse();
// unclear what circumstances would return a string
if (typeof body === 'string') {
body = { message: body };
}
// handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) {
body['message'] = zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
);
body = {
message: zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
),
error: 'Bad Request',
};
}
}
// remove fields that duplicate the HTTP response line or will be reformatted in a later step
delete body['error'];
delete body['statusCode'];
delete body['errors'];
return { status, body };
}
+72 -1
View File
@@ -19,6 +19,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -98,6 +99,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -195,6 +197,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -258,6 +261,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -334,6 +338,70 @@ where
order by
"album"."createdAt" desc
-- AlbumRepository.getFavorites
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"shared_link".*
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."isFavorite" = true
where
"album"."deletedAt" is null
order by
"album"."createdAt" desc
-- AlbumRepository.getNotShared
select
"album".*,
@@ -357,6 +425,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
@@ -461,7 +530,8 @@ with
returning
"album_user"."albumId",
"album_user"."userId",
"album_user"."role"
"album_user"."role",
"album_user"."isFavorite"
),
"album_asset" as (
insert into
@@ -485,6 +555,7 @@ select
(
select
"album_user"."role",
"album_user"."isFavorite",
(
select
to_json(obj)
+2
View File
@@ -331,6 +331,7 @@ select
"album_user"."albumId" as "albumId",
"album_user"."userId" as "userId",
"album_user"."role",
"album_user"."isFavorite",
"album_user"."updateId"
from
"album_user" as "album_user"
@@ -368,6 +369,7 @@ select
"album_user"."albumId" as "albumId",
"album_user"."userId" as "userId",
"album_user"."role",
"album_user"."isFavorite",
"album_user"."updateId"
from
"album_user" as "album_user"
@@ -1,46 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- VideoStreamRepository.getSession
select
*
from
"video_stream_session"
where
"id" = $1
-- VideoStreamRepository.getVariant
select
*
from
"video_stream_variant"
where
"id" = $1
-- VideoStreamRepository.getSegment
select
*
from
"video_stream_segment"
where
"variantId" = $1
and "index" = $2
-- VideoStreamRepository.getExpiredSessions
select
"id"
from
"video_stream_session"
where
"expiresAt" <= $1
-- VideoStreamRepository.extendSession
update "video_stream_session"
set
"expiresAt" = $1
where
"id" = $2
-- VideoStreamRepository.deleteSession
delete from "video_stream_session"
where
"id" = $1
+23 -1
View File
@@ -39,6 +39,7 @@ const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'albu
.innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role')
.select('album_user.isFavorite')
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
@@ -244,6 +245,27 @@ export class AlbumRepository {
.execute();
}
/**
* Get albums the user has favorited (owned or shared).
*/
@GenerateSql({ params: [DummyValue.UUID] })
getFavorites(userId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', userId)
.on('album_user.isFavorite', '=', sql.lit(true)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(userId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
/**
* Get albums of owner that are _not_ shared
*/
@@ -380,7 +402,7 @@ export class AlbumRepository {
sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
]),
)
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role', 'album_user.isFavorite']),
)
.with('album_asset', (db) =>
db
+4 -2
View File
@@ -301,9 +301,11 @@ const getEnv = (): EnvData => {
mount: true,
generateId: true,
setup: (cls, req: Request, res: Response) => {
const cid = req.header(ImmichHeader.CorrelationId) || cls.get(CLS_ID);
const headerValues = req.headers[ImmichHeader.Cid];
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
cls.set(CLS_ID, cid);
res.header(ImmichHeader.CorrelationId, cid);
res.header(ImmichHeader.Cid, cid);
},
},
},
-2
View File
@@ -46,7 +46,6 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -101,7 +100,6 @@ export const repositories = [
UserRepository,
ViewRepository,
VersionHistoryRepository,
VideoStreamRepository,
WebsocketRepository,
WorkflowRepository,
];
@@ -1,62 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
@Injectable()
export class VideoStreamRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
createSession(session: Insertable<VideoStreamSessionTable>) {
return this.db.insertInto('video_stream_session').values(session).returning(['id']).executeTakeFirstOrThrow();
}
createVariant(variant: Insertable<VideoStreamVariantTable>) {
return this.db.insertInto('video_stream_variant').values(variant).returning(['id']).executeTakeFirstOrThrow();
}
async createSegment(segment: Insertable<VideoStreamSegmentTable>) {
await this.db.insertInto('video_stream_segment').values(segment).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getSession(id: string) {
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getVariant(id: string) {
return this.db.selectFrom('video_stream_variant').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
getSegment(variantId: string, index: number) {
return this.db
.selectFrom('video_stream_segment')
.selectAll()
.where('variantId', '=', variantId)
.where('index', '=', index)
.executeTakeFirst();
}
@GenerateSql()
getExpiredSessions() {
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
async extendSession(id: string, expiresAt: Date) {
await this.db.updateTable('video_stream_session').set({ expiresAt }).where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteSession(id: string) {
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
}
}
+1 -13
View File
@@ -1,12 +1,5 @@
import { registerEnum } from '@immich/sql-tools';
import {
AlbumUserRole,
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
@@ -32,8 +25,3 @@ export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm),
});
export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: Object.values(VideoSegmentCodec),
});
-12
View File
@@ -76,11 +76,6 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@@ -138,9 +133,6 @@ export class ImmichDatabase {
UserMetadataAuditTable,
UserTable,
VersionHistoryTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
VideoStreamSegmentTable,
PluginTable,
PluginFilterTable,
PluginActionTable,
@@ -255,10 +247,6 @@ export interface DB {
version_history: VersionHistoryTable;
video_stream_session: VideoStreamSessionTable;
video_stream_variant: VideoStreamVariantTable;
video_stream_segment: VideoStreamSegmentTable;
plugin: PluginTable;
plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable;
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" ADD "isFavorite" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" DROP COLUMN "isFavorite";`.execute(db);
}
@@ -1,40 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "video_stream_variant_codec_enum" AS ENUM ('av1','hevc','h264');`.execute(db);
await sql`CREATE TABLE "video_stream_session" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"assetId" uuid NOT NULL,
"expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "video_stream_session_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_session_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "video_stream_session_assetId_idx" ON "video_stream_session" ("assetId");`.execute(db);
await sql`CREATE INDEX "video_stream_session_expiresAt_idx" ON "video_stream_session" ("expiresAt");`.execute(db);
await sql`CREATE TABLE "video_stream_variant" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"sessionId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"bitrate" integer NOT NULL,
"codec" video_stream_variant_codec_enum NOT NULL,
"resolution" smallint NOT NULL,
CONSTRAINT "video_stream_variant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "video_stream_session" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_variant_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "video_stream_variant_sessionId_bitrate_resolution_codec_idx" ON "video_stream_variant" ("sessionId", "bitrate", "resolution", "codec");`.execute(db);
await sql`CREATE TABLE "video_stream_segment" (
"variantId" uuid NOT NULL,
"index" integer NOT NULL,
"durationUs" integer NOT NULL,
CONSTRAINT "video_stream_segment_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "video_stream_variant" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_segment_pkey" PRIMARY KEY ("variantId", "index")
);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "video_stream_segment";`.execute(db);
await sql`DROP TABLE "video_stream_variant";`.execute(db);
await sql`DROP TABLE "video_stream_session";`.execute(db);
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
}
@@ -58,6 +58,9 @@ export class AlbumUserTable {
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@CreateIdColumn({ index: true })
createId!: Generated<string>;
@@ -1,63 +0,0 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryColumn,
PrimaryGeneratedColumn,
Table,
Timestamp,
} from '@immich/sql-tools';
import { VideoSegmentCodec } from 'src/enum';
import { video_stream_variant_codec_enum } from 'src/schema/enums';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('video_stream_session')
export class VideoStreamSessionTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE' })
assetId!: string;
@Column({ type: 'timestamp with time zone', index: true })
expiresAt!: Timestamp;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}
@Index({ columns: ['sessionId', 'bitrate', 'resolution', 'codec'], unique: true })
@Table('video_stream_variant')
export class VideoStreamVariantTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => VideoStreamSessionTable, { onDelete: 'CASCADE', index: false })
sessionId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'integer' })
bitrate!: number;
@Column({ enum: video_stream_variant_codec_enum })
codec!: VideoSegmentCodec;
@Column({ type: 'smallint' })
resolution!: number;
}
@Table('video_stream_segment')
export class VideoStreamSegmentTable {
@ForeignKeyColumn(() => VideoStreamVariantTable, { onDelete: 'CASCADE', primary: true, index: false })
variantId!: string;
@PrimaryColumn({ type: 'integer' })
index!: number;
@Column({ type: 'integer' })
durationUs!: number;
}
@@ -196,7 +196,6 @@ describe(AlbumService.name, () => {
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: albumUser.userId,
+29 -9
View File
@@ -38,12 +38,17 @@ export class AlbumService extends BaseService {
};
}
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, shared, favorite }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[];
if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (favorite === true) {
albums = await this.albumRepository.getFavorites(ownerId);
} else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId);
} else if (shared === false) {
@@ -61,7 +66,7 @@ export class AlbumService extends BaseService {
}
return albums.map((album) => ({
...mapAlbum(album),
...mapAlbum(album, ownerId),
sharedLinks: undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
@@ -82,7 +87,7 @@ export class AlbumService extends BaseService {
const isShared = hasSharedUsers || hasSharedLink;
return {
...mapAlbum(album),
...mapAlbum(album, auth.user.id),
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0,
@@ -114,6 +119,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Cannot share album with owner');
}
}
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
const allowedAssetIdsSet = await this.checkAccess({
auth,
@@ -132,7 +138,7 @@ export class AlbumService extends BaseService {
order: getPreferences(userMetadata).albums.defaultAssetOrder,
},
assetIds,
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
albumUsers,
auth.user.id,
);
@@ -140,7 +146,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
return mapAlbum(album);
return mapAlbum(album, auth.user.id);
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
@@ -167,7 +173,7 @@ export class AlbumService extends BaseService {
auth.user.id,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
return mapAlbum({ ...updatedAlbum, assets: album.assets }, auth.user.id);
}
async delete(auth: AuthDto, id: string): Promise<void> {
@@ -309,7 +315,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
return this.findOrFail(id, auth.user.id, { withAssets: true }).then((album) => mapAlbum(album, auth.user.id));
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -340,8 +346,22 @@ export class AlbumService extends BaseService {
}
async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
if (dto.role === undefined && dto.isFavorite === undefined) {
throw new BadRequestException('No updates provided');
}
if (dto.role !== undefined) {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
}
if (dto.isFavorite !== undefined) {
if (userId !== auth.user.id) {
throw new BadRequestException('Cannot favorite an album on behalf of another user');
}
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
}
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role, isFavorite: dto.isFavorite });
}
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
-3
View File
@@ -53,7 +53,6 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -110,7 +109,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
TrashRepository,
UserRepository,
VersionHistoryRepository,
VideoStreamRepository,
ViewRepository,
WebsocketRepository,
WorkflowRepository,
@@ -169,7 +167,6 @@ export class BaseService {
protected trashRepository: TrashRepository,
protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository,
protected videoStreamRepository: VideoStreamRepository,
protected viewRepository: ViewRepository,
protected websocketRepository: WebsocketRepository,
protected workflowRepository: WorkflowRepository,
@@ -24,6 +24,7 @@ export class AlbumUserFactory {
albumId: newUuid(),
userId: newUuid(),
role: AlbumUserRole.Editor,
isFavorite: false,
createId: newUuidV7(),
createdAt: newDate(),
updateId: newUuidV7(),
+32
View File
@@ -2,36 +2,68 @@ import { expect } from 'vitest';
export const errorDto = {
unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required',
correlationId: expect.any(String),
},
forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password',
correlationId: expect.any(String),
},
invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token',
correlationId: expect.any(String),
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
correlationId: expect.any(String),
},
invalidSharePassword: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid password',
correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
}),
noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
},
incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password',
correlationId: expect.any(String),
},
alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin',
correlationId: expect.any(String),
},
};
+2
View File
@@ -246,6 +246,8 @@ export const factory = {
date: newDate,
responses: {
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
}),
},
-4
View File
@@ -64,7 +64,6 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -261,7 +260,6 @@ export type ServiceOverrides = {
trash: TrashRepository;
user: UserRepository;
versionHistory: VersionHistoryRepository;
videoStream: VideoStreamRepository;
view: ViewRepository;
websocket: WebsocketRepository;
workflow: WorkflowRepository;
@@ -346,7 +344,6 @@ export const getMocks = () => {
trash: automock(TrashRepository),
user: automock(UserRepository, { strict: false }),
versionHistory: automock(VersionHistoryRepository),
videoStream: automock(VideoStreamRepository),
view: automock(ViewRepository),
// eslint-disable-next-line no-sparse-arrays
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
@@ -411,7 +408,6 @@ export const newTestService = <T extends BaseService>(
overrides.trash || (mocks.trash as As<TrashRepository>),
overrides.user || (mocks.user as As<UserRepository>),
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
overrides.videoStream || (mocks.videoStream as As<VideoStreamRepository>),
overrides.view || (mocks.view as As<ViewRepository>),
overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
+1 -1
View File
@@ -9,7 +9,7 @@
"build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'state_referenced_locally:ignore'",
"check:typescript": "tsc --noEmit",
"check:watch": "pnpm run check:svelte --watch",
"check:code": "pnpm run format && pnpm run lint && pnpm run check:svelte && pnpm run check:typescript",
@@ -35,7 +35,7 @@
const setSelectedDate = (value: DateTime | undefined) => {
selectedPresetValue = null; // Clear preset when manually setting date
expiresAt = value ? value.toUTC().toISO() : null;
expiresAt = value ? value.toISO() : null;
};
const selectPreset = (value: number) => {
@@ -44,8 +44,8 @@
expiresAt = null;
return;
}
const newDate = DateTime.now().plus({ milliseconds: value });
expiresAt = newDate.toUTC().toISO();
const newDate = DateTime.now().plus(value);
expiresAt = newDate.toISO();
};
const isSelected = (value: number) => {
@@ -1,11 +1,12 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { toggleAlbumFavorite } from '$lib/services/album.service';
import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time';
import { type AlbumResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { Icon, IconButton } from '@immich/ui';
import { mdiDotsVertical, mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -26,37 +27,70 @@
onShowContextMenu = undefined,
}: Props = $props();
let togglingFavorite = $state(false);
const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onShowContextMenu?.(getContextMenuPositionFromEvent(e));
};
const onToggleFavorite = async (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (togglingFavorite) {
return;
}
togglingFavorite = true;
try {
await toggleAlbumFavorite(album);
} finally {
togglingFavorite = false;
}
};
</script>
<div
class="group relative rounded-2xl border border-transparent p-5 hover:bg-gray-100 hover:border-gray-200 dark:hover:border-gray-800 dark:hover:bg-gray-900"
data-testid="album-card"
>
{#if onShowContextMenu}
<div
id="icon-{album.id}"
class="absolute end-6 top-6 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
data-testid="context-button-parent"
<div class="relative">
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
{#if onShowContextMenu}
<div
id="icon-{album.id}"
class="absolute inset-e-3 top-3 opacity-0 group-hover:opacity-100 focus-within:opacity-100"
data-testid="context-button-parent"
>
<IconButton
color="secondary"
aria-label={$t('show_album_options')}
icon={mdiDotsVertical}
shape="round"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}
/>
</div>
{/if}
<button
type="button"
class="absolute inset-e-3 bottom-3 inline-flex items-center justify-center rounded-full p-2 bg-white/50 dark:bg-gray-900/90 backdrop-blur-md shadow-lg ring-1 ring-black/5 transition-all duration-150 hover:scale-110 active:scale-95 disabled:opacity-50 {album.isFavorite
? 'text-red-500 dark:text-red-400'
: 'text-gray-500 dark:text-gray-300 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-red-500 dark:hover:text-red-400'}"
aria-label={$t(album.isFavorite ? 'unfavorite' : 'favorite')}
aria-pressed={album.isFavorite}
disabled={togglingFavorite}
onclick={onToggleFavorite}
>
<IconButton
color="secondary"
aria-label={$t('show_album_options')}
icon={mdiDotsVertical}
shape="round"
variant="filled"
size="medium"
class="icon-white-drop-shadow"
onclick={showAlbumContextMenu}
<Icon
icon={album.isFavorite ? mdiHeart : mdiHeartOutline}
size="1.125em"
class={album.isFavorite ? 'drop-shadow-[0_1px_2px_rgba(239,68,68,0.5)]' : ''}
/>
</div>
{/if}
<AlbumCover {album} {preload} class="transition-all duration-300 hover:shadow-lg" />
</button>
</div>
<div class="mt-4">
<p
@@ -30,7 +30,7 @@
let { sharedLink }: Props = $props();
const album = $derived(sharedLink.album as AlbumResponseDto);
const album = sharedLink.album as AlbumResponseDto;
let { slideshowState, slideshowNavigation } = slideshowStore;
@@ -131,6 +131,15 @@
case AlbumFilter.Shared: {
return sharedAlbums;
}
case AlbumFilter.Favorites: {
const nonOwnedFavorites = sharedAlbums.filter(
(album) =>
album.isFavorite &&
album.albumUsers.find(({ user: { id } }) => id === authManager.user.id)?.role !== AlbumUserRole.Owner,
);
const ownedFavorites = ownedAlbums.filter((album) => album.isFavorite);
return nonOwnedFavorites.length > 0 ? ownedFavorites.concat(nonOwnedFavorites) : ownedFavorites;
}
default: {
const nonOwnedAlbums = sharedAlbums.filter(
(album) =>
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
import { waitFor, type RenderResult } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { init, register, waitLocale } from 'svelte-i18n';
import { sdkMock } from '$lib/__mocks__/sdk.mock';
@@ -40,7 +40,7 @@ describe('AlbumCard component', () => {
shared: true,
},
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
sut = render(AlbumCard, { album, showItemCount: true });
sut = renderWithTooltips(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
@@ -64,7 +64,7 @@ describe('AlbumCard component', () => {
shared: false,
albumName: 'some album name',
});
sut = render(AlbumCard, { album, showItemCount: true });
sut = renderWithTooltips(AlbumCard, { album, showItemCount: true });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
@@ -79,7 +79,7 @@ describe('AlbumCard component', () => {
it('hides context menu when "onShowContextMenu" is undefined', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
sut = render(AlbumCard, { album });
sut = renderWithTooltips(AlbumCard, { album });
const contextButtonParent = sut.queryByTestId('context-button-parent');
expect(contextButtonParent).not.toBeInTheDocument();
@@ -53,8 +53,7 @@
let activityHeight: number = $state(0);
let chatHeight: number = $state(0);
let divHeight = $derived(innerHeight - activityHeight);
// svelte-ignore state_referenced_locally
let previousAssetId = $state(assetId);
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
const isAlbumOwner = $derived(albumUsers[0].user.id === authManager.user.id);
@@ -67,7 +67,7 @@
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void;
onClose?: (asset: AssetResponseDto) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
}
@@ -179,7 +179,7 @@
});
const closeViewer = () => {
onClose?.(asset.id);
onClose?.(asset);
};
const closeEditor = async () => {
@@ -474,7 +474,7 @@
onAction={handleAction}
{onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
onClose={onClose ? () => onClose(asset) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
{setPlayOriginalVideo}
@@ -10,8 +10,9 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@@ -24,15 +25,26 @@
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import {
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
mdiEyeOff,
mdiImageOutline,
mdiInformationOutline,
mdiPencil,
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/ImageThumbnail.svelte';
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props {
asset: AssetResponseDto;
@@ -42,6 +54,9 @@
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -149,7 +164,108 @@
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
{@const ageInMonths = Math.floor(
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
)}
{#if age >= 0}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
{#if ageInMonths <= 11}
{$t('age_months', { values: { months: ageInMonths } })}
{:else if ageInMonths > 12 && ageInMonths <= 23}
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
{:else}
{$t('age_years', { values: { years: age } })}
{/if}
</p>
{/if}
{/if}
</a>
{/if}
{/each}
</div>
</section>
{/if}
<div class="px-4 py-4">
{#if asset.exifInfo}
@@ -1,133 +0,0 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type AssetResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui';
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
asset: AssetResponseDto;
isOwner: boolean;
previousRoute: string;
};
const { asset, isOwner, previousRoute }: Props = $props();
const unassignedFaces = $derived(asset.unassignedFaces || []);
const people = $derived(asset.people || []);
const visiblePeople = $derived(
people
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
.map((person) => {
if (!person.birthDate) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
}
const personBirthDate = DateTime.fromISO(person.birthDate);
const ageInYears = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years);
const ageInMonths = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months);
let formattedAge;
if (ageInYears < 0) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
} else if (ageInMonths < 12) {
formattedAge = $t('age_months', { values: { months: ageInMonths } });
} else if (ageInMonths > 12 && ageInMonths < 24) {
formattedAge = $t('age_year_months', { values: { months: ageInMonths - 12 } });
} else {
formattedAge = $t('age_years', { values: { years: ageInYears } });
}
const formattedBirthDate = personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
);
return { formattedBirthDate, formattedAge, ...person };
}),
);
</script>
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
{#each visiblePeople as person (person.id)}
{@const isHighlighted = person.faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a
class="group outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(person.faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="100%"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 outline-offset-2 outline-immich-primary dark:outline-immich-dark-primary"
/>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate && person.formattedAge}
<p class="font-light {visiblePeople.length > 6 ? 'text-xs' : ''}" title={person.formattedBirthDate!}>
{person.formattedAge}
</p>
{/if}
</a>
{/each}
</div>
</section>
{/if}
@@ -1,8 +1,9 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import {
@@ -54,9 +55,14 @@
let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
$effect(() => {
const faces: Faces[] = assetViewerManager.highlightedFaces;
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
// Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
if (animationInProgress) {
animationInProgress.cancel();
@@ -99,7 +105,7 @@
textureX: x,
textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500,
speed: 500, // duration in ms
});
}
});
@@ -241,8 +247,7 @@
if (viewer) {
viewer.destroy();
}
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
boundingBoxesUnsubscribe();
assetViewerManager.zoom = 1;
});
</script>
@@ -6,9 +6,10 @@
import Thumbhash from '$lib/components/Thumbhash.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
@@ -49,13 +50,12 @@
untrack(() => {
assetViewerManager.resetZoomState();
visibleImageReady = false;
assetViewerManager.clearHighlightedFaces();
$boundingBoxesArray = [];
});
});
onDestroy(() => {
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
$boundingBoxesArray = [];
});
let containerWidth = $state(0);
@@ -74,13 +74,15 @@
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
});
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]);
let visibleBoundingBoxes = $state<Faces[]>([]);
$effect(() => {
if (isHighlighting) {
visibleBoxes = highlightedBoxes;
visibleBoundingBoxes = $boundingBoxesArray;
}
});
@@ -158,9 +160,6 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
for (const person of asset.people ?? []) {
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
continue;
}
for (const face of person.faces ?? []) {
map.set(face, person.name);
}
@@ -170,31 +169,35 @@
const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived.by(() => {
if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return [];
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return;
}
const knownBoxes = getBoundingBox(faces, overlaySize);
const result = knownBoxes.map((box, index) => ({
...box,
face: faces[index],
name: faceToNameMap.get(faces[index]),
}));
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
if (assetViewerManager.highlightedFaces.length === 0) {
return result;
const contentOffsetX = (container.width - scaled.width) / 2;
const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
}
};
const knownIds = new Set(faces.map((f) => f.id));
const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id));
const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize);
for (let i = 0; i < unassignedBoxes.length; i++) {
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
}
return result;
});
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
</script>
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
@@ -215,6 +218,8 @@
bind:clientHeight={containerHeight}
role="presentation"
ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
>
@@ -256,27 +261,22 @@
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg>
</div>
{#each boundingBoxes as boundingbox (boundingbox.id)}
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute pointer-events-auto rounded-lg {isActive && 'border-solid border-white border-3'}"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
onpointerenter={() => assetViewerManager.setHighlightedFaces([boundingbox.face])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
{#if isActive && boundingbox.name}
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
<div
aria-hidden="true"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
style="top: {boundingbox.height + 4}px; right: 0;"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
>
{boundingbox.name}
{faceToNameMap.get(visibleBoundingBoxes[index])}
</div>
{/if}
</div>
{/each}
{/each}
</div>
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
@@ -16,7 +16,7 @@
}
let { asset, onAction, preAction }: Props = $props();
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const isLocked = asset.visibility === AssetVisibility.Locked;
const toggleLockedVisibility = async () => {
const isConfirmed = await modalManager.showDialog({
@@ -29,7 +29,7 @@
class: className,
}: Props = $props();
let remainingSeconds = $derived(durationInSeconds);
let remainingSeconds = $state(durationInSeconds);
let loading = $state(true);
let error = $state(false);
let player: HTMLVideoElement | undefined = $state();
@@ -4,6 +4,7 @@
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -238,15 +239,15 @@
{:else}
{#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)}
{@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)}
<div class="relative h-29 w-24">
<div
role="button"
tabindex={index}
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onpointerenter={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
{#if selectedPersonToCreate[face.id]}

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