Implemented EXIF store and display (#19)

* Added EXIF extracting in the backend
* Added EXIF displaying on `image_viewer_page.dart`
* Added Icon for backup option not enable
This commit is contained in:
Alex 2022-02-10 20:40:11 -06:00 committed by GitHub
parent d1498506a8
commit de1dbcea9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1092 additions and 847 deletions

View File

@ -0,0 +1,45 @@
import 'dart:convert';
class ImageViewerPageState {
final bool isBottomSheetEnable;
ImageViewerPageState({
required this.isBottomSheetEnable,
});
ImageViewerPageState copyWith({
bool? isBottomSheetEnable,
}) {
return ImageViewerPageState(
isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
);
}
Map<String, dynamic> toMap() {
return {
'isBottomSheetEnable': isBottomSheetEnable,
};
}
factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
return ImageViewerPageState(
isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
@override
String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
}
@override
int get hashCode => isBottomSheetEnable.hashCode;
}

View File

@ -0,0 +1,21 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/home/models/home_page_state.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
void toggleBottomSheet() {
bool isBottomSheetEnable = state.isBottomSheetEnable;
if (isBottomSheetEnable) {
state.copyWith(isBottomSheetEnable: false);
} else {
state.copyWith(isBottomSheetEnable: true);
}
}
}
final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
((ref) => ImageViewerPageStateNotifier()));

View File

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
class ExifBottomSheet extends ConsumerWidget {
final ImmichAssetWithExif assetDetail;
const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView(
children: [
assetDetail.exifInfo?.dateTimeOriginal != null
? Text(
DateFormat('E, LLL d, y • h:mm a').format(
DateTime.parse(assetDetail.exifInfo!.dateTimeOriginal!),
),
style: TextStyle(
color: Colors.grey[400],
fontWeight: FontWeight.bold,
fontSize: 14,
),
)
: Container(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
"Add Description...",
style: TextStyle(
color: Colors.grey[500],
fontSize: 11,
),
),
),
// Location
assetDetail.exifInfo?.latitude != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Text(
"LOCATION",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
Text(
"${assetDetail.exifInfo!.latitude!.toStringAsFixed(4)}, ${assetDetail.exifInfo!.longitude!.toStringAsFixed(4)}",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
)
],
),
)
: Container(),
// Detail
assetDetail.exifInfo != null
? Padding(
padding: const EdgeInsets.only(top: 32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(
thickness: 1,
color: Colors.grey[600],
),
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"DETAILS",
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
),
),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.image),
title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"${assetDetail.exifInfo?.exifImageHeight!} x ${assetDetail.exifInfo?.exifImageWidth!} ${assetDetail.exifInfo?.fileSizeInByte!}B "),
),
assetDetail.exifInfo?.make != null
? ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
textColor: Colors.grey[300],
iconColor: Colors.grey[300],
leading: const Icon(Icons.camera),
title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
"ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / assetDetail.exifInfo!.exposureTime!).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} "),
)
: Container()
],
),
)
: Container()
],
),
);
}
}

View File

@ -0,0 +1,57 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
final ImmichAsset asset;
final Function onMoreInfoPressed;
@override
Widget build(BuildContext context) {
double iconSize = 18.0;
return AppBar(
foregroundColor: Colors.grey[100],
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
),
),
actions: [
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("backup");
},
icon: const Icon(Icons.backup_outlined),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
print("favorite");
},
icon: asset.isFavorite ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_border_rounded),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onMoreInfoPressed();
},
icon: const Icon(Icons.more_horiz_rounded))
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@ -0,0 +1,85 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:photo_view/photo_view.dart';
// ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
final ImmichAsset asset;
final AssetService _assetService = AssetService();
ImmichAssetWithExif? assetDetail;
ImageViewerPage(
{Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
getAssetExif() async {
assetDetail = await _assetService.getAssetById(asset.id);
}
useEffect(() {
getAssetExif();
}, []);
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
asset: asset,
onMoreInfoPressed: () {
showModalBottomSheet(
backgroundColor: Colors.black,
barrierColor: Colors.transparent,
isScrollControlled: false,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
});
},
),
body: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => const Icon(Icons.error),
imageBuilder: (context, imageProvider) {
return PhotoView(imageProvider: imageProvider);
},
placeholder: (context, url) {
return CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
},
),
),
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart'; import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
class AssetService { class AssetService {
@ -58,4 +59,21 @@ class AssetService {
return []; return [];
} }
} }
Future<ImmichAssetWithExif?> getAssetById(String assetId) async {
try {
var res = await _networkService.getRequest(
url: "asset/assetById/$assetId",
);
Map<String, dynamic> decodedData = jsonDecode(res.toString());
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
print("result $result");
return result;
} catch (e) {
debugPrint("Error getAllAsset ${e.toString()}");
return null;
}
}
} }

