Merge branch 'main' into upload-to-album

This commit is contained in:
Alex
2026-04-30 05:24:28 -05:00
committed by GitHub
74 changed files with 1741 additions and 1473 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tools] [tools]
terragrunt = "1.0.1" terragrunt = "1.0.2"
opentofu = "1.11.6" opentofu = "1.11.6"
[tasks."tg:fmt"] [tasks."tg:fmt"]
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78 image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - 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 ### 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. - 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/) that permits up to 3 users and up to 100 devices. - Tailscale is a paid service. However, there is a generous [free tier](https://tailscale.com/pricing/) suitable for personal use.
- Tailscale needs to be installed and running on both server-side and client-side. - Tailscale needs to be installed and running on both server-side and client-side.
## Option 3: Reverse Proxy ## Option 3: Reverse Proxy
-39
View File
@@ -2,82 +2,43 @@ import { expect } from 'vitest';
export const errorDto = { export const errorDto = {
unauthorized: { unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required', message: 'Authentication required',
correlationId: expect.any(String),
}, },
unauthorizedWithMessage: (message: string) => ({ unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message, message,
correlationId: expect.any(String),
}), }),
forbidden: { forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String), message: expect.any(String),
correlationId: expect.any(String),
}, },
missingPermission: (permission: string) => ({ missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`, message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}), }),
wrongPassword: { wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password', message: 'Wrong password',
correlationId: expect.any(String),
}, },
invalidToken: { invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token', message: 'Invalid user token',
correlationId: expect.any(String),
}, },
invalidShareKey: { invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key', message: 'Invalid share key',
correlationId: expect.any(String),
}, },
passwordRequired: { passwordRequired: {
error: 'Unauthorized',
statusCode: 401,
message: 'Password required', message: 'Password required',
correlationId: expect.any(String),
}, },
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(), message: message ?? expect.anything(),
correlationId: expect.any(String),
}), }),
noPermission: { noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'), message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
}, },
incorrectLogin: { incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password', message: 'Incorrect email or password',
correlationId: expect.any(String),
}, },
alreadyHasAdmin: { alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin', message: 'The server already has an admin',
correlationId: expect.any(String),
}, },
invalidEmail: { invalidEmail: {
error: 'Bad Request',
statusCode: 400,
message: ['email must be an email'], message: ['email must be an email'],
correlationId: expect.any(String),
}, },
}; };
+1 -4
View File
@@ -332,9 +332,7 @@ describe(`/oauth`, () => {
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(500); expect(status).toBe(500);
expect(body).toMatchObject({ expect(body).toMatchObject({
error: 'Internal Server Error',
message: 'Failed to finish oauth', message: 'Failed to finish oauth',
statusCode: 500,
}); });
}); });
@@ -495,11 +493,10 @@ describe(`/oauth`, () => {
}); });
it('should reject OAuth discovery over HTTP', async () => { it('should reject OAuth discovery over HTTP', async () => {
const { status, body } = await request(app) const { status } = await request(app)
.post('/oauth/authorize') .post('/oauth/authorize')
.send({ redirectUri: 'http://127.0.0.1:2285/auth/login' }); .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' });
expect(status).toBe(500); expect(status).toBe(500);
expect(body).toMatchObject({ statusCode: 500 });
}); });
}); });
}); });
+3 -3
View File
@@ -15,9 +15,9 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
flutter = "3.41.6" flutter = "3.41.7"
pnpm = "10.33.0" pnpm = "10.33.1"
terragrunt = "1.0.1" terragrunt = "1.0.2"
opentofu = "1.11.6" opentofu = "1.11.6"
java = "21.0.2" java = "21.0.2"
+1 -1
View File
@@ -1,5 +1,5 @@
app_identifier "app.alextran.immich" # The bundle identifier of your app app_identifier "app.alextran.immich" # The bundle identifier of your app
apple_id "alex.tran1502@gmail.com" # Your Apple email address apple_id "altran@futo.org" # Your Apple email address
# For more information about the Appfile, see: # For more information about the Appfile, see:
+41 -41
View File
@@ -17,10 +17,11 @@ default_platform(:ios)
platform :ios do platform :ios do
# Constants # Constants
TEAM_ID = "2F67MQ8R79" TEAM_ID = "2W7AC6T8T5"
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})" CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
BASE_BUNDLE_ID = "app.alextran.immich" BASE_BUNDLE_ID = "app.alextran.immich"
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
# Helper method to get App Store Connect API key # Helper method to get App Store Connect API key
def get_api_key def get_api_key
app_store_connect_api_key( app_store_connect_api_key(
@@ -44,47 +45,45 @@ def get_version_from_pubspec
end end
# Helper method to configure code signing for all targets # Helper method to configure code signing for all targets
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:) def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
# Runner (main app) # Runner (main app)
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}", bundle_identifier: base_bundle_id,
profile_name: profile_name_main, profile_name: profile_name_main,
targets: ["Runner"] targets: ["Runner"]
) )
# ShareExtension # ShareExtension
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension", bundle_identifier: "#{base_bundle_id}.ShareExtension",
profile_name: profile_name_share, profile_name: profile_name_share,
targets: ["ShareExtension"] targets: ["ShareExtension"]
) )
# WidgetExtension # WidgetExtension
update_code_signing_settings( update_code_signing_settings(
use_automatic_signing: false, use_automatic_signing: false,
path: "./Runner.xcodeproj", path: "./Runner.xcodeproj",
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID, team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
code_sign_identity: CODE_SIGN_IDENTITY, code_sign_identity: CODE_SIGN_IDENTITY,
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget", bundle_identifier: "#{base_bundle_id}.Widget",
profile_name: profile_name_widget, profile_name: profile_name_widget,
targets: ["WidgetExtension"] targets: ["WidgetExtension"]
) )
end end
# Helper method to build and upload to TestFlight # Helper method to build and upload to TestFlight
def build_and_upload( def build_and_upload(
api_key:, api_key:,
bundle_id_suffix: "", base_bundle_id:,
configuration: "Release", configuration: "Release",
distribute_external: true, distribute_external: true,
version_number: nil, version_number: nil,
@@ -92,9 +91,8 @@ end
profile_name_share:, profile_name_share:,
profile_name_widget: profile_name_widget:
) )
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}" app_identifier = base_bundle_id
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
# Set version number if provided # Set version number if provided
if version_number if version_number
increment_version_number(version_number: version_number) increment_version_number(version_number: version_number)
@@ -138,31 +136,31 @@ end
desc "iOS Development Build to TestFlight (requires separate bundle ID)" desc "iOS Development Build to TestFlight (requires separate bundle ID)"
lane :gha_testflight_dev do lane :gha_testflight_dev do
api_key = get_api_key api_key = get_api_key
# Download and install provisioning profiles from App Store Connect # Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain # Certificate is imported by GHA workflow into build.keychain
# Capture profile names after each sigh call # Capture profile names after each sigh call
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true) sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME] main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true) sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME] share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true) sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME] widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs using the downloaded profile names # Configure code signing for dev bundle IDs using the downloaded profile names
configure_code_signing( configure_code_signing(
bundle_id_suffix: "development", base_bundle_id: DEV_BUNDLE_ID,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
) )
# Build and upload # Build and upload
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
bundle_id_suffix: "development", base_bundle_id: DEV_BUNDLE_ID,
configuration: "Profile", configuration: "Profile",
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
@@ -189,6 +187,7 @@ end
# Configure code signing for production bundle IDs # Configure code signing for production bundle IDs
configure_code_signing( configure_code_signing(
base_bundle_id: BASE_BUNDLE_ID,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
@@ -197,6 +196,7 @@ end
# Build and upload with version number # Build and upload with version number
build_and_upload( build_and_upload(
api_key: api_key, api_key: api_key,
base_bundle_id: BASE_BUNDLE_ID,
version_number: get_version_from_pubspec, version_number: get_version_from_pubspec,
distribute_external: false, distribute_external: false,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
@@ -243,30 +243,30 @@ end
desc "iOS Build Only (no TestFlight upload)" desc "iOS Build Only (no TestFlight upload)"
lane :gha_build_only do lane :gha_build_only do
# Use the same build process as production, just skip the upload # Use the same build process as the dev TestFlight lane, just skip the upload
# This ensures PR builds validate the same way as production builds # This ensures PR builds validate the same way as dev TestFlight builds
api_key = get_api_key api_key = get_api_key
# Download and install provisioning profiles from App Store Connect # Download and install provisioning profiles from App Store Connect
# Certificate is imported by GHA workflow into build.keychain # Certificate is imported by GHA workflow into build.keychain
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true) sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
main_profile_name = lane_context[SharedValues::SIGH_NAME] main_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true) sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
share_profile_name = lane_context[SharedValues::SIGH_NAME] share_profile_name = lane_context[SharedValues::SIGH_NAME]
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true) sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
widget_profile_name = lane_context[SharedValues::SIGH_NAME] widget_profile_name = lane_context[SharedValues::SIGH_NAME]
# Configure code signing for dev bundle IDs # Configure code signing for dev bundle IDs
configure_code_signing( configure_code_signing(
bundle_id_suffix: "development", base_bundle_id: DEV_BUNDLE_ID,
profile_name_main: main_profile_name, profile_name_main: main_profile_name,
profile_name_share: share_profile_name, profile_name_share: share_profile_name,
profile_name_widget: widget_profile_name profile_name_widget: widget_profile_name
) )
# Build the app (same as gha_testflight_dev but without upload) # Build the app (same as gha_testflight_dev but without upload)
build_app( build_app(
scheme: "Runner", scheme: "Runner",
@@ -277,9 +277,9 @@ end
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual", xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
export_options: { export_options: {
provisioningProfiles: { provisioningProfiles: {
"#{BASE_BUNDLE_ID}.development" => main_profile_name, DEV_BUNDLE_ID => main_profile_name,
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name, "#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name "#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
}, },
signingStyle: "manual", signingStyle: "manual",
signingCertificate: CODE_SIGN_IDENTITY signingCertificate: CODE_SIGN_IDENTITY
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/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/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
@@ -363,7 +364,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
} }
BaseAsset displayAsset = asset; BaseAsset displayAsset = asset;
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex); displayAsset = stackChildren.elementAt(stackIndex);
} }
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.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/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
class AssetStackRow extends ConsumerWidget { class AssetStackRow extends ConsumerWidget {
final List<RemoteAsset> stack; final List<RemoteAsset> stack;
@@ -15,6 +17,11 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink(); 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)); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
@@ -21,6 +21,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.lockSelection = false, this.lockSelection = false,
this.heroOffset, this.heroOffset,
this.showStackIndicator = false,
super.key, super.key,
}); });
@@ -30,6 +31,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
final bool showStorageIndicator; final bool showStorageIndicator;
final bool lockSelection; final bool lockSelection;
final int? heroOffset; final int? heroOffset;
final bool showStackIndicator;
@override @override
ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState(); ConsumerState<ThumbnailTile> createState() => _ThumbnailTileState();
@@ -139,7 +141,14 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
duration: Durations.short4, duration: Durations.short4,
child: Align( child: Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: _AssetTypeIcons(asset: asset), child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_AssetTypeIcons(asset: asset),
if (widget.showStackIndicator) _StackIndicator(asset: asset),
],
),
), ),
), ),
if (storageIndicator && asset != null) if (storageIndicator && asset != null)
@@ -286,8 +295,8 @@ class _AssetTypeIcons extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null; final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
final isLivePhoto = asset is RemoteAsset && asset.livePhotoVideoId != null; final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -295,11 +304,6 @@ class _AssetTypeIcons extends StatelessWidget {
children: [ children: [
if (asset.isVideo) if (asset.isVideo)
Padding(padding: const EdgeInsets.only(right: 10.0, top: 6.0), child: _VideoIndicator(asset.duration)), 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) if (isLivePhoto)
const Padding( const Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0), padding: EdgeInsets.only(right: 10.0, top: 6.0),
@@ -312,6 +316,24 @@ 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 { class _UploadProgressOverlay extends StatelessWidget {
final double progress; final double progress;
@@ -248,6 +248,7 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref); final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
return RepaintBoundary( return RepaintBoundary(
child: GestureDetector( child: GestureDetector(
@@ -257,6 +258,7 @@ class _AssetTileWidget extends ConsumerWidget {
asset, asset,
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
showStackIndicator: showStackIndicator,
heroOffset: heroOffset, heroOffset: heroOffset,
), ),
), ),
@@ -148,6 +148,7 @@ enum ActionButtonType {
context.selectedCount == 1, context.selectedCount == 1,
ActionButtonType.unstack => ActionButtonType.unstack =>
context.isOwner && // context.isOwner && //
context.timelineOrigin != TimelineOrigin.trash &&
!context.isInLockedView && // !context.isInLockedView && //
context.isStacked, context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
+6 -6
View File
@@ -183,15 +183,15 @@ class PeopleApi {
/// * [String] closestPersonId: /// * [String] closestPersonId:
/// Closest person ID for similarity search /// Closest person ID for similarity search
/// ///
/// * [num] page: /// * [int] page:
/// Page number for pagination /// Page number for pagination
/// ///
/// * [num] size: /// * [int] size:
/// Number of items per page /// Number of items per page
/// ///
/// * [bool] withHidden: /// * [bool] withHidden:
/// Include hidden people /// Include hidden people
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async { Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/people'; final apiPath = r'/people';
@@ -244,15 +244,15 @@ class PeopleApi {
/// * [String] closestPersonId: /// * [String] closestPersonId:
/// Closest person ID for similarity search /// Closest person ID for similarity search
/// ///
/// * [num] page: /// * [int] page:
/// Page number for pagination /// Page number for pagination
/// ///
/// * [num] size: /// * [int] size:
/// Number of items per page /// Number of items per page
/// ///
/// * [bool] withHidden: /// * [bool] withHidden:
/// Include hidden people /// Include hidden people
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async { Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, ); final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+6 -6
View File
@@ -404,10 +404,10 @@ class SearchApi {
/// * [List<String>] personIds: /// * [List<String>] personIds:
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [num] rating: /// * [int] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating [1-5], or null for unrated
/// ///
/// * [num] size: /// * [int] size:
/// Number of results to return /// Number of results to return
/// ///
/// * [String] state: /// * [String] state:
@@ -443,7 +443,7 @@ class SearchApi {
/// ///
/// * [bool] withExif: /// * [bool] withExif:
/// Include EXIF data in response /// 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, 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 { 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 {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/search/large-assets'; final apiPath = r'/search/large-assets';
@@ -619,10 +619,10 @@ class SearchApi {
/// * [List<String>] personIds: /// * [List<String>] personIds:
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [num] rating: /// * [int] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating [1-5], or null for unrated
/// ///
/// * [num] size: /// * [int] size:
/// Number of results to return /// Number of results to return
/// ///
/// * [String] state: /// * [String] state:
@@ -658,7 +658,7 @@ class SearchApi {
/// ///
/// * [bool] withExif: /// * [bool] withExif:
/// Include EXIF data in response /// 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, 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 { 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 {
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, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+5 -2
View File
@@ -37,12 +37,15 @@ class AssetBulkUpdateDto {
/// Relative time offset in seconds /// Relative time offset in seconds
/// ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? dateTimeRelative; int? dateTimeRelative;
/// Asset description /// Asset description
/// ///
@@ -213,7 +216,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto( return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'), dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'), dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'), duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable ids: json[r'ids'] is Iterable
@@ -24,22 +24,26 @@ class AssetEditActionItemDtoParameters {
/// Height of the crop /// Height of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num height; /// Maximum value: 9007199254740991
int height;
/// Width of the crop /// Width of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num width; /// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop /// Top-Left X coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num x; /// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop /// Top-Left Y coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num y; /// Maximum value: 9007199254740991
int y;
/// Rotation angle in degrees /// Rotation angle in degrees
num angle; num angle;
@@ -88,10 +92,10 @@ class AssetEditActionItemDtoParameters {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters( return AssetEditActionItemDtoParameters(
height: num.parse('${json[r'height']}'), height: mapValueOfType<int>(json, r'height')!,
width: num.parse('${json[r'width']}'), width: mapValueOfType<int>(json, r'width')!,
x: num.parse('${json[r'x']}'), x: mapValueOfType<int>(json, r'x')!,
y: num.parse('${json[r'y']}'), y: mapValueOfType<int>(json, r'y')!,
angle: num.parse('${json[r'angle']}'), angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!, axis: MirrorAxis.fromJson(json[r'axis'])!,
); );
+6 -8
View File
@@ -80,7 +80,8 @@ class AssetResponseDto {
/// Asset height /// Asset height
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? height; /// Maximum value: 9007199254740991
int? height;
/// Asset ID /// Asset ID
String id; String id;
@@ -165,7 +166,8 @@ class AssetResponseDto {
/// Asset width /// Asset width
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? width; /// Maximum value: 9007199254740991
int? width;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -346,9 +348,7 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!, hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null height: mapValueOfType<int>(json, r'height'),
? null
: num.parse('${json[r'height']}'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!, isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!, isEdited: mapValueOfType<bool>(json, r'isEdited')!,
@@ -372,9 +372,7 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!, visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null width: mapValueOfType<int>(json, r'width'),
? null
: num.parse('${json[r'width']}'),
); );
} }
return null; return null;
+12 -8
View File
@@ -22,22 +22,26 @@ class CropParameters {
/// Height of the crop /// Height of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num height; /// Maximum value: 9007199254740991
int height;
/// Width of the crop /// Width of the crop
/// ///
/// Minimum value: 1 /// Minimum value: 1
num width; /// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop /// Top-Left X coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num x; /// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop /// Top-Left Y coordinate of crop
/// ///
/// Minimum value: 0 /// Minimum value: 0
num y; /// Maximum value: 9007199254740991
int y;
@override @override
bool operator ==(Object other) => identical(this, other) || other is CropParameters && bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
@@ -75,10 +79,10 @@ class CropParameters {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return CropParameters( return CropParameters(
height: num.parse('${json[r'height']}'), height: mapValueOfType<int>(json, r'height')!,
width: num.parse('${json[r'width']}'), width: mapValueOfType<int>(json, r'width')!,
x: num.parse('${json[r'x']}'), x: mapValueOfType<int>(json, r'x')!,
y: num.parse('${json[r'y']}'), y: mapValueOfType<int>(json, r'y')!,
); );
} }
return null; return null;
+3 -2
View File
@@ -27,7 +27,8 @@ class DatabaseBackupConfig {
/// Keep last amount /// Keep last amount
/// ///
/// Minimum value: 1 /// Minimum value: 1
num keepLastAmount; /// Maximum value: 9007199254740991
int keepLastAmount;
@override @override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig && bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
@@ -64,7 +65,7 @@ class DatabaseBackupConfig {
return DatabaseBackupConfig( return DatabaseBackupConfig(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!, cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
keepLastAmount: num.parse('${json[r'keepLastAmount']}'), keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
); );
} }
return null; return null;
+5 -2
View File
@@ -22,7 +22,10 @@ class DatabaseBackupDto {
String filename; String filename;
/// Backup file size /// Backup file size
num filesize; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int filesize;
/// Backup timezone /// Backup timezone
String timezone; String timezone;
@@ -61,7 +64,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto( return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!, filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'), filesize: mapValueOfType<int>(json, r'filesize')!,
timezone: mapValueOfType<String>(json, r'timezone')!, timezone: mapValueOfType<String>(json, r'timezone')!,
); );
} }
+16 -16
View File
@@ -52,12 +52,14 @@ class ExifResponseDto {
/// Image height in pixels /// Image height in pixels
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? exifImageHeight; /// Maximum value: 9007199254740991
int? exifImageHeight;
/// Image width in pixels /// Image width in pixels
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? exifImageWidth; /// Maximum value: 9007199254740991
int? exifImageWidth;
/// Exposure time /// Exposure time
String? exposureTime; String? exposureTime;
@@ -75,7 +77,10 @@ class ExifResponseDto {
num? focalLength; num? focalLength;
/// ISO sensitivity /// ISO sensitivity
num? iso; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? iso;
/// GPS latitude /// GPS latitude
num? latitude; num? latitude;
@@ -102,7 +107,10 @@ class ExifResponseDto {
String? projectionType; String? projectionType;
/// Rating /// Rating
num? rating; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? rating;
/// State/province name /// State/province name
String? state; String? state;
@@ -292,12 +300,8 @@ class ExifResponseDto {
country: mapValueOfType<String>(json, r'country'), country: mapValueOfType<String>(json, r'country'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
description: mapValueOfType<String>(json, r'description'), description: mapValueOfType<String>(json, r'description'),
exifImageHeight: json[r'exifImageHeight'] == null exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
? null exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
: num.parse('${json[r'exifImageHeight']}'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse('${json[r'exifImageWidth']}'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'), exposureTime: mapValueOfType<String>(json, r'exposureTime'),
fNumber: json[r'fNumber'] == null fNumber: json[r'fNumber'] == null
? null ? null
@@ -306,9 +310,7 @@ class ExifResponseDto {
focalLength: json[r'focalLength'] == null focalLength: json[r'focalLength'] == null
? null ? null
: num.parse('${json[r'focalLength']}'), : num.parse('${json[r'focalLength']}'),
iso: json[r'iso'] == null iso: mapValueOfType<int>(json, r'iso'),
? null
: num.parse('${json[r'iso']}'),
latitude: json[r'latitude'] == null latitude: json[r'latitude'] == null
? null ? null
: num.parse('${json[r'latitude']}'), : num.parse('${json[r'latitude']}'),
@@ -321,9 +323,7 @@ class ExifResponseDto {
modifyDate: mapDateTime(json, r'modifyDate', r''), modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'), orientation: mapValueOfType<String>(json, r'orientation'),
projectionType: mapValueOfType<String>(json, r'projectionType'), projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
timeZone: mapValueOfType<String>(json, r'timeZone'), timeZone: mapValueOfType<String>(json, r'timeZone'),
); );
@@ -21,9 +21,13 @@ class MachineLearningAvailabilityChecksDto {
/// Enabled /// Enabled
bool enabled; bool enabled;
num interval; /// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int interval;
num timeout; /// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int timeout;
@override @override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto && bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
@@ -59,8 +63,8 @@ class MachineLearningAvailabilityChecksDto {
return MachineLearningAvailabilityChecksDto( return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: num.parse('${json[r'interval']}'), interval: mapValueOfType<int>(json, r'interval')!,
timeout: num.parse('${json[r'timeout']}'), timeout: mapValueOfType<int>(json, r'timeout')!,
); );
} }
return null; return null;
@@ -20,7 +20,10 @@ class MaintenanceDetectInstallStorageFolderDto {
}); });
/// Number of files in the folder /// Number of files in the folder
num files; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int files;
StorageFolder folder; StorageFolder folder;
@@ -66,7 +69,7 @@ class MaintenanceDetectInstallStorageFolderDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto( return MaintenanceDetectInstallStorageFolderDto(
files: num.parse('${json[r'files']}'), files: mapValueOfType<int>(json, r'files')!,
folder: StorageFolder.fromJson(json[r'folder'])!, folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!, readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!, writable: mapValueOfType<bool>(json, r'writable')!,
@@ -32,13 +32,15 @@ class MaintenanceStatusResponseDto {
/// ///
String? error; String? error;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? progress; int? progress;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
@@ -102,7 +104,7 @@ class MaintenanceStatusResponseDto {
action: MaintenanceAction.fromJson(json[r'action'])!, action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!, active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'), error: mapValueOfType<String>(json, r'error'),
progress: num.parse('${json[r'progress']}'), progress: mapValueOfType<int>(json, r'progress'),
task: mapValueOfType<String>(json, r'task'), task: mapValueOfType<String>(json, r'task'),
); );
} }
+7 -8
View File
@@ -215,13 +215,14 @@ class MetadataSearchDto {
/// Page number /// Page number
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? page; int? page;
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
@@ -239,7 +240,7 @@ class MetadataSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -251,7 +252,7 @@ class MetadataSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? size; int? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -724,15 +725,13 @@ class MetadataSearchDto {
order: AssetOrder.fromJson(json[r'order']), order: AssetOrder.fromJson(json[r'order']),
originalFileName: mapValueOfType<String>(json, r'originalFileName'), originalFileName: mapValueOfType<String>(json, r'originalFileName'),
originalPath: mapValueOfType<String>(json, r'originalPath'), originalPath: mapValueOfType<String>(json, r'originalPath'),
page: num.parse('${json[r'page']}'), page: mapValueOfType<int>(json, r'page'),
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
previewPath: mapValueOfType<String>(json, r'previewPath'), previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null size: mapValueOfType<int>(json, r'size'),
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+4 -6
View File
@@ -147,7 +147,7 @@ class RandomSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -159,7 +159,7 @@ class RandomSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? size; int? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -549,10 +549,8 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null size: mapValueOfType<int>(json, r'size'),
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+3 -2
View File
@@ -39,13 +39,14 @@ class SessionCreateDto {
/// Session duration in seconds /// Session duration in seconds
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? duration; int? duration;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto && bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
@@ -94,7 +95,7 @@ class SessionCreateDto {
return SessionCreateDto( return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'), deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'), deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: num.parse('${json[r'duration']}'), duration: mapValueOfType<int>(json, r'duration'),
); );
} }
return null; return null;
+7 -8
View File
@@ -154,13 +154,14 @@ class SmartSearchDto {
/// Page number /// Page number
/// ///
/// Minimum value: 1 /// Minimum value: 1
/// Maximum value: 9007199254740991
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// 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 /// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? page; int? page;
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
@@ -187,7 +188,7 @@ class SmartSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Number of results to return /// Number of results to return
/// ///
@@ -199,7 +200,7 @@ class SmartSearchDto {
/// source code must fall back to having a nullable type. /// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note. /// Consider adding a "default:" property in the specification file to hide this note.
/// ///
num? size; int? size;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -583,16 +584,14 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'), make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'), model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'), ocr: mapValueOfType<String>(json, r'ocr'),
page: num.parse('${json[r'page']}'), page: mapValueOfType<int>(json, r'page'),
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
query: mapValueOfType<String>(json, r'query'), query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'), queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null size: mapValueOfType<int>(json, r'size'),
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+2 -4
View File
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
num? rating; int? rating;
/// Filter by state/province name /// Filter by state/province name
String? state; String? state;
@@ -479,9 +479,7 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: json[r'rating'] == null rating: mapValueOfType<int>(json, r'rating'),
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
+3 -4
View File
@@ -57,7 +57,8 @@ class SystemConfigOAuthDto {
/// Default storage quota /// Default storage quota
/// ///
/// Minimum value: 0 /// Minimum value: 0
num? defaultStorageQuota; /// Maximum value: 9007199254740991
int? defaultStorageQuota;
/// Enabled /// Enabled
bool enabled; bool enabled;
@@ -200,9 +201,7 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!, buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!, clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!, clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: json[r'defaultStorageQuota'] == null defaultStorageQuota: mapValueOfType<int>(json, r'defaultStorageQuota'),
? null
: num.parse('${json[r'defaultStorageQuota']}'),
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!, endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!, issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
/// ///
/// Minimum value: 0 /// Minimum value: 0
/// Maximum value: 65535 /// Maximum value: 65535
num port; int port;
/// Whether to use secure connection (TLS/SSL) /// Whether to use secure connection (TLS/SSL)
bool secure; bool secure;
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
host: mapValueOfType<String>(json, r'host')!, host: mapValueOfType<String>(json, r'host')!,
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!, ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!, password: mapValueOfType<String>(json, r'password')!,
port: num.parse('${json[r'port']}'), port: mapValueOfType<int>(json, r'port')!,
secure: mapValueOfType<bool>(json, r'secure')!, secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!, username: mapValueOfType<String>(json, r'username')!,
); );
+5 -2
View File
@@ -26,7 +26,10 @@ class WorkflowActionResponseDto {
String id; String id;
/// Action order /// Action order
num order; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin action ID /// Plugin action ID
String pluginActionId; String pluginActionId;
@@ -79,7 +82,7 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto( return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'), actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'), order: mapValueOfType<int>(json, r'order')!,
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!, pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!, workflowId: mapValueOfType<String>(json, r'workflowId')!,
); );
+5 -2
View File
@@ -26,7 +26,10 @@ class WorkflowFilterResponseDto {
String id; String id;
/// Filter order /// Filter order
num order; ///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin filter ID /// Plugin filter ID
String pluginFilterId; String pluginFilterId;
@@ -79,7 +82,7 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto( return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'), filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'), order: mapValueOfType<int>(json, r'order')!,
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!, pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!, workflowId: mapValueOfType<String>(json, r'workflowId')!,
); );
+69 -35
View File
@@ -7964,8 +7964,9 @@
"description": "Page number for pagination", "description": "Page number for pagination",
"schema": { "schema": {
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991,
"default": 1, "default": 1,
"type": "number" "type": "integer"
} }
}, },
{ {
@@ -7977,7 +7978,7 @@
"minimum": 1, "minimum": 1,
"maximum": 1000, "maximum": 1000,
"default": 500, "default": 500,
"type": "number" "type": "integer"
} }
}, },
{ {
@@ -9372,7 +9373,7 @@
], ],
"x-immich-state": "Stable", "x-immich-state": "Stable",
"schema": { "schema": {
"type": "number", "type": "integer",
"minimum": -1, "minimum": -1,
"maximum": 5, "maximum": 5,
"nullable": true "nullable": true
@@ -9386,7 +9387,7 @@
"schema": { "schema": {
"minimum": 1, "minimum": 1,
"maximum": 1000, "maximum": 1000,
"type": "number" "type": "integer"
} }
}, },
{ {
@@ -15636,7 +15637,9 @@
}, },
"dateTimeRelative": { "dateTimeRelative": {
"description": "Relative time offset in seconds", "description": "Relative time offset in seconds",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"description": { "description": {
"description": "Asset description", "description": "Asset description",
@@ -16650,9 +16653,10 @@
}, },
"height": { "height": {
"description": "Asset height", "description": "Asset height",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"id": { "id": {
"description": "Asset ID", "description": "Asset ID",
@@ -16795,9 +16799,10 @@
}, },
"width": { "width": {
"description": "Asset width", "description": "Asset width",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
} }
}, },
"required": [ "required": [
@@ -17214,23 +17219,27 @@
"properties": { "properties": {
"height": { "height": {
"description": "Height of the crop", "description": "Height of the crop",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"width": { "width": {
"description": "Width of the crop", "description": "Width of the crop",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"x": { "x": {
"description": "Top-Left X coordinate of crop", "description": "Top-Left X coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
}, },
"y": { "y": {
"description": "Top-Left Y coordinate of crop", "description": "Top-Left Y coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
} }
}, },
"required": [ "required": [
@@ -17254,8 +17263,9 @@
}, },
"keepLastAmount": { "keepLastAmount": {
"description": "Keep last amount", "description": "Keep last amount",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
} }
}, },
"required": [ "required": [
@@ -17288,7 +17298,9 @@
}, },
"filesize": { "filesize": {
"description": "Backup file size", "description": "Backup file size",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"timezone": { "timezone": {
"description": "Backup timezone", "description": "Backup timezone",
@@ -17627,16 +17639,18 @@
"exifImageHeight": { "exifImageHeight": {
"default": null, "default": null,
"description": "Image height in pixels", "description": "Image height in pixels",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"exifImageWidth": { "exifImageWidth": {
"default": null, "default": null,
"description": "Image width in pixels", "description": "Image width in pixels",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"exposureTime": { "exposureTime": {
"default": null, "default": null,
@@ -17667,8 +17681,10 @@
"iso": { "iso": {
"default": null, "default": null,
"description": "ISO sensitivity", "description": "ISO sensitivity",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"latitude": { "latitude": {
"default": null, "default": null,
@@ -17722,8 +17738,10 @@
"rating": { "rating": {
"default": null, "default": null,
"description": "Rating", "description": "Rating",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"default": null, "default": null,
@@ -18150,10 +18168,14 @@
"type": "boolean" "type": "boolean"
}, },
"interval": { "interval": {
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"timeout": { "timeout": {
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
} }
}, },
"required": [ "required": [
@@ -18203,7 +18225,9 @@
"properties": { "properties": {
"files": { "files": {
"description": "Number of files in the folder", "description": "Number of files in the folder",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"folder": { "folder": {
"$ref": "#/components/schemas/StorageFolder" "$ref": "#/components/schemas/StorageFolder"
@@ -18246,7 +18270,9 @@
"type": "string" "type": "string"
}, },
"progress": { "progress": {
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"task": { "task": {
"type": "string" "type": "string"
@@ -18723,8 +18749,9 @@
}, },
"page": { "page": {
"description": "Page number", "description": "Page number",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"personIds": { "personIds": {
"description": "Filter by person IDs", "description": "Filter by person IDs",
@@ -18744,7 +18771,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -18766,7 +18793,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -20597,7 +20624,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -20619,7 +20646,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -21437,8 +21464,9 @@
}, },
"duration": { "duration": {
"description": "Session duration in seconds", "description": "Session duration in seconds",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
} }
}, },
"type": "object" "type": "object"
@@ -21952,8 +21980,9 @@
}, },
"page": { "page": {
"description": "Page number", "description": "Page number",
"maximum": 9007199254740991,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"personIds": { "personIds": {
"description": "Filter by person IDs", "description": "Filter by person IDs",
@@ -21979,7 +22008,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -22001,7 +22030,7 @@
"description": "Number of results to return", "description": "Number of results to return",
"maximum": 1000, "maximum": 1000,
"minimum": 1, "minimum": 1,
"type": "number" "type": "integer"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -22239,7 +22268,7 @@
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "nullable": true,
"type": "number", "type": "integer",
"x-immich-history": [ "x-immich-history": [
{ {
"version": "v1", "version": "v1",
@@ -24371,9 +24400,10 @@
}, },
"defaultStorageQuota": { "defaultStorageQuota": {
"description": "Default storage quota", "description": "Default storage quota",
"maximum": 9007199254740991,
"minimum": 0, "minimum": 0,
"nullable": true, "nullable": true,
"type": "number" "type": "integer"
}, },
"enabled": { "enabled": {
"description": "Enabled", "description": "Enabled",
@@ -24548,7 +24578,7 @@
"description": "SMTP server port", "description": "SMTP server port",
"maximum": 65535, "maximum": 65535,
"minimum": 0, "minimum": 0,
"type": "number" "type": "integer"
}, },
"secure": { "secure": {
"description": "Whether to use secure connection (TLS/SSL)", "description": "Whether to use secure connection (TLS/SSL)",
@@ -25966,7 +25996,9 @@
}, },
"order": { "order": {
"description": "Action order", "description": "Action order",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"pluginActionId": { "pluginActionId": {
"description": "Plugin action ID", "description": "Plugin action ID",
@@ -26065,7 +26097,9 @@
}, },
"order": { "order": {
"description": "Filter order", "description": "Filter order",
"type": "number" "maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}, },
"pluginFilterId": { "pluginFilterId": {
"description": "Plugin filter ID", "description": "Plugin filter ID",
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "2.7.5", "version": "2.7.5",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
} }
+960 -998
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-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4", "@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0", "@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "11.2.6", "@nestjs/swagger": "^11.4.2",
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.215.0", "@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/instrumentation-http": "^0.215.0", "@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.62.0", "@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0", "@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.66.0", "@opentelemetry/instrumentation-pg": "^0.67.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.215.0", "@opentelemetry/sdk-node": "^0.215.0",
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
}); });
it('should reject an invalid size', async () => { it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 }); const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1'])); expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
}); });
+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)'), 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'), livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'), hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.number().min(0).nullable().describe('Asset width'), width: z.int().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'), height: z.int().min(0).nullable().describe('Asset height'),
}) })
.meta({ id: 'SanitizedAssetResponseDto' }); .meta({ id: 'SanitizedAssetResponseDto' });
+1 -1
View File
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({ const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'), ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'), duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'), dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'), timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z const DatabaseBackupSchema = z
.object({ .object({
filename: z.string().describe('Backup filename'), filename: z.string().describe('Backup filename'),
filesize: z.number().describe('Backup file size'), filesize: z.int().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'), timezone: z.string().describe('Backup timezone'),
}) })
.meta({ id: 'DatabaseBackupDto' }); .meta({ id: 'DatabaseBackupDto' });
+4 -4
View File
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z const CropParametersSchema = z
.object({ .object({
x: z.number().min(0).describe('Top-Left X coordinate of crop'), x: z.int().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'), y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'), width: z.int().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'), height: z.int().min(1).describe('Height of the crop'),
}) })
.meta({ id: 'CropParameters' }); .meta({ id: 'CropParameters' });
+4 -4
View File
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
.object({ .object({
make: z.string().nullish().default(null).describe('Camera make'), make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'), model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'), exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'), exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'), fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'), orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers. // 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'), lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'), fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'), focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.number().nullish().default(null).describe('ISO sensitivity'), iso: z.int().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'), exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'), latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'), 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'), country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'), description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'), projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.number().nullish().default(null).describe('Rating'), rating: z.int().nullish().default(null).describe('Rating'),
}) })
.describe('EXIF response') .describe('EXIF response')
.meta({ id: 'ExifResponseDto' }); .meta({ id: 'ExifResponseDto' });
+2 -2
View File
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({ .object({
active: z.boolean(), active: z.boolean(),
action: MaintenanceActionSchema, action: MaintenanceActionSchema,
progress: z.number().optional(), progress: z.int().optional(),
task: z.string().optional(), task: z.string().optional(),
error: z.string().optional(), error: z.string().optional(),
}) })
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema, folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'), readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'), writable: z.boolean().describe('Whether the folder is writable'),
files: z.number().describe('Number of files in the folder'), files: z.int().describe('Number of files in the folder'),
}) })
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' }); .meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
+2 -2
View File
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'), withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'), closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'), closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'), page: z.coerce.number().int().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'), size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
}) })
.meta({ id: 'PersonSearchDto' }); .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'), tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'), albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z rating: z
.number() .int()
.min(-1) .min(-1)
.max(5) .max(5)
.nullish() .nullish()
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({ const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'), withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'), withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.number().min(1).max(1000).optional().describe('Number of results to return'), size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
}); });
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({ const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'), minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'), size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' }); }).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({ const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'), thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'), encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'), order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.number().min(1).optional().describe('Page number'), page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' }); }).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({ const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'), query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'), queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'), language: z.string().optional().describe('Search language code'),
page: z.number().min(1).optional().describe('Page number'), page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' }); }).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z const SearchPlacesSchema = z
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z const SessionCreateSchema = z
.object({ .object({
duration: z.number().min(1).optional().describe('Session duration in seconds'), duration: z.int().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'), deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'), deviceOS: z.string().optional().describe('Device OS'),
}) })
+5 -5
View File
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({ .object({
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema, cronExpression: cronExpressionSchema,
keepLastAmount: z.number().min(1).describe('Keep last amount'), keepLastAmount: z.int().min(1).describe('Keep last amount'),
}) })
.meta({ id: 'DatabaseBackupConfig' }); .meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z const MachineLearningAvailabilityChecksSchema = z
.object({ .object({
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
timeout: z.number(), timeout: z.int(),
interval: z.number(), interval: z.int(),
}) })
.meta({ id: 'MachineLearningAvailabilityChecksDto' }); .meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema, tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'), timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'), allowInsecureRequests: configBool.describe('Allow insecure requests'),
defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'), defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled'), enabled: configBool.describe('Enabled'),
issuerUrl: z issuerUrl: z
.string() .string()
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({ .object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'), ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'), host: z.string().describe('SMTP server hostname'),
port: z.number().min(0).max(65_535).describe('SMTP server port'), port: z.int().min(0).max(65_535).describe('SMTP server port'),
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'), secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'), username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'), password: z.string().describe('SMTP password'),
+2 -2
View File
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'), workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'), pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(), filterConfig: FilterConfigSchema.nullable(),
order: z.number().describe('Filter order'), order: z.int().describe('Filter order'),
}) })
.meta({ id: 'WorkflowFilterResponseDto' }); .meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'), workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'), pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(), actionConfig: ActionConfigSchema.nullable(),
order: z.number().describe('Action order'), order: z.int().describe('Action order'),
}) })
.meta({ id: 'WorkflowActionResponseDto' }); .meta({ id: 'WorkflowActionResponseDto' });
+7 -1
View File
@@ -22,7 +22,7 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key', SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug', SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum', Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid', CorrelationId = 'X-Correlation-ID',
} }
export enum ImmichQuery { export enum ImmichQuery {
@@ -445,6 +445,12 @@ export enum VideoCodec {
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: '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 { export enum AudioCodec {
Mp3 = 'mp3', Mp3 = 'mp3',
Aac = 'aac', Aac = 'aac',
@@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
import { Response } from 'express'; import { Response } from 'express';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod'; import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger'; import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
@@ -16,18 +17,13 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
} }
catch(error: Error, host: ArgumentsHost) { catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp(); this.handleError(host.switchToHttp().getResponse<Response>(), error);
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) { handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error); const { status, body } = this.fromError(error);
if (!res.headersSent) { if (!res.headersSent) {
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); res.header(ImmichHeader.CorrelationId, this.cls.getId()).status(status).json(body);
} }
} }
@@ -36,26 +32,24 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof HttpException) { if (error instanceof HttpException) {
const status = error.getStatus(); const status = error.getStatus();
let body = error.getResponse(); const response = error.getResponse();
const body: Record<string, unknown> =
// unclear what circumstances would return a string typeof response === 'string' ? { message: response } : { ...(response as object) };
if (typeof body === 'string') {
body = { message: body };
}
// handle both request and response validation errors // handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) { if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError(); const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) { if (zodError instanceof ZodError && zodError.issues.length > 0) {
body = { body['message'] = zodError.issues.map((issue) =>
message: zodError.issues.map((issue) => issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
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 }; return { status, body };
} }
@@ -0,0 +1,46 @@
-- 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
+2 -4
View File
@@ -301,11 +301,9 @@ const getEnv = (): EnvData => {
mount: true, mount: true,
generateId: true, generateId: true,
setup: (cls, req: Request, res: Response) => { setup: (cls, req: Request, res: Response) => {
const headerValues = req.headers[ImmichHeader.Cid]; const cid = req.header(ImmichHeader.CorrelationId) || cls.get(CLS_ID);
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
cls.set(CLS_ID, cid); cls.set(CLS_ID, cid);
res.header(ImmichHeader.Cid, cid); res.header(ImmichHeader.CorrelationId, cid);
}, },
}, },
}, },
+2
View File
@@ -46,6 +46,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.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 { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -100,6 +101,7 @@ export const repositories = [
UserRepository, UserRepository,
ViewRepository, ViewRepository,
VersionHistoryRepository, VersionHistoryRepository,
VideoStreamRepository,
WebsocketRepository, WebsocketRepository,
WorkflowRepository, WorkflowRepository,
]; ];
@@ -0,0 +1,62 @@
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();
}
}
+13 -1
View File
@@ -1,5 +1,12 @@
import { registerEnum } from '@immich/sql-tools'; import { registerEnum } from '@immich/sql-tools';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum'; import {
AlbumUserRole,
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
export const album_user_role_enum = registerEnum({ export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum', name: 'album_user_role_enum',
@@ -25,3 +32,8 @@ export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum', name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm), 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,6 +76,11 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.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'; import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@@ -133,6 +138,9 @@ export class ImmichDatabase {
UserMetadataAuditTable, UserMetadataAuditTable,
UserTable, UserTable,
VersionHistoryTable, VersionHistoryTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
VideoStreamSegmentTable,
PluginTable, PluginTable,
PluginFilterTable, PluginFilterTable,
PluginActionTable, PluginActionTable,
@@ -247,6 +255,10 @@ export interface DB {
version_history: VersionHistoryTable; version_history: VersionHistoryTable;
video_stream_session: VideoStreamSessionTable;
video_stream_variant: VideoStreamVariantTable;
video_stream_segment: VideoStreamSegmentTable;
plugin: PluginTable; plugin: PluginTable;
plugin_filter: PluginFilterTable; plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable; plugin_action: PluginActionTable;
@@ -0,0 +1,40 @@
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);
}
@@ -0,0 +1,63 @@
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;
}
+3
View File
@@ -53,6 +53,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.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 { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -109,6 +110,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
TrashRepository, TrashRepository,
UserRepository, UserRepository,
VersionHistoryRepository, VersionHistoryRepository,
VideoStreamRepository,
ViewRepository, ViewRepository,
WebsocketRepository, WebsocketRepository,
WorkflowRepository, WorkflowRepository,
@@ -167,6 +169,7 @@ export class BaseService {
protected trashRepository: TrashRepository, protected trashRepository: TrashRepository,
protected userRepository: UserRepository, protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository, protected versionRepository: VersionHistoryRepository,
protected videoStreamRepository: VideoStreamRepository,
protected viewRepository: ViewRepository, protected viewRepository: ViewRepository,
protected websocketRepository: WebsocketRepository, protected websocketRepository: WebsocketRepository,
protected workflowRepository: WorkflowRepository, protected workflowRepository: WorkflowRepository,
-32
View File
@@ -2,68 +2,36 @@ import { expect } from 'vitest';
export const errorDto = { export const errorDto = {
unauthorized: { unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required', message: 'Authentication required',
correlationId: expect.any(String),
}, },
forbidden: { forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String), message: expect.any(String),
correlationId: expect.any(String),
}, },
missingPermission: (permission: string) => ({ missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`, message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}), }),
wrongPassword: { wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password', message: 'Wrong password',
correlationId: expect.any(String),
}, },
invalidToken: { invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token', message: 'Invalid user token',
correlationId: expect.any(String),
}, },
invalidShareKey: { invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key', message: 'Invalid share key',
correlationId: expect.any(String),
}, },
invalidSharePassword: { invalidSharePassword: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid password', message: 'Invalid password',
correlationId: expect.any(String),
}, },
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(), message: message ?? expect.anything(),
}), }),
noPermission: { noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'), message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
}, },
incorrectLogin: { incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password', message: 'Incorrect email or password',
correlationId: expect.any(String),
}, },
alreadyHasAdmin: { alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin', message: 'The server already has an admin',
correlationId: expect.any(String),
}, },
}; };
-2
View File
@@ -246,8 +246,6 @@ export const factory = {
date: newDate, date: newDate,
responses: { responses: {
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(), message: message ?? expect.anything(),
}), }),
}, },
+4
View File
@@ -64,6 +64,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.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 { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -260,6 +261,7 @@ export type ServiceOverrides = {
trash: TrashRepository; trash: TrashRepository;
user: UserRepository; user: UserRepository;
versionHistory: VersionHistoryRepository; versionHistory: VersionHistoryRepository;
videoStream: VideoStreamRepository;
view: ViewRepository; view: ViewRepository;
websocket: WebsocketRepository; websocket: WebsocketRepository;
workflow: WorkflowRepository; workflow: WorkflowRepository;
@@ -344,6 +346,7 @@ export const getMocks = () => {
trash: automock(TrashRepository), trash: automock(TrashRepository),
user: automock(UserRepository, { strict: false }), user: automock(UserRepository, { strict: false }),
versionHistory: automock(VersionHistoryRepository), versionHistory: automock(VersionHistoryRepository),
videoStream: automock(VideoStreamRepository),
view: automock(ViewRepository), view: automock(ViewRepository),
// eslint-disable-next-line no-sparse-arrays // eslint-disable-next-line no-sparse-arrays
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }), websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
@@ -408,6 +411,7 @@ export const newTestService = <T extends BaseService>(
overrides.trash || (mocks.trash as As<TrashRepository>), overrides.trash || (mocks.trash as As<TrashRepository>),
overrides.user || (mocks.user as As<UserRepository>), overrides.user || (mocks.user as As<UserRepository>),
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>), overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
overrides.videoStream || (mocks.videoStream as As<VideoStreamRepository>),
overrides.view || (mocks.view as As<ViewRepository>), overrides.view || (mocks.view as As<ViewRepository>),
overrides.websocket || (mocks.websocket as As<WebsocketRepository>), overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
overrides.workflow || (mocks.workflow as As<WorkflowRepository>), overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
@@ -35,7 +35,7 @@
const setSelectedDate = (value: DateTime | undefined) => { const setSelectedDate = (value: DateTime | undefined) => {
selectedPresetValue = null; // Clear preset when manually setting date selectedPresetValue = null; // Clear preset when manually setting date
expiresAt = value ? value.toISO() : null; expiresAt = value ? value.toUTC().toISO() : null;
}; };
const selectPreset = (value: number) => { const selectPreset = (value: number) => {
@@ -44,8 +44,8 @@
expiresAt = null; expiresAt = null;
return; return;
} }
const newDate = DateTime.now().plus(value); const newDate = DateTime.now().plus({ milliseconds: value });
expiresAt = newDate.toISO(); expiresAt = newDate.toUTC().toISO();
}; };
const isSelected = (value: number) => { const isSelected = (value: number) => {
@@ -67,7 +67,7 @@
preAction?: PreAction; preAction?: PreAction;
onAction?: OnAction; onAction?: OnAction;
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void; onClose?: (assetId: string) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void; onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>; onRandom?: () => Promise<{ id: string } | undefined>;
} }
@@ -179,7 +179,7 @@
}); });
const closeViewer = () => { const closeViewer = () => {
onClose?.(asset); onClose?.(asset.id);
}; };
const closeEditor = async () => { const closeEditor = async () => {
@@ -474,7 +474,7 @@
onAction={handleAction} onAction={handleAction}
{onUndoDelete} {onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined} onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum} {onRemoveFromAlbum}
{playOriginalVideo} {playOriginalVideo}
{setPlayOriginalVideo} {setPlayOriginalVideo}
@@ -11,7 +11,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { getAssetMediaUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils'; import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -24,26 +24,15 @@
type AssetResponseDto, type AssetResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui'; import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
mdiEyeOff,
mdiImageOutline,
mdiInformationOutline,
mdiPencil,
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/ImageThumbnail.svelte';
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte'; import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
import OnEvents from '../OnEvents.svelte'; import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte'; import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte'; import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -53,8 +42,6 @@
let { asset, currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let latlng = $derived( let latlng = $derived(
(() => { (() => {
const lat = asset.exifInfo?.latitude; const lat = asset.exifInfo?.latitude;
@@ -162,110 +149,7 @@
<DetailPanelDescription {asset} {isOwner} /> <DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {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={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 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if assetViewerManager.isShowingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<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"> <div class="px-4 py-4">
{#if asset.exifInfo} {#if asset.exifInfo}
@@ -0,0 +1,133 @@
<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}
@@ -96,9 +96,9 @@
return { id: randomAsset.id }; return { id: randomAsset.id };
}; };
const handleClose = async (asset: { id: string }) => { const handleClose = async (assetId: string) => {
invisible = true; invisible = true;
assetViewerManager.gridScrollTarget = { at: asset.id }; assetViewerManager.gridScrollTarget = { at: assetId };
await navigate({ await navigate({
targetRoute: 'current', targetRoute: 'current',
assetId: null, assetId: null,
@@ -117,7 +117,7 @@
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) || (await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) || (await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current)); (await handleClose(assetCursor.current.id));
}; };
const handlePreAction = async (action: Action) => { const handlePreAction = async (action: Action) => {
@@ -136,7 +136,7 @@
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) || (await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) || (await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset)); (await handleClose(action.asset.id));
break; break;
} }
@@ -45,10 +45,7 @@
await deleteAssets( await deleteAssets(
force, force,
(assetIds) => { (assetIds) => timelineManager.removeAssets(assetIds),
timelineManager.removeAssets(assetIds);
eventManager.emit('AssetsDelete', assetIds);
},
selectedAssets, selectedAssets,
force ? undefined : (assets) => timelineManager.upsertAssets(assets), force ? undefined : (assets) => timelineManager.upsertAssets(assets),
); );
@@ -33,6 +33,8 @@ class MemoryManager {
if (authManager.authenticated) { if (authManager.authenticated) {
void this.initialize(); void this.initialize();
} }
this.scheduleHourlyRefresh();
} }
ready() { ready() {
@@ -132,6 +134,29 @@ class MemoryManager {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) }); const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0); this.memories = memories.filter((memory) => memory.assets.length > 0);
} }
private scheduleHourlyRefresh() {
const now = DateTime.utc();
let nextEvent = now.set({ minute: 0, second: 5 });
if (nextEvent <= now) {
nextEvent = nextEvent.plus({ hours: 1 });
}
const initialDelay = nextEvent.diff(now).as('milliseconds');
setTimeout(() => {
this.#loading = this.load();
// Schedule subsequent events hourly
setInterval(
() => {
this.#loading = this.load();
},
60 * 60 * 1000,
);
}, initialDelay);
}
} }
export const memoryManager = new MemoryManager(); export const memoryManager = new MemoryManager();
+2
View File
@@ -80,6 +80,8 @@ websocket
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event)) .on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
.on('on_session_delete', () => eventManager.emit('SessionDelete')) .on('on_session_delete', () => eventManager.emit('SessionDelete'))
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id })) .on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
.on('on_asset_delete', (asset) => eventManager.emit('AssetsDelete', [asset]))
.on('on_asset_trash', (assets) => eventManager.emit('AssetsDelete', assets))
.on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset)) .on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset))
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id })) .on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
.on('on_notification', () => notificationManager.refresh()) .on('on_notification', () => notificationManager.refresh())
@@ -86,7 +86,7 @@
</div> </div>
</UserPageLayout> </UserPageLayout>
<Portal target="body"> <Portal target="body">
{#if assetViewerManager.isViewing} {#if assetViewerManager.isViewing && !isTimelinePanelVisible}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer <AssetViewer
cursor={{ current: assetViewerManager.asset! }} cursor={{ current: assetViewerManager.asset! }}
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte'; import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import LargeAssetData from './LargeAssetData.svelte'; import LargeAssetData from './LargeAssetData.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils'; import { getNextAsset, getPreviousAsset, navigateToAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -17,7 +18,7 @@
let { data }: Props = $props(); let { data }: Props = $props();
let assets = $derived(data.assets); let assets = $state(data.assets);
let asset = $derived(data.asset); let asset = $derived(data.asset);
$effect(() => { $effect(() => {
@@ -36,13 +37,19 @@
return asset; return asset;
}; };
const onAction = (payload: Action) => { const preAction = async (payload: Action) => {
if (payload.type == 'trash') { if (payload.type == 'trash') {
assets = assets.filter((a) => a.id != payload.asset.id); // eslint-disable-next-line @typescript-eslint/no-unused-expressions
assetViewerManager.showAssetViewer(false); (await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
assetViewerManager.showAssetViewer(false);
} }
}; };
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id));
};
const onViewAsset = async (asset: AssetResponseDto) => { const onViewAsset = async (asset: AssetResponseDto) => {
await navigate({ targetRoute: 'current', assetId: asset.id }); await navigate({ targetRoute: 'current', assetId: asset.id });
}; };
@@ -54,9 +61,11 @@
}); });
</script> </script>
<OnEvents {onAssetsDelete} />
<UserPageLayout title={data.meta.title} scrollbar={true}> <UserPageLayout title={data.meta.title} scrollbar={true}>
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> <div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{#if assets && data.assets.length > 0} {#if assets && assets.length > 0}
{#each assets as asset (asset.id)} {#each assets as asset (asset.id)}
<LargeAssetData {asset} {onViewAsset} /> <LargeAssetData {asset} {onViewAsset} />
{/each} {/each}
@@ -75,7 +84,7 @@
cursor={assetCursor} cursor={assetCursor}
showNavigation={assets.length > 1} showNavigation={assets.length > 1}
{onRandom} {onRandom}
{onAction} {preAction}
onClose={() => { onClose={() => {
assetViewerManager.showAssetViewer(false); assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));