mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	feat(web): rotate image
This commit is contained in:
		
							parent
							
								
									dbbefde98d
								
							
						
					
					
						commit
						9cd0871178
					
				@ -602,6 +602,8 @@
 | 
			
		||||
  "enable": "Enable",
 | 
			
		||||
  "enabled": "Enabled",
 | 
			
		||||
  "end_date": "End date",
 | 
			
		||||
  "rotate_left": "Rotate left",
 | 
			
		||||
  "rotate_right": "Rotate right",
 | 
			
		||||
  "error": "Error",
 | 
			
		||||
  "error_loading_image": "Error loading image",
 | 
			
		||||
  "error_title": "Error - Something went wrong",
 | 
			
		||||
@ -644,6 +646,7 @@
 | 
			
		||||
    "quota_higher_than_disk_size": "You set a quota higher than the disk size",
 | 
			
		||||
    "repair_unable_to_check_items": "Unable to check {count, select, one {item} other {items}}",
 | 
			
		||||
    "unable_to_add_album_users": "Unable to add users to album",
 | 
			
		||||
    "unable_to_rotate_image": "Unable to rotate image",
 | 
			
		||||
    "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
 | 
			
		||||
    "unable_to_add_comment": "Unable to add comment",
 | 
			
		||||
    "unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										105
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										105
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							@ -20,6 +20,7 @@ class AssetBulkUpdateDto {
 | 
			
		||||
    this.isFavorite,
 | 
			
		||||
    this.latitude,
 | 
			
		||||
    this.longitude,
 | 
			
		||||
    this.orientation,
 | 
			
		||||
    this.rating,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -67,6 +68,8 @@ class AssetBulkUpdateDto {
 | 
			
		||||
  ///
 | 
			
		||||
  num? longitude;
 | 
			
		||||
 | 
			
		||||
  AssetBulkUpdateDtoOrientationEnum? orientation;
 | 
			
		||||
 | 
			
		||||
  /// Minimum value: -1
 | 
			
		||||
  /// Maximum value: 5
 | 
			
		||||
  ///
 | 
			
		||||
@ -86,6 +89,7 @@ class AssetBulkUpdateDto {
 | 
			
		||||
    other.isFavorite == isFavorite &&
 | 
			
		||||
    other.latitude == latitude &&
 | 
			
		||||
    other.longitude == longitude &&
 | 
			
		||||
    other.orientation == orientation &&
 | 
			
		||||
    other.rating == rating;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -98,10 +102,11 @@ class AssetBulkUpdateDto {
 | 
			
		||||
    (isFavorite == null ? 0 : isFavorite!.hashCode) +
 | 
			
		||||
    (latitude == null ? 0 : latitude!.hashCode) +
 | 
			
		||||
    (longitude == null ? 0 : longitude!.hashCode) +
 | 
			
		||||
    (orientation == null ? 0 : orientation!.hashCode) +
 | 
			
		||||
    (rating == null ? 0 : rating!.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]';
 | 
			
		||||
  String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, rating=$rating]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -136,6 +141,11 @@ class AssetBulkUpdateDto {
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'longitude'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.orientation != null) {
 | 
			
		||||
      json[r'orientation'] = this.orientation;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'orientation'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.rating != null) {
 | 
			
		||||
      json[r'rating'] = this.rating;
 | 
			
		||||
    } else {
 | 
			
		||||
@ -162,6 +172,7 @@ class AssetBulkUpdateDto {
 | 
			
		||||
        isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
 | 
			
		||||
        latitude: num.parse('${json[r'latitude']}'),
 | 
			
		||||
        longitude: num.parse('${json[r'longitude']}'),
 | 
			
		||||
        orientation: AssetBulkUpdateDtoOrientationEnum.fromJson(json[r'orientation']),
 | 
			
		||||
        rating: num.parse('${json[r'rating']}'),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@ -214,3 +225,95 @@ class AssetBulkUpdateDto {
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AssetBulkUpdateDtoOrientationEnum {
 | 
			
		||||
  /// Instantiate a new enum with the provided [value].
 | 
			
		||||
  const AssetBulkUpdateDtoOrientationEnum._(this.value);
 | 
			
		||||
 | 
			
		||||
  /// The underlying value of this enum member.
 | 
			
		||||
  final int value;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => value.toString();
 | 
			
		||||
 | 
			
		||||
  int toJson() => value;
 | 
			
		||||
 | 
			
		||||
  static const number1 = AssetBulkUpdateDtoOrientationEnum._(1);
 | 
			
		||||
  static const number2 = AssetBulkUpdateDtoOrientationEnum._(2);
 | 
			
		||||
  static const number3 = AssetBulkUpdateDtoOrientationEnum._(3);
 | 
			
		||||
  static const number4 = AssetBulkUpdateDtoOrientationEnum._(4);
 | 
			
		||||
  static const number5 = AssetBulkUpdateDtoOrientationEnum._(5);
 | 
			
		||||
  static const number6 = AssetBulkUpdateDtoOrientationEnum._(6);
 | 
			
		||||
  static const number7 = AssetBulkUpdateDtoOrientationEnum._(7);
 | 
			
		||||
  static const number8 = AssetBulkUpdateDtoOrientationEnum._(8);
 | 
			
		||||
 | 
			
		||||
  /// List of all possible values in this [enum][AssetBulkUpdateDtoOrientationEnum].
 | 
			
		||||
  static const values = <AssetBulkUpdateDtoOrientationEnum>[
 | 
			
		||||
    number1,
 | 
			
		||||
    number2,
 | 
			
		||||
    number3,
 | 
			
		||||
    number4,
 | 
			
		||||
    number5,
 | 
			
		||||
    number6,
 | 
			
		||||
    number7,
 | 
			
		||||
    number8,
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  static AssetBulkUpdateDtoOrientationEnum? fromJson(dynamic value) => AssetBulkUpdateDtoOrientationEnumTypeTransformer().decode(value);
 | 
			
		||||
 | 
			
		||||
  static List<AssetBulkUpdateDtoOrientationEnum> listFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final result = <AssetBulkUpdateDtoOrientationEnum>[];
 | 
			
		||||
    if (json is List && json.isNotEmpty) {
 | 
			
		||||
      for (final row in json) {
 | 
			
		||||
        final value = AssetBulkUpdateDtoOrientationEnum.fromJson(row);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          result.add(value);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return result.toList(growable: growable);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Transformation class that can [encode] an instance of [AssetBulkUpdateDtoOrientationEnum] to int,
 | 
			
		||||
/// and [decode] dynamic data back to [AssetBulkUpdateDtoOrientationEnum].
 | 
			
		||||
class AssetBulkUpdateDtoOrientationEnumTypeTransformer {
 | 
			
		||||
  factory AssetBulkUpdateDtoOrientationEnumTypeTransformer() => _instance ??= const AssetBulkUpdateDtoOrientationEnumTypeTransformer._();
 | 
			
		||||
 | 
			
		||||
  const AssetBulkUpdateDtoOrientationEnumTypeTransformer._();
 | 
			
		||||
 | 
			
		||||
  int encode(AssetBulkUpdateDtoOrientationEnum data) => data.value;
 | 
			
		||||
 | 
			
		||||
  /// Decodes a [dynamic value][data] to a AssetBulkUpdateDtoOrientationEnum.
 | 
			
		||||
  ///
 | 
			
		||||
  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
 | 
			
		||||
  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
 | 
			
		||||
  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
 | 
			
		||||
  ///
 | 
			
		||||
  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
 | 
			
		||||
  /// and users are still using an old app with the old code.
 | 
			
		||||
  AssetBulkUpdateDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) {
 | 
			
		||||
    if (data != null) {
 | 
			
		||||
      switch (data) {
 | 
			
		||||
        case 1: return AssetBulkUpdateDtoOrientationEnum.number1;
 | 
			
		||||
        case 2: return AssetBulkUpdateDtoOrientationEnum.number2;
 | 
			
		||||
        case 3: return AssetBulkUpdateDtoOrientationEnum.number3;
 | 
			
		||||
        case 4: return AssetBulkUpdateDtoOrientationEnum.number4;
 | 
			
		||||
        case 5: return AssetBulkUpdateDtoOrientationEnum.number5;
 | 
			
		||||
        case 6: return AssetBulkUpdateDtoOrientationEnum.number6;
 | 
			
		||||
        case 7: return AssetBulkUpdateDtoOrientationEnum.number7;
 | 
			
		||||
        case 8: return AssetBulkUpdateDtoOrientationEnum.number8;
 | 
			
		||||
        default:
 | 
			
		||||
          if (!allowNull) {
 | 
			
		||||
            throw ArgumentError('Unknown enum value to decode: $data');
 | 
			
		||||
          }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Singleton [AssetBulkUpdateDtoOrientationEnumTypeTransformer] instance.
 | 
			
		||||
  static AssetBulkUpdateDtoOrientationEnumTypeTransformer? _instance;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										105
									
								
								mobile/openapi/lib/model/update_asset_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										105
									
								
								mobile/openapi/lib/model/update_asset_dto.dart
									
									
									
										generated
									
									
									
								
							@ -20,6 +20,7 @@ class UpdateAssetDto {
 | 
			
		||||
    this.latitude,
 | 
			
		||||
    this.livePhotoVideoId,
 | 
			
		||||
    this.longitude,
 | 
			
		||||
    this.orientation,
 | 
			
		||||
    this.rating,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -73,6 +74,8 @@ class UpdateAssetDto {
 | 
			
		||||
  ///
 | 
			
		||||
  num? longitude;
 | 
			
		||||
 | 
			
		||||
  UpdateAssetDtoOrientationEnum? orientation;
 | 
			
		||||
 | 
			
		||||
  /// Minimum value: -1
 | 
			
		||||
  /// Maximum value: 5
 | 
			
		||||
  ///
 | 
			
		||||
@ -92,6 +95,7 @@ class UpdateAssetDto {
 | 
			
		||||
    other.latitude == latitude &&
 | 
			
		||||
    other.livePhotoVideoId == livePhotoVideoId &&
 | 
			
		||||
    other.longitude == longitude &&
 | 
			
		||||
    other.orientation == orientation &&
 | 
			
		||||
    other.rating == rating;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
@ -104,10 +108,11 @@ class UpdateAssetDto {
 | 
			
		||||
    (latitude == null ? 0 : latitude!.hashCode) +
 | 
			
		||||
    (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
 | 
			
		||||
    (longitude == null ? 0 : longitude!.hashCode) +
 | 
			
		||||
    (orientation == null ? 0 : orientation!.hashCode) +
 | 
			
		||||
    (rating == null ? 0 : rating!.hashCode);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]';
 | 
			
		||||
  String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, orientation=$orientation, rating=$rating]';
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toJson() {
 | 
			
		||||
    final json = <String, dynamic>{};
 | 
			
		||||
@ -146,6 +151,11 @@ class UpdateAssetDto {
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'longitude'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.orientation != null) {
 | 
			
		||||
      json[r'orientation'] = this.orientation;
 | 
			
		||||
    } else {
 | 
			
		||||
    //  json[r'orientation'] = null;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.rating != null) {
 | 
			
		||||
      json[r'rating'] = this.rating;
 | 
			
		||||
    } else {
 | 
			
		||||
@ -170,6 +180,7 @@ class UpdateAssetDto {
 | 
			
		||||
        latitude: num.parse('${json[r'latitude']}'),
 | 
			
		||||
        livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
 | 
			
		||||
        longitude: num.parse('${json[r'longitude']}'),
 | 
			
		||||
        orientation: UpdateAssetDtoOrientationEnum.fromJson(json[r'orientation']),
 | 
			
		||||
        rating: num.parse('${json[r'rating']}'),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
@ -221,3 +232,95 @@ class UpdateAssetDto {
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UpdateAssetDtoOrientationEnum {
 | 
			
		||||
  /// Instantiate a new enum with the provided [value].
 | 
			
		||||
  const UpdateAssetDtoOrientationEnum._(this.value);
 | 
			
		||||
 | 
			
		||||
  /// The underlying value of this enum member.
 | 
			
		||||
  final int value;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => value.toString();
 | 
			
		||||
 | 
			
		||||
  int toJson() => value;
 | 
			
		||||
 | 
			
		||||
  static const number1 = UpdateAssetDtoOrientationEnum._(1);
 | 
			
		||||
  static const number2 = UpdateAssetDtoOrientationEnum._(2);
 | 
			
		||||
  static const number3 = UpdateAssetDtoOrientationEnum._(3);
 | 
			
		||||
  static const number4 = UpdateAssetDtoOrientationEnum._(4);
 | 
			
		||||
  static const number5 = UpdateAssetDtoOrientationEnum._(5);
 | 
			
		||||
  static const number6 = UpdateAssetDtoOrientationEnum._(6);
 | 
			
		||||
  static const number7 = UpdateAssetDtoOrientationEnum._(7);
 | 
			
		||||
  static const number8 = UpdateAssetDtoOrientationEnum._(8);
 | 
			
		||||
 | 
			
		||||
  /// List of all possible values in this [enum][UpdateAssetDtoOrientationEnum].
 | 
			
		||||
  static const values = <UpdateAssetDtoOrientationEnum>[
 | 
			
		||||
    number1,
 | 
			
		||||
    number2,
 | 
			
		||||
    number3,
 | 
			
		||||
    number4,
 | 
			
		||||
    number5,
 | 
			
		||||
    number6,
 | 
			
		||||
    number7,
 | 
			
		||||
    number8,
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  static UpdateAssetDtoOrientationEnum? fromJson(dynamic value) => UpdateAssetDtoOrientationEnumTypeTransformer().decode(value);
 | 
			
		||||
 | 
			
		||||
  static List<UpdateAssetDtoOrientationEnum> listFromJson(dynamic json, {bool growable = false,}) {
 | 
			
		||||
    final result = <UpdateAssetDtoOrientationEnum>[];
 | 
			
		||||
    if (json is List && json.isNotEmpty) {
 | 
			
		||||
      for (final row in json) {
 | 
			
		||||
        final value = UpdateAssetDtoOrientationEnum.fromJson(row);
 | 
			
		||||
        if (value != null) {
 | 
			
		||||
          result.add(value);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return result.toList(growable: growable);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Transformation class that can [encode] an instance of [UpdateAssetDtoOrientationEnum] to int,
 | 
			
		||||
/// and [decode] dynamic data back to [UpdateAssetDtoOrientationEnum].
 | 
			
		||||
class UpdateAssetDtoOrientationEnumTypeTransformer {
 | 
			
		||||
  factory UpdateAssetDtoOrientationEnumTypeTransformer() => _instance ??= const UpdateAssetDtoOrientationEnumTypeTransformer._();
 | 
			
		||||
 | 
			
		||||
  const UpdateAssetDtoOrientationEnumTypeTransformer._();
 | 
			
		||||
 | 
			
		||||
  int encode(UpdateAssetDtoOrientationEnum data) => data.value;
 | 
			
		||||
 | 
			
		||||
  /// Decodes a [dynamic value][data] to a UpdateAssetDtoOrientationEnum.
 | 
			
		||||
  ///
 | 
			
		||||
  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
 | 
			
		||||
  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
 | 
			
		||||
  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
 | 
			
		||||
  ///
 | 
			
		||||
  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
 | 
			
		||||
  /// and users are still using an old app with the old code.
 | 
			
		||||
  UpdateAssetDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) {
 | 
			
		||||
    if (data != null) {
 | 
			
		||||
      switch (data) {
 | 
			
		||||
        case 1: return UpdateAssetDtoOrientationEnum.number1;
 | 
			
		||||
        case 2: return UpdateAssetDtoOrientationEnum.number2;
 | 
			
		||||
        case 3: return UpdateAssetDtoOrientationEnum.number3;
 | 
			
		||||
        case 4: return UpdateAssetDtoOrientationEnum.number4;
 | 
			
		||||
        case 5: return UpdateAssetDtoOrientationEnum.number5;
 | 
			
		||||
        case 6: return UpdateAssetDtoOrientationEnum.number6;
 | 
			
		||||
        case 7: return UpdateAssetDtoOrientationEnum.number7;
 | 
			
		||||
        case 8: return UpdateAssetDtoOrientationEnum.number8;
 | 
			
		||||
        default:
 | 
			
		||||
          if (!allowNull) {
 | 
			
		||||
            throw ArgumentError('Unknown enum value to decode: $data');
 | 
			
		||||
          }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Singleton [UpdateAssetDtoOrientationEnumTypeTransformer] instance.
 | 
			
		||||
  static UpdateAssetDtoOrientationEnumTypeTransformer? _instance;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7963,6 +7963,21 @@
 | 
			
		||||
          "longitude": {
 | 
			
		||||
            "type": "number"
 | 
			
		||||
          },
 | 
			
		||||
          "orientation": {
 | 
			
		||||
            "enum": [
 | 
			
		||||
              1,
 | 
			
		||||
              2,
 | 
			
		||||
              3,
 | 
			
		||||
              4,
 | 
			
		||||
              5,
 | 
			
		||||
              6,
 | 
			
		||||
              7,
 | 
			
		||||
              8
 | 
			
		||||
            ],
 | 
			
		||||
            "maximum": 8,
 | 
			
		||||
            "minimum": 1,
 | 
			
		||||
            "type": "integer"
 | 
			
		||||
          },
 | 
			
		||||
          "rating": {
 | 
			
		||||
            "maximum": 5,
 | 
			
		||||
            "minimum": -1,
 | 
			
		||||
@ -12880,6 +12895,21 @@
 | 
			
		||||
          "longitude": {
 | 
			
		||||
            "type": "number"
 | 
			
		||||
          },
 | 
			
		||||
          "orientation": {
 | 
			
		||||
            "enum": [
 | 
			
		||||
              1,
 | 
			
		||||
              2,
 | 
			
		||||
              3,
 | 
			
		||||
              4,
 | 
			
		||||
              5,
 | 
			
		||||
              6,
 | 
			
		||||
              7,
 | 
			
		||||
              8
 | 
			
		||||
            ],
 | 
			
		||||
            "maximum": 8,
 | 
			
		||||
            "minimum": 1,
 | 
			
		||||
            "type": "integer"
 | 
			
		||||
          },
 | 
			
		||||
          "rating": {
 | 
			
		||||
            "maximum": 5,
 | 
			
		||||
            "minimum": -1,
 | 
			
		||||
 | 
			
		||||
@ -391,6 +391,7 @@ export type AssetBulkUpdateDto = {
 | 
			
		||||
    isFavorite?: boolean;
 | 
			
		||||
    latitude?: number;
 | 
			
		||||
    longitude?: number;
 | 
			
		||||
    orientation?: Orientation;
 | 
			
		||||
    rating?: number;
 | 
			
		||||
};
 | 
			
		||||
export type AssetBulkUploadCheckItem = {
 | 
			
		||||
@ -439,6 +440,7 @@ export type UpdateAssetDto = {
 | 
			
		||||
    latitude?: number;
 | 
			
		||||
    livePhotoVideoId?: string | null;
 | 
			
		||||
    longitude?: number;
 | 
			
		||||
    orientation?: Orientation;
 | 
			
		||||
    rating?: number;
 | 
			
		||||
};
 | 
			
		||||
export type AssetMediaReplaceDto = {
 | 
			
		||||
@ -3481,6 +3483,16 @@ export enum AssetMediaStatus {
 | 
			
		||||
    Replaced = "replaced",
 | 
			
		||||
    Duplicate = "duplicate"
 | 
			
		||||
}
 | 
			
		||||
export enum Orientation {
 | 
			
		||||
    $1 = 1,
 | 
			
		||||
    $2 = 2,
 | 
			
		||||
    $3 = 3,
 | 
			
		||||
    $4 = 4,
 | 
			
		||||
    $5 = 5,
 | 
			
		||||
    $6 = 6,
 | 
			
		||||
    $7 = 7,
 | 
			
		||||
    $8 = 8
 | 
			
		||||
}
 | 
			
		||||
export enum Action {
 | 
			
		||||
    Accept = "accept",
 | 
			
		||||
    Reject = "reject"
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,7 @@ import {
 | 
			
		||||
  ValidateIf,
 | 
			
		||||
} from 'class-validator';
 | 
			
		||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
 | 
			
		||||
import { AssetType } from 'src/enum';
 | 
			
		||||
import { AssetType, ExifOrientation } from 'src/enum';
 | 
			
		||||
import { AssetStats } from 'src/repositories/asset.repository';
 | 
			
		||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
 | 
			
		||||
 | 
			
		||||
@ -54,6 +54,12 @@ export class UpdateAssetBase {
 | 
			
		||||
  @Max(5)
 | 
			
		||||
  @Min(-1)
 | 
			
		||||
  rating?: number;
 | 
			
		||||
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @Min(1)
 | 
			
		||||
  @Max(8)
 | 
			
		||||
  @ApiProperty({ type: 'integer' })
 | 
			
		||||
  orientation?: ExifOrientation;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AssetBulkUpdateDto extends UpdateAssetBase {
 | 
			
		||||
 | 
			
		||||
@ -101,6 +101,7 @@ export class MetadataRepository {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
 | 
			
		||||
    this.logger.verbose(`Writing tags ${JSON.stringify(tags)} to ${path}`);
 | 
			
		||||
    try {
 | 
			
		||||
      await this.exiftool.write(path, tags);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
 | 
			
		||||
@ -100,7 +100,7 @@ export class AssetService extends BaseService {
 | 
			
		||||
  async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
 | 
			
		||||
    await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] });
 | 
			
		||||
 | 
			
		||||
    const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
 | 
			
		||||
    const { description, dateTimeOriginal, latitude, longitude, rating, orientation, ...rest } = dto;
 | 
			
		||||
    const repos = { asset: this.assetRepository, event: this.eventRepository };
 | 
			
		||||
 | 
			
		||||
    let previousMotion: AssetEntity | null = null;
 | 
			
		||||
@ -113,7 +113,7 @@ export class AssetService extends BaseService {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
 | 
			
		||||
    await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating, orientation });
 | 
			
		||||
 | 
			
		||||
    const asset = await this.assetRepository.update({ id, ...rest });
 | 
			
		||||
 | 
			
		||||
@ -129,11 +129,12 @@ export class AssetService extends BaseService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
 | 
			
		||||
    const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto;
 | 
			
		||||
    const { ids, dateTimeOriginal, latitude, longitude, orientation, ...options } = dto;
 | 
			
		||||
    await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids });
 | 
			
		||||
 | 
			
		||||
    // TODO rewrite this to support batching
 | 
			
		||||
    for (const id of ids) {
 | 
			
		||||
      await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
 | 
			
		||||
      await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude, orientation });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
@ -284,11 +285,14 @@ export class AssetService extends BaseService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async updateMetadata(dto: ISidecarWriteJob) {
 | 
			
		||||
    const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
 | 
			
		||||
    const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
 | 
			
		||||
    const { id, description, dateTimeOriginal, latitude, longitude, rating, orientation } = dto;
 | 
			
		||||
    const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating, orientation }, _.isUndefined);
 | 
			
		||||
    if (Object.keys(writes).length > 0) {
 | 
			
		||||
      await this.assetRepository.upsertExif({ assetId: id, ...writes });
 | 
			
		||||
      await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });
 | 
			
		||||
      if (orientation !== undefined) {
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id, notify: true } });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -215,7 +215,7 @@ export class MediaService extends BaseService {
 | 
			
		||||
      const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
 | 
			
		||||
      const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true';
 | 
			
		||||
 | 
			
		||||
      const orientation = useExtracted && asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined;
 | 
			
		||||
      const orientation = asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined;
 | 
			
		||||
      const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size, orientation };
 | 
			
		||||
      const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -295,7 +295,7 @@ export class MetadataService extends BaseService {
 | 
			
		||||
 | 
			
		||||
  @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR })
 | 
			
		||||
  async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> {
 | 
			
		||||
    const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
 | 
			
		||||
    const { id, description, dateTimeOriginal, latitude, longitude, rating, tags, orientation } = job;
 | 
			
		||||
    const [asset] = await this.assetRepository.getByIds([id], { tags: true });
 | 
			
		||||
    if (!asset) {
 | 
			
		||||
      return JobStatus.FAILED;
 | 
			
		||||
@ -311,6 +311,7 @@ export class MetadataService extends BaseService {
 | 
			
		||||
        DateTimeOriginal: dateTimeOriginal,
 | 
			
		||||
        GPSLatitude: latitude,
 | 
			
		||||
        GPSLongitude: longitude,
 | 
			
		||||
        'Orientation#': orientation,
 | 
			
		||||
        Rating: rating,
 | 
			
		||||
        TagsList: tags ? tagsList : undefined,
 | 
			
		||||
      },
 | 
			
		||||
 | 
			
		||||
@ -222,6 +222,7 @@ export interface ISidecarWriteJob extends IEntityJob {
 | 
			
		||||
  latitude?: number;
 | 
			
		||||
  longitude?: number;
 | 
			
		||||
  rating?: number;
 | 
			
		||||
  orientation?: ExifOrientation;
 | 
			
		||||
  tags?: true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -13,6 +13,7 @@ type ActionMap = {
 | 
			
		||||
  [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto };
 | 
			
		||||
  [AssetAction.UNSTACK]: { assets: AssetResponseDto[] };
 | 
			
		||||
  [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto };
 | 
			
		||||
  [AssetAction.ROTATE]: { asset: AssetResponseDto; counterclockwise: boolean };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Action = {
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,96 @@
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
  import { shortcut } from '$lib/actions/shortcut';
 | 
			
		||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
			
		||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
			
		||||
  import { AssetAction, ExifOrientation } from '$lib/constants';
 | 
			
		||||
  import { handleError } from '$lib/utils/handle-error';
 | 
			
		||||
  import { updateAsset, type AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
  import { mdiRotateLeft, mdiRotateRight } from '@mdi/js';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import type { OnAction } from './action';
 | 
			
		||||
  import {
 | 
			
		||||
    notificationController,
 | 
			
		||||
    NotificationType,
 | 
			
		||||
  } from '$lib/components/shared-components/notification/notification';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    asset: AssetResponseDto;
 | 
			
		||||
    onAction: OnAction;
 | 
			
		||||
    counterclockwise?: boolean;
 | 
			
		||||
    menuItem?: boolean;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let { asset, onAction, counterclockwise = false, menuItem }: Props = $props();
 | 
			
		||||
 | 
			
		||||
  const icon = $derived(counterclockwise ? mdiRotateLeft : mdiRotateRight);
 | 
			
		||||
  const text = $derived(counterclockwise ? $t('rotate_left') : $t('rotate_right'));
 | 
			
		||||
 | 
			
		||||
  const getNextOrientation = (current: number) => {
 | 
			
		||||
    switch (current) {
 | 
			
		||||
      case -1:
 | 
			
		||||
      case 0:
 | 
			
		||||
      case ExifOrientation.Horizontal: {
 | 
			
		||||
        return ExifOrientation.Rotate90CW;
 | 
			
		||||
      }
 | 
			
		||||
      case ExifOrientation.Rotate90CW: {
 | 
			
		||||
        return ExifOrientation.Rotate180;
 | 
			
		||||
      }
 | 
			
		||||
      case ExifOrientation.Rotate180: {
 | 
			
		||||
        return ExifOrientation.Rotate270CW;
 | 
			
		||||
      }
 | 
			
		||||
      case ExifOrientation.Rotate270CW: {
 | 
			
		||||
        return ExifOrientation.Horizontal;
 | 
			
		||||
      }
 | 
			
		||||
      case ExifOrientation.MirrorHorizontal: {
 | 
			
		||||
        return ExifOrientation.MirrorHorizontalRotate90CW;
 | 
			
		||||
      }
 | 
			
		||||
      case ExifOrientation.MirrorHorizontalRotate90CW: {
 | 
			
		||||
        return ExifOrientation.MirrorVertical;
 | 
			
		||||
      }
 | 
			
		||||
      case ExifOrientation.MirrorVertical: {
 | 
			
		||||
        return ExifOrientation.MirrorHorizontalRotate270CW;
 | 
			
		||||
      }
 | 
			
		||||
      case ExifOrientation.MirrorHorizontalRotate270CW: {
 | 
			
		||||
        return ExifOrientation.MirrorVertical;
 | 
			
		||||
      }
 | 
			
		||||
      default: {
 | 
			
		||||
        return current;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleRotate = async () => {
 | 
			
		||||
    const current = Number(asset.exifInfo?.orientation);
 | 
			
		||||
    if (!current && current !== 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const orientation = counterclockwise
 | 
			
		||||
      ? getNextOrientation(getNextOrientation(getNextOrientation(current)))
 | 
			
		||||
      : getNextOrientation(current);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const data = await updateAsset({ id: asset.id, updateAssetDto: { orientation } });
 | 
			
		||||
 | 
			
		||||
      // TODO: remove if/when there is immediate UI feedback on image rotation (css animation)
 | 
			
		||||
      notificationController.show({ message: text, type: NotificationType.Info });
 | 
			
		||||
 | 
			
		||||
      onAction({ type: AssetAction.ROTATE, asset: data, counterclockwise });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      handleError(error, $t('errors.unable_to_rotate_image'));
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svelte:window use:shortcut={{ shortcut: { key: 'r', shift: counterclockwise }, onShortcut: handleRotate }} />
 | 
			
		||||
 | 
			
		||||
{#if menuItem}
 | 
			
		||||
  <MenuOption {icon} onClick={handleRotate} {text} />
 | 
			
		||||
{:else}
 | 
			
		||||
  <CircleIconButton
 | 
			
		||||
    color="opaque"
 | 
			
		||||
    icon={counterclockwise ? mdiRotateLeft : mdiRotateRight}
 | 
			
		||||
    title={text}
 | 
			
		||||
    onclick={handleRotate}
 | 
			
		||||
  />
 | 
			
		||||
{/if}
 | 
			
		||||
@ -7,14 +7,15 @@
 | 
			
		||||
  import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
 | 
			
		||||
  import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
 | 
			
		||||
  import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
 | 
			
		||||
  import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
 | 
			
		||||
  import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
 | 
			
		||||
  import RotateAction from '$lib/components/asset-viewer/actions/rotate-action.svelte';
 | 
			
		||||
  import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
 | 
			
		||||
  import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
 | 
			
		||||
  import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
 | 
			
		||||
  import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
 | 
			
		||||
  import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
 | 
			
		||||
  import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
 | 
			
		||||
  import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
 | 
			
		||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
			
		||||
  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
			
		||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
			
		||||
@ -22,6 +23,7 @@
 | 
			
		||||
  import { user } from '$lib/stores/user.store';
 | 
			
		||||
  import { photoZoomState } from '$lib/stores/zoom-image.store';
 | 
			
		||||
  import { getAssetJobName, getSharedLink } from '$lib/utils';
 | 
			
		||||
  import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
 | 
			
		||||
  import { openFileUploadDialog } from '$lib/utils/file-uploader';
 | 
			
		||||
  import {
 | 
			
		||||
    AssetJobName,
 | 
			
		||||
@ -45,9 +47,8 @@
 | 
			
		||||
    mdiPresentationPlay,
 | 
			
		||||
    mdiUpload,
 | 
			
		||||
  } from '@mdi/js';
 | 
			
		||||
  import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
  import type { Snippet } from 'svelte';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    asset: AssetResponseDto;
 | 
			
		||||
@ -87,6 +88,9 @@
 | 
			
		||||
  const sharedLink = getSharedLink();
 | 
			
		||||
  let isOwner = $derived($user && asset.ownerId === $user?.id);
 | 
			
		||||
  let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline);
 | 
			
		||||
  let canRotate = $derived(
 | 
			
		||||
    asset.type === AssetTypeEnum.Image && !asset.livePhotoVideoId && asset.exifInfo?.orientation !== undefined,
 | 
			
		||||
  );
 | 
			
		||||
  // $: showEditorButton =
 | 
			
		||||
  //   isOwner &&
 | 
			
		||||
  //   asset.type === AssetTypeEnum.Image &&
 | 
			
		||||
@ -180,6 +184,11 @@
 | 
			
		||||
            <SetProfilePictureAction {asset} />
 | 
			
		||||
          {/if}
 | 
			
		||||
          <ArchiveAction {asset} {onAction} />
 | 
			
		||||
 | 
			
		||||
          {#if canRotate}
 | 
			
		||||
            <RotateAction {asset} {onAction} counterclockwise menuItem />
 | 
			
		||||
            <RotateAction {asset} {onAction} menuItem />
 | 
			
		||||
          {/if}
 | 
			
		||||
          <MenuOption
 | 
			
		||||
            icon={mdiUpload}
 | 
			
		||||
            onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
 | 
			
		||||
 | 
			
		||||
@ -43,10 +43,10 @@
 | 
			
		||||
  import DetailPanel from './detail-panel.svelte';
 | 
			
		||||
  import CropArea from './editor/crop-tool/crop-area.svelte';
 | 
			
		||||
  import EditorPanel from './editor/editor-panel.svelte';
 | 
			
		||||
  import ImagePanoramaViewer from './image-panorama-viewer.svelte';
 | 
			
		||||
  import PhotoViewer from './photo-viewer.svelte';
 | 
			
		||||
  import SlideshowBar from './slideshow-bar.svelte';
 | 
			
		||||
  import VideoViewer from './video-wrapper-viewer.svelte';
 | 
			
		||||
  import ImagePanoramaViewer from './image-panorama-viewer.svelte';
 | 
			
		||||
 | 
			
		||||
  type HasAsset = boolean;
 | 
			
		||||
 | 
			
		||||
@ -190,7 +190,7 @@
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onAssetUpdate = (assetUpdate: AssetResponseDto) => {
 | 
			
		||||
  const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => {
 | 
			
		||||
    if (assetUpdate.id === asset.id) {
 | 
			
		||||
      asset = assetUpdate;
 | 
			
		||||
    }
 | 
			
		||||
@ -198,8 +198,8 @@
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    unsubscribes.push(
 | 
			
		||||
      websocketEvents.on('on_upload_success', onAssetUpdate),
 | 
			
		||||
      websocketEvents.on('on_asset_update', onAssetUpdate),
 | 
			
		||||
      websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
 | 
			
		||||
      websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
 | 
			
		||||
@ -377,6 +377,7 @@
 | 
			
		||||
      case AssetAction.KEEP_THIS_DELETE_OTHERS:
 | 
			
		||||
      case AssetAction.UNSTACK: {
 | 
			
		||||
        closeViewer();
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -483,7 +484,7 @@
 | 
			
		||||
        {:else}
 | 
			
		||||
          <VideoViewer
 | 
			
		||||
            assetId={previewStackedAsset.id}
 | 
			
		||||
            checksum={previewStackedAsset.checksum}
 | 
			
		||||
            cacheKey={previewStackedAsset.thumbhash}
 | 
			
		||||
            projectionType={previewStackedAsset.exifInfo?.projectionType}
 | 
			
		||||
            loopVideo={true}
 | 
			
		||||
            onPreviousAsset={() => navigateAsset('previous')}
 | 
			
		||||
@ -500,7 +501,7 @@
 | 
			
		||||
          {#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
 | 
			
		||||
            <VideoViewer
 | 
			
		||||
              assetId={asset.livePhotoVideoId}
 | 
			
		||||
              checksum={asset.checksum}
 | 
			
		||||
              cacheKey={asset.thumbhash}
 | 
			
		||||
              projectionType={asset.exifInfo?.projectionType}
 | 
			
		||||
              loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
 | 
			
		||||
              onPreviousAsset={() => navigateAsset('previous')}
 | 
			
		||||
@ -529,7 +530,7 @@
 | 
			
		||||
        {:else}
 | 
			
		||||
          <VideoViewer
 | 
			
		||||
            assetId={asset.id}
 | 
			
		||||
            checksum={asset.checksum}
 | 
			
		||||
            cacheKey={asset.thumbhash}
 | 
			
		||||
            projectionType={asset.exifInfo?.projectionType}
 | 
			
		||||
            loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
 | 
			
		||||
            onPreviousAsset={() => navigateAsset('previous')}
 | 
			
		||||
 | 
			
		||||
@ -50,7 +50,7 @@
 | 
			
		||||
    img = new Image();
 | 
			
		||||
    await tick();
 | 
			
		||||
 | 
			
		||||
    img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum });
 | 
			
		||||
    img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
 | 
			
		||||
 | 
			
		||||
    img.addEventListener('load', () => onImageLoad(true));
 | 
			
		||||
    img.addEventListener('error', (error) => {
 | 
			
		||||
 | 
			
		||||
@ -40,7 +40,7 @@ describe('PhotoViewer component', () => {
 | 
			
		||||
    expect(getAssetThumbnailUrlSpy).toBeCalledWith({
 | 
			
		||||
      id: asset.id,
 | 
			
		||||
      size: AssetMediaSize.Preview,
 | 
			
		||||
      checksum: asset.checksum,
 | 
			
		||||
      cacheKey: asset.thumbhash,
 | 
			
		||||
    });
 | 
			
		||||
    expect(getAssetOriginalUrlSpy).not.toBeCalled();
 | 
			
		||||
  });
 | 
			
		||||
@ -50,7 +50,7 @@ describe('PhotoViewer component', () => {
 | 
			
		||||
    render(PhotoViewer, { asset });
 | 
			
		||||
 | 
			
		||||
    expect(getAssetThumbnailUrlSpy).not.toBeCalled();
 | 
			
		||||
    expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum });
 | 
			
		||||
    expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('loads original for shared link when download permission is true and showMetadata permission is true', () => {
 | 
			
		||||
@ -59,7 +59,7 @@ describe('PhotoViewer component', () => {
 | 
			
		||||
    render(PhotoViewer, { asset, sharedLink });
 | 
			
		||||
 | 
			
		||||
    expect(getAssetThumbnailUrlSpy).not.toBeCalled();
 | 
			
		||||
    expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, checksum: asset.checksum });
 | 
			
		||||
    expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('not loads original image when shared link download permission is false', () => {
 | 
			
		||||
@ -70,7 +70,7 @@ describe('PhotoViewer component', () => {
 | 
			
		||||
    expect(getAssetThumbnailUrlSpy).toBeCalledWith({
 | 
			
		||||
      id: asset.id,
 | 
			
		||||
      size: AssetMediaSize.Preview,
 | 
			
		||||
      checksum: asset.checksum,
 | 
			
		||||
      cacheKey: asset.thumbhash,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(getAssetOriginalUrlSpy).not.toBeCalled();
 | 
			
		||||
@ -84,7 +84,7 @@ describe('PhotoViewer component', () => {
 | 
			
		||||
    expect(getAssetThumbnailUrlSpy).toBeCalledWith({
 | 
			
		||||
      id: asset.id,
 | 
			
		||||
      size: AssetMediaSize.Preview,
 | 
			
		||||
      checksum: asset.checksum,
 | 
			
		||||
      cacheKey: asset.thumbhash,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    expect(getAssetOriginalUrlSpy).not.toBeCalled();
 | 
			
		||||
 | 
			
		||||
@ -70,19 +70,19 @@
 | 
			
		||||
    for (const preloadAsset of preloadAssets || []) {
 | 
			
		||||
      if (preloadAsset.type === AssetTypeEnum.Image) {
 | 
			
		||||
        let img = new Image();
 | 
			
		||||
        img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.checksum);
 | 
			
		||||
        img.src = getAssetUrl(preloadAsset.id, useOriginal, preloadAsset.thumbhash);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getAssetUrl = (id: string, useOriginal: boolean, checksum: string) => {
 | 
			
		||||
  const getAssetUrl = (id: string, useOriginal: boolean, cacheKey: string | null) => {
 | 
			
		||||
    if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
 | 
			
		||||
      return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum });
 | 
			
		||||
      return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return useOriginal
 | 
			
		||||
      ? getAssetOriginalUrl({ id, checksum })
 | 
			
		||||
      : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, checksum });
 | 
			
		||||
      ? getAssetOriginalUrl({ id, cacheKey })
 | 
			
		||||
      : getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  copyImage = async () => {
 | 
			
		||||
@ -144,7 +144,7 @@
 | 
			
		||||
      loader?.removeEventListener('error', onerror);
 | 
			
		||||
    };
 | 
			
		||||
  });
 | 
			
		||||
  let isWebCompatible = $derived(isWebCompatibleImage(asset));
 | 
			
		||||
  let isWebCompatible = $derived(isWebCompatibleImage(asset) && !asset?.exifInfo?.orientation);
 | 
			
		||||
  let useOriginalByDefault = $derived(isWebCompatible && $alwaysLoadOriginalFile);
 | 
			
		||||
  // when true, will force loading of the original image
 | 
			
		||||
 | 
			
		||||
@ -158,7 +158,7 @@
 | 
			
		||||
    preload(useOriginalImage, preloadAssets);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.checksum));
 | 
			
		||||
  let imageLoaderUrl = $derived(getAssetUrl(asset.id, useOriginalImage, asset.thumbhash));
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<svelte:window
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@
 | 
			
		||||
  interface Props {
 | 
			
		||||
    assetId: string;
 | 
			
		||||
    loopVideo: boolean;
 | 
			
		||||
    checksum: string;
 | 
			
		||||
    cacheKey: string | null;
 | 
			
		||||
    onPreviousAsset?: () => void;
 | 
			
		||||
    onNextAsset?: () => void;
 | 
			
		||||
    onVideoEnded?: () => void;
 | 
			
		||||
@ -24,7 +24,7 @@
 | 
			
		||||
  let {
 | 
			
		||||
    assetId,
 | 
			
		||||
    loopVideo,
 | 
			
		||||
    checksum,
 | 
			
		||||
    cacheKey,
 | 
			
		||||
    onPreviousAsset = () => {},
 | 
			
		||||
    onNextAsset = () => {},
 | 
			
		||||
    onVideoEnded = () => {},
 | 
			
		||||
@ -39,7 +39,7 @@
 | 
			
		||||
 | 
			
		||||
  onMount(() => {
 | 
			
		||||
    if (videoPlayer) {
 | 
			
		||||
      assetFileUrl = getAssetPlaybackUrl({ id: assetId, checksum });
 | 
			
		||||
      assetFileUrl = getAssetPlaybackUrl({ id: assetId, cacheKey });
 | 
			
		||||
      forceMuted = false;
 | 
			
		||||
      videoPlayer.load();
 | 
			
		||||
    }
 | 
			
		||||
@ -106,7 +106,7 @@
 | 
			
		||||
    onclose={() => onClose()}
 | 
			
		||||
    muted={forceMuted || $videoViewerMuted}
 | 
			
		||||
    bind:volume={$videoViewerVolume}
 | 
			
		||||
    poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, checksum })}
 | 
			
		||||
    poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
 | 
			
		||||
    src={assetFileUrl}
 | 
			
		||||
  >
 | 
			
		||||
  </video>
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@
 | 
			
		||||
  interface Props {
 | 
			
		||||
    assetId: string;
 | 
			
		||||
    projectionType: string | null | undefined;
 | 
			
		||||
    checksum: string;
 | 
			
		||||
    cacheKey: string | null;
 | 
			
		||||
    loopVideo: boolean;
 | 
			
		||||
    onClose?: () => void;
 | 
			
		||||
    onPreviousAsset?: () => void;
 | 
			
		||||
@ -18,7 +18,7 @@
 | 
			
		||||
  let {
 | 
			
		||||
    assetId,
 | 
			
		||||
    projectionType,
 | 
			
		||||
    checksum,
 | 
			
		||||
    cacheKey,
 | 
			
		||||
    loopVideo,
 | 
			
		||||
    onPreviousAsset,
 | 
			
		||||
    onClose,
 | 
			
		||||
@ -33,7 +33,7 @@
 | 
			
		||||
{:else}
 | 
			
		||||
  <VideoNativeViewer
 | 
			
		||||
    {loopVideo}
 | 
			
		||||
    {checksum}
 | 
			
		||||
    {cacheKey}
 | 
			
		||||
    {assetId}
 | 
			
		||||
    {onPreviousAsset}
 | 
			
		||||
    {onNextAsset}
 | 
			
		||||
 | 
			
		||||
@ -327,7 +327,7 @@
 | 
			
		||||
        {/if}
 | 
			
		||||
 | 
			
		||||
        <ImageThumbnail
 | 
			
		||||
          url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, checksum: asset.checksum })}
 | 
			
		||||
          url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
 | 
			
		||||
          altText={$getAltText(asset)}
 | 
			
		||||
          widthStyle="{width}px"
 | 
			
		||||
          heightStyle="{height}px"
 | 
			
		||||
@ -339,7 +339,7 @@
 | 
			
		||||
          <div class="absolute top-0 h-full w-full">
 | 
			
		||||
            <VideoThumbnail
 | 
			
		||||
              {assetStore}
 | 
			
		||||
              url={getAssetPlaybackUrl({ id: asset.id, checksum: asset.checksum })}
 | 
			
		||||
              url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
 | 
			
		||||
              enablePlayback={mouseOver && $playVideoThumbnailOnHover}
 | 
			
		||||
              curve={selected}
 | 
			
		||||
              durationInSeconds={timeToSeconds(asset.duration)}
 | 
			
		||||
@ -352,7 +352,7 @@
 | 
			
		||||
          <div class="absolute top-0 h-full w-full">
 | 
			
		||||
            <VideoThumbnail
 | 
			
		||||
              {assetStore}
 | 
			
		||||
              url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, checksum: asset.checksum })}
 | 
			
		||||
              url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
 | 
			
		||||
              pauseIcon={mdiMotionPauseOutline}
 | 
			
		||||
              playIcon={mdiMotionPlayOutline}
 | 
			
		||||
              showTime={false}
 | 
			
		||||
 | 
			
		||||
@ -34,6 +34,8 @@
 | 
			
		||||
        { key: ['i'], action: $t('show_or_hide_info') },
 | 
			
		||||
        { key: ['s'], action: $t('stack_selected_photos') },
 | 
			
		||||
        { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
 | 
			
		||||
        { key: ['r'], action: $t('rotate_right') },
 | 
			
		||||
        { key: ['⇧', 'r'], action: $t('rotate_left') },
 | 
			
		||||
        { key: ['⇧', 'd'], action: $t('download') },
 | 
			
		||||
        { key: ['Space'], action: $t('play_or_pause_video') },
 | 
			
		||||
        { key: ['Del'], action: $t('trash_delete_asset'), info: $t('shift_to_permanent_delete') },
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,19 @@ export enum AssetAction {
 | 
			
		||||
  ADD_TO_ALBUM = 'add-to-album',
 | 
			
		||||
  UNSTACK = 'unstack',
 | 
			
		||||
  KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others',
 | 
			
		||||
  ROTATE = 'rotate',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// copied from the server because numeric enums lose their names
 | 
			
		||||
export enum ExifOrientation {
 | 
			
		||||
  Horizontal = 1,
 | 
			
		||||
  MirrorHorizontal = 2,
 | 
			
		||||
  Rotate180 = 3,
 | 
			
		||||
  MirrorVertical = 4,
 | 
			
		||||
  MirrorHorizontalRotate270CW = 5,
 | 
			
		||||
  Rotate90CW = 6,
 | 
			
		||||
  MirrorHorizontalRotate90CW = 7,
 | 
			
		||||
  Rotate270CW = 8,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum AppRoute {
 | 
			
		||||
 | 
			
		||||
@ -180,28 +180,30 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
 | 
			
		||||
  return getBaseUrl() + url.pathname + url.search + url.hash;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getAssetOriginalUrl = (options: string | { id: string; checksum?: string }) => {
 | 
			
		||||
type AssetUrlOptions = { id: string; cacheKey?: string | null };
 | 
			
		||||
 | 
			
		||||
export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
 | 
			
		||||
  if (typeof options === 'string') {
 | 
			
		||||
    options = { id: options };
 | 
			
		||||
  }
 | 
			
		||||
  const { id, checksum } = options;
 | 
			
		||||
  return createUrl(getAssetOriginalPath(id), { key: getKey(), c: checksum });
 | 
			
		||||
  const { id, cacheKey } = options;
 | 
			
		||||
  return createUrl(getAssetOriginalPath(id), { key: getKey(), c: cacheKey });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getAssetThumbnailUrl = (options: string | { id: string; size?: AssetMediaSize; checksum?: string }) => {
 | 
			
		||||
export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => {
 | 
			
		||||
  if (typeof options === 'string') {
 | 
			
		||||
    options = { id: options };
 | 
			
		||||
  }
 | 
			
		||||
  const { id, size, checksum } = options;
 | 
			
		||||
  return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: checksum });
 | 
			
		||||
  const { id, size, cacheKey } = options;
 | 
			
		||||
  return createUrl(getAssetThumbnailPath(id), { size, key: getKey(), c: cacheKey });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getAssetPlaybackUrl = (options: string | { id: string; checksum?: string }) => {
 | 
			
		||||
export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
 | 
			
		||||
  if (typeof options === 'string') {
 | 
			
		||||
    options = { id: options };
 | 
			
		||||
  }
 | 
			
		||||
  const { id, checksum } = options;
 | 
			
		||||
  return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: checksum });
 | 
			
		||||
  const { id, cacheKey } = options;
 | 
			
		||||
  return createUrl(getAssetPlaybackPath(id), { key: getKey(), c: cacheKey });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getProfileImageUrl = (user: UserResponseDto) =>
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user