View File

@ -1,7 +1,10 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:badges/badges.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/shared/models/backup_state.model.dart';
@ -20,10 +23,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final BackUpState _backupState = ref.watch(backupProvider); final BackUpState _backupState = ref.watch(backupProvider);
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
return SliverPadding( return SliverAppBar(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
sliver: SliverAppBar(
centerTitle: true, centerTitle: true,
floating: true, floating: true,
pinned: false, pinned: false,
@ -70,7 +71,20 @@ class ImmichSliverAppBar extends ConsumerWidget {
) )
: Container(), : Container(),
IconButton( IconButton(
icon: const Icon(Icons.backup_rounded), splashRadius: 25,
iconSize: 30,
icon: _isEnableAutoBackup
? const Icon(Icons.backup_rounded)
: Badge(
padding: const EdgeInsets.all(4),
elevation: 1,
position: BadgePosition.bottomEnd(bottom: -4, end: -4),
badgeColor: Colors.white,
badgeContent: const Icon(
Icons.cloud_off_rounded,
size: 8,
),
child: const Icon(Icons.backup_rounded)),
tooltip: 'Backup Controller', tooltip: 'Backup Controller',
onPressed: () async { onPressed: () async {
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute()); var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
@ -92,7 +106,6 @@ class ImmichSliverAppBar extends ConsumerWidget {
], ],
), ),
], ],
),
); );
} }
} }

View File

@ -56,6 +56,7 @@ class ThumbnailImage extends HookConsumerWidget {
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false', '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
heroTag: asset.id, heroTag: asset.id,
thumbnailUrl: thumbnailRequestUrl, thumbnailUrl: thumbnailRequestUrl,
asset: asset,
), ),
); );
} else { } else {

View File

@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(text: 'testuser@email.com'); final usernameController = useTextEditingController(text: 'testuser@email.com');
final passwordController = useTextEditingController(text: 'password'); final passwordController = useTextEditingController(text: 'password');
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283'); final serverEndpointController = useTextEditingController(text: 'http://192.168.1.204:2283');
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(

View File

@ -1,7 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/login/ui/login_form.dart'; import 'package:immich_mobile/modules/login/ui/login_form.dart';
class LoginPage extends HookConsumerWidget { class LoginPage extends HookConsumerWidget {

View File

@ -3,8 +3,9 @@ import 'package:flutter/widgets.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/shared/views/backup_controller_page.dart';
import 'package:immich_mobile/shared/views/image_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/views/video_viewer_page.dart'; import 'package:immich_mobile/shared/views/video_viewer_page.dart';
part 'router.gr.dart'; part 'router.gr.dart';

View File

@ -41,7 +41,8 @@ class _$AppRouter extends RootStackRouter {
key: args.key, key: args.key,
imageUrl: args.imageUrl, imageUrl: args.imageUrl,
heroTag: args.heroTag, heroTag: args.heroTag,
thumbnailUrl: args.thumbnailUrl)); thumbnailUrl: args.thumbnailUrl,
asset: args.asset));
}, },
VideoViewerRoute.name: (routeData) { VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>(); final args = routeData.argsAs<VideoViewerRouteArgs>();
@ -96,14 +97,16 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
{Key? key, {Key? key,
required String imageUrl, required String imageUrl,
required String heroTag, required String heroTag,
required String thumbnailUrl}) required String thumbnailUrl,
required ImmichAsset asset})
: super(ImageViewerRoute.name, : super(ImageViewerRoute.name,
path: '/image-viewer-page', path: '/image-viewer-page',
args: ImageViewerRouteArgs( args: ImageViewerRouteArgs(
key: key, key: key,
imageUrl: imageUrl, imageUrl: imageUrl,
heroTag: heroTag, heroTag: heroTag,
thumbnailUrl: thumbnailUrl)); thumbnailUrl: thumbnailUrl,
asset: asset));
static const String name = 'ImageViewerRoute'; static const String name = 'ImageViewerRoute';
} }
@ -113,7 +116,8 @@ class ImageViewerRouteArgs {
{this.key, {this.key,
required this.imageUrl, required this.imageUrl,
required this.heroTag, required this.heroTag,
required this.thumbnailUrl}); required this.thumbnailUrl,
required this.asset});
final Key? key; final Key? key;
@ -123,9 +127,11 @@ class ImageViewerRouteArgs {
final String thumbnailUrl; final String thumbnailUrl;
final ImmichAsset asset;
@override @override
String toString() { String toString() {
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}'; return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
} }
} }

View File

@ -0,0 +1,187 @@
import 'dart:convert';
class ImmichExif {
final int? id;
final String? assetId;
final String? make;
final String? model;
final String? imageName;
final int? exifImageWidth;
final int? exifImageHeight;
final int? fileSizeInByte;
final String? orientation;
final String? dateTimeOriginal;
final String? modifyDate;
final String? lensModel;
final double? fNumber;
final double? focalLength;
final int? iso;
final double? exposureTime;
final double? latitude;
final double? longitude;
ImmichExif({
this.id,
this.assetId,
this.make,
this.model,
this.imageName,
this.exifImageWidth,
this.exifImageHeight,
this.fileSizeInByte,
this.orientation,
this.dateTimeOriginal,
this.modifyDate,
this.lensModel,
this.fNumber,
this.focalLength,
this.iso,
this.exposureTime,
this.latitude,
this.longitude,
});
ImmichExif copyWith({
int? id,
String? assetId,
String? make,
String? model,
String? imageName,
int? exifImageWidth,
int? exifImageHeight,
int? fileSizeInByte,
String? orientation,
String? dateTimeOriginal,
String? modifyDate,
String? lensModel,
double? fNumber,
double? focalLength,
int? iso,
double? exposureTime,
double? latitude,
double? longitude,
}) {
return ImmichExif(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
make: make ?? this.make,
model: model ?? this.model,
imageName: imageName ?? this.imageName,
exifImageWidth: exifImageWidth ?? this.exifImageWidth,
exifImageHeight: exifImageHeight ?? this.exifImageHeight,
fileSizeInByte: fileSizeInByte ?? this.fileSizeInByte,
orientation: orientation ?? this.orientation,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
modifyDate: modifyDate ?? this.modifyDate,
lensModel: lensModel ?? this.lensModel,
fNumber: fNumber ?? this.fNumber,
focalLength: focalLength ?? this.focalLength,
iso: iso ?? this.iso,
exposureTime: exposureTime ?? this.exposureTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'assetId': assetId,
'make': make,
'model': model,
'imageName': imageName,
'exifImageWidth': exifImageWidth,
'exifImageHeight': exifImageHeight,
'fileSizeInByte': fileSizeInByte,
'orientation': orientation,
'dateTimeOriginal': dateTimeOriginal,
'modifyDate': modifyDate,
'lensModel': lensModel,
'fNumber': fNumber,
'focalLength': focalLength,
'iso': iso,
'exposureTime': exposureTime,
'latitude': latitude,
'longitude': longitude,
};
}
factory ImmichExif.fromMap(Map<String, dynamic> map) {
return ImmichExif(
id: map['id']?.toInt(),
assetId: map['assetId'],
make: map['make'],
model: map['model'],
imageName: map['imageName'],
exifImageWidth: map['exifImageWidth']?.toInt(),
exifImageHeight: map['exifImageHeight']?.toInt(),
fileSizeInByte: map['fileSizeInByte']?.toInt(),
orientation: map['orientation'],
dateTimeOriginal: map['dateTimeOriginal'],
modifyDate: map['modifyDate'],
lensModel: map['lensModel'],
fNumber: map['fNumber']?.toDouble(),
focalLength: map['focalLength']?.toDouble(),
iso: map['iso']?.toInt(),
exposureTime: map['exposureTime']?.toDouble(),
latitude: map['latitude']?.toDouble(),
longitude: map['longitude']?.toDouble(),
);
}
String toJson() => json.encode(toMap());
factory ImmichExif.fromJson(String source) => ImmichExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichExif(id: $id, assetId: $assetId, make: $make, model: $model, imageName: $imageName, exifImageWidth: $exifImageWidth, exifImageHeight: $exifImageHeight, fileSizeInByte: $fileSizeInByte, orientation: $orientation, dateTimeOriginal: $dateTimeOriginal, modifyDate: $modifyDate, lensModel: $lensModel, fNumber: $fNumber, focalLength: $focalLength, iso: $iso, exposureTime: $exposureTime, latitude: $latitude, longitude: $longitude)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichExif &&
other.id == id &&
other.assetId == assetId &&
other.make == make &&
other.model == model &&
other.imageName == imageName &&
other.exifImageWidth == exifImageWidth &&
other.exifImageHeight == exifImageHeight &&
other.fileSizeInByte == fileSizeInByte &&
other.orientation == orientation &&
other.dateTimeOriginal == dateTimeOriginal &&
other.modifyDate == modifyDate &&
other.lensModel == lensModel &&
other.fNumber == fNumber &&
other.focalLength == focalLength &&
other.iso == iso &&
other.exposureTime == exposureTime &&
other.latitude == latitude &&
other.longitude == longitude;
}
@override
int get hashCode {
return id.hashCode ^
assetId.hashCode ^
make.hashCode ^
model.hashCode ^
imageName.hashCode ^
exifImageWidth.hashCode ^
exifImageHeight.hashCode ^
fileSizeInByte.hashCode ^
orientation.hashCode ^
dateTimeOriginal.hashCode ^
modifyDate.hashCode ^
lensModel.hashCode ^
fNumber.hashCode ^
focalLength.hashCode ^
iso.hashCode ^
exposureTime.hashCode ^
latitude.hashCode ^
longitude.hashCode;
}
}

View File

@ -0,0 +1,133 @@
import 'dart:convert';
import 'package:immich_mobile/shared/models/exif.model.dart';
class ImmichAssetWithExif {
final String id;
final String deviceAssetId;
final String userId;
final String deviceId;
final String type;
final String createdAt;
final String modifiedAt;
final String originalPath;
final bool isFavorite;
final String? duration;
final ImmichExif? exifInfo;
ImmichAssetWithExif({
required this.id,
required this.deviceAssetId,
required this.userId,
required this.deviceId,
required this.type,
required this.createdAt,
required this.modifiedAt,
required this.originalPath,
required this.isFavorite,
this.duration,
this.exifInfo,
});
ImmichAssetWithExif copyWith({
String? id,
String? deviceAssetId,
String? userId,
String? deviceId,
String? type,
String? createdAt,
String? modifiedAt,
String? originalPath,
bool? isFavorite,
String? duration,
ImmichExif? exifInfo,
}) {
return ImmichAssetWithExif(
id: id ?? this.id,
deviceAssetId: deviceAssetId ?? this.deviceAssetId,
userId: userId ?? this.userId,
deviceId: deviceId ?? this.deviceId,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
modifiedAt: modifiedAt ?? this.modifiedAt,
originalPath: originalPath ?? this.originalPath,
isFavorite: isFavorite ?? this.isFavorite,
duration: duration ?? this.duration,
exifInfo: exifInfo ?? this.exifInfo,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'deviceAssetId': deviceAssetId,
'userId': userId,
'deviceId': deviceId,
'type': type,
'createdAt': createdAt,
'modifiedAt': modifiedAt,
'originalPath': originalPath,
'isFavorite': isFavorite,
'duration': duration,
'exifInfo': exifInfo?.toMap(),
};
}
factory ImmichAssetWithExif.fromMap(Map<String, dynamic> map) {
return ImmichAssetWithExif(
id: map['id'] ?? '',
deviceAssetId: map['deviceAssetId'] ?? '',
userId: map['userId'] ?? '',
deviceId: map['deviceId'] ?? '',
type: map['type'] ?? '',
createdAt: map['createdAt'] ?? '',
modifiedAt: map['modifiedAt'] ?? '',
originalPath: map['originalPath'] ?? '',
isFavorite: map['isFavorite'] ?? false,
duration: map['duration'],
exifInfo: map['exifInfo'] != null ? ImmichExif.fromMap(map['exifInfo']) : null,
);
}
String toJson() => json.encode(toMap());
factory ImmichAssetWithExif.fromJson(String source) => ImmichAssetWithExif.fromMap(json.decode(source));
@override
String toString() {
return 'ImmichAssetWithExif(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, originalPath: $originalPath, isFavorite: $isFavorite, duration: $duration, exifInfo: $exifInfo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetWithExif &&
other.id == id &&
other.deviceAssetId == deviceAssetId &&
other.userId == userId &&
other.deviceId == deviceId &&
other.type == type &&
other.createdAt == createdAt &&
other.modifiedAt == modifiedAt &&
other.originalPath == originalPath &&
other.isFavorite == isFavorite &&
other.duration == duration &&
other.exifInfo == exifInfo;
}
@override
int get hashCode {
return id.hashCode ^
deviceAssetId.hashCode ^
userId.hashCode ^
deviceId.hashCode ^
type.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
originalPath.hashCode ^
isFavorite.hashCode ^
duration.hashCode ^
exifInfo.hashCode;
}
}

View File

@ -1,64 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
class ImageViewerPage extends StatelessWidget {
final String imageUrl;
final String heroTag;
final String thumbnailUrl;
const ImageViewerPage({Key? key, required this.imageUrl, required this.heroTag, required this.thumbnailUrl})
: super(key: key);
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
toolbarHeight: 60,
backgroundColor: Colors.black,
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(Icons.arrow_back_ios)),
),
body: Dismissible(
direction: DismissDirection.vertical,
onDismissed: (_) {
AutoRouter.of(context).pop();
},
key: Key(heroTag),
child: Center(
child: Hero(
tag: heroTag,
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: imageUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
errorWidget: (context, url, error) => const Icon(Icons.error),
placeholder: (context, url) {
return CachedNetworkImage(
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
placeholderFadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) => const Icon(Icons.error),
);
},
),
),
),
),
);
}
}

View File

@ -2,7 +2,7 @@ build:
flutter packages pub run build_runner build flutter packages pub run build_runner build
watch: watch:
flutter packages pub run build_runner watch flutter packages pub run build_runner watch --delete-conflicting-outputs
create_app_icon: create_app_icon:
flutter pub run flutter_launcher_icons:main flutter pub run flutter_launcher_icons:main

View File

@ -50,6 +50,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.1" version: "3.2.1"
badges:
dependency: "direct main"
description:
name: badges
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -513,13 +520,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.12.11" version: "0.12.11"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.3"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -639,6 +639,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.10" version: "1.3.10"
photo_view:
dependency: "direct main"
description:
name: photo_view
url: "https://pub.dartlang.org"
source: hosted
version: "0.13.0"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -825,7 +832,7 @@ packages:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.8" version: "0.4.3"
timing: timing:
dependency: transitive dependency: transitive
description: description:

View File

@ -31,6 +31,8 @@ dependencies:
video_player: ^2.2.18 video_player: ^2.2.18
chewie: ^1.2.2 chewie: ^1.2.2
sliver_tools: ^0.2.5 sliver_tools: ^0.2.5
badges: ^2.0.2
photo_view: ^0.13.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -17,35 +17,25 @@ COPY . .
RUN npm run build RUN npm run build
################################## #################################
# PRODUCTION # PRODUCTION
################################## #################################
# FROM node:16-bullseye-slim as production FROM node:16-alpine3.14 AS production
# ARG DEBIAN_FRONTEND=noninteractive
# ARG NODE_ENV=production
# ENV NODE_ENV=${NODE_ENV}
# WORKDIR /usr/src/app ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
# COPY package.json yarn.lock ./ WORKDIR /usr/src/app
# RUN apt-get update COPY package.json package-lock.json ./
# RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
# RUN npm i -g yarn --force RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
# RUN yarn install --only=production RUN npm install --only=production
# COPY . . COPY . .
# COPY --from=development /usr/src/app/dist ./dist COPY --from=development /usr/src/app/dist ./dist
# # Clean up commands CMD ["node", "dist/main"]
# RUN apt-get autoremove -y && apt-get clean && \
# rm -rf /usr/local/src/*
# RUN apt-get clean && \
# rm -rf /var/lib/apt/lists/*
# CMD ["node", "dist/main"]

692
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -30,6 +30,7 @@ import { promisify } from 'util';
import { stat } from 'fs'; import { stat } from 'fs';
import { pipeline } from 'stream'; import { pipeline } from 'stream';
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto'; import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@ -37,8 +38,9 @@ const fileInfo = promisify(stat);
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
constructor( constructor(
private readonly assetService: AssetService, private assetService: AssetService,
private readonly assetOptimizeService: AssetOptimizeService, private assetOptimizeService: AssetOptimizeService,
private backgroundTaskService: BackgroundTaskService,
) {} ) {}
@Post('upload') @Post('upload')
@ -53,6 +55,7 @@ export class AssetController {
if (savedAsset && savedAsset.type == AssetType.IMAGE) { if (savedAsset && savedAsset.type == AssetType.IMAGE) {
await this.assetOptimizeService.resizeImage(savedAsset); await this.assetOptimizeService.resizeImage(savedAsset);
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
} }
if (savedAsset && savedAsset.type == AssetType.VIDEO) { if (savedAsset && savedAsset.type == AssetType.VIDEO) {
@ -155,4 +158,9 @@ export class AssetController {
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) { async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId); return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
} }
@Get('/assetById/:assetId')
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
return this.assetService.getAssetById(authUser, assetId);
}
} }

View File

@ -6,6 +6,8 @@ import { AssetEntity } from './entities/asset.entity';
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module'; import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service'; import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
@Module({ @Module({
imports: [ imports: [
@ -17,11 +19,20 @@ import { BullModule } from '@nestjs/bull';
removeOnFail: false, removeOnFail: false,
}, },
}), }),
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity]), TypeOrmModule.forFeature([AssetEntity]),
ImageOptimizeModule, ImageOptimizeModule,
BackgroundTaskModule,
], ],
controllers: [AssetController], controllers: [AssetController],
providers: [AssetService, AssetOptimizeService], providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
exports: [], exports: [],
}) })
export class AssetModule {} export class AssetModule {}

View File

@ -112,4 +112,14 @@ export class AssetService {
}, },
}); });
} }
public async getAssetById(authUser: AuthUserDto, assetId: string) {
return await this.assetRepository.findOne({
where: {
userId: authUser.id,
id: assetId,
},
relations: ['exifInfo'],
});
}
} }

View File

@ -0,0 +1,48 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class CreateExifDto {
@IsNotEmpty()
assetId: string;
@IsOptional()
make: string;
@IsOptional()
model: string;
@IsOptional()
imageName: string;
@IsOptional()
exifImageWidth: number;
@IsOptional()
exifImageHeight: number;
@IsOptional()
fileSizeInByte: number;
@IsOptional()
orientation: string;
@IsOptional()
dateTimeOriginal: Date;
@IsOptional()
modifiedDate: Date;
@IsOptional()
lensModel: string;
@IsOptional()
fNumber: number;
@IsOptional()
focalLenght: number;
@IsOptional()
iso: number;
@IsOptional()
exposureTime: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExifDto } from './create-exif.dto';
export class UpdateExifDto extends PartialType(CreateExifDto) {}

View File

@ -1,4 +1,5 @@
import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { ExifEntity } from './exif.entity';
@Entity('assets') @Entity('assets')
@Unique(['deviceAssetId', 'userId', 'deviceId']) @Unique(['deviceAssetId', 'userId', 'deviceId'])
@ -38,6 +39,9 @@ export class AssetEntity {
@Column({ nullable: true }) @Column({ nullable: true })
duration: string; duration: string;
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo: ExifEntity;
} }
export enum AssetType { export enum AssetType {

View File

@ -0,0 +1,67 @@
import { Index, JoinColumn, OneToOne } from 'typeorm';
import { Column } from 'typeorm/decorator/columns/Column';
import { PrimaryGeneratedColumn } from 'typeorm/decorator/columns/PrimaryGeneratedColumn';
import { Entity } from 'typeorm/decorator/entity/Entity';
import { AssetEntity } from './asset.entity';
@Entity('exif')
export class ExifEntity {
@PrimaryGeneratedColumn()
id: string;
@Index({ unique: true })
@Column({ type: 'uuid' })
assetId: string;
@Column({ nullable: true })
make: string;
@Column({ nullable: true })
model: string;
@Column({ nullable: true })
imageName: string;
@Column({ nullable: true })
exifImageWidth: number;
@Column({ nullable: true })
exifImageHeight: number;
@Column({ nullable: true })
fileSizeInByte: number;
@Column({ nullable: true })
orientation: string;
@Column({ type: 'timestamptz', nullable: true })
dateTimeOriginal: Date;
@Column({ type: 'timestamptz', nullable: true })
modifyDate: Date;
@Column({ nullable: true })
lensModel: string;
@Column({ type: 'float8', nullable: true })
fNumber: number;
@Column({ type: 'float8', nullable: true })
focalLength: number;
@Column({ nullable: true })
iso: number;
@Column({ type: 'float', nullable: true })
exposureTime: number;
@Column({ type: 'float', nullable: true })
latitude: number;
@Column({ type: 'float', nullable: true })
longitude: number;
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset: ExifEntity;
}

View File

@ -13,6 +13,7 @@ import { immichAppConfig } from './config/app.config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module'; import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
import { ServerInfoModule } from './api-v1/server-info/server-info.module'; import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
@Module({ @Module({
imports: [ imports: [
@ -29,7 +30,6 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
redis: { redis: {
host: 'immich_redis', host: 'immich_redis',
port: 6379, port: 6379,
// password: configService.get('REDIS_PASSWORD'),
}, },
}), }),
inject: [ConfigService], inject: [ConfigService],
@ -38,6 +38,8 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
ImageOptimizeModule, ImageOptimizeModule,
ServerInfoModule, ServerInfoModule,
BackgroundTaskModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],

View File

@ -0,0 +1,24 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
import { BackgroundTaskProcessor } from './background-task.processor';
import { BackgroundTaskService } from './background-task.service';
@Module({
imports: [
BullModule.registerQueue({
name: 'background-task',
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
],
providers: [BackgroundTaskService, BackgroundTaskProcessor],
exports: [BackgroundTaskService],
})
export class BackgroundTaskModule {}

View File

@ -0,0 +1,59 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bull';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import { ConfigService } from '@nestjs/config';
import exifr from 'exifr';
import { readFile } from 'fs/promises';
import { Logger } from '@nestjs/common';
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
@Processor('background-task')
export class BackgroundTaskProcessor {
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
private configService: ConfigService,
) {}
@Process('extract-exif')
async extractExif(job: Job) {
const { savedAsset, fileName, fileSize }: { savedAsset: AssetEntity; fileName: string; fileSize: number } =
job.data;
const fileBuffer = await readFile(savedAsset.originalPath);
const exifData = await exifr.parse(fileBuffer);
const newExif = new ExifEntity();
newExif.assetId = savedAsset.id;
newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null;
newExif.imageName = fileName || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null;
newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
newExif.modifyDate = exifData['ModifyDate'] || null;
newExif.lensModel = exifData['LensModel'] || null;
newExif.fNumber = exifData['FNumber'] || null;
newExif.focalLength = exifData['FocalLength'] || null;
newExif.iso = exifData['ISO'] || null;
newExif.exposureTime = exifData['ExposureTime'] || null;
newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null;
await this.exifRepository.save(newExif);
try {
} catch (e) {
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
}
}
}

View File

@ -0,0 +1,25 @@
import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { randomUUID } from 'node:crypto';
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
@Injectable()
export class BackgroundTaskService {
constructor(
@InjectQueue('background-task')
private backgroundTaskQueue: Queue,
) {}
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
const job = await this.backgroundTaskQueue.add(
'extract-exif',
{
savedAsset,
fileName,
fileSize,
},
{ jobId: randomUUID() },
);
}
}

View File

@ -22,7 +22,7 @@ export class AssetOptimizeService {
}; };
} }
public async getVideoThumbnail(savedAsset: AssetEntity, filename: String) { public async getVideoThumbnail(savedAsset: AssetEntity, filename: string) {
const job = await this.optimizeQueue.add( const job = await this.optimizeQueue.add(
'get-video-thumbnail', 'get-video-thumbnail',
{ {