mirror of
https://github.com/immich-app/immich.git
synced 2026-02-05 17:30:25 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f47c8c06b | ||
|
|
de1dbcea9c | ||
|
|
d1498506a8 | ||
|
|
9bcbdd31ce | ||
|
|
38c968d47e | ||
|
|
f578ca6d47 |
2
.github/workflows/Build+push Immich.yml
vendored
2
.github/workflows/Build+push Immich.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||
platforms: linux/arm/v7,linux/amd64
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: |
|
||||
|
||||
10
README.md
10
README.md
@@ -22,10 +22,10 @@ Loading ~4000 images/videos
|
||||
|
||||
# Note
|
||||
|
||||
This project is under heavy development, there will be continous functions, features and api changes.
|
||||
|
||||
**!! NOT READY FOR PRODUCTION! DO NOT USE TO STORE YOUR ASSETS !!**
|
||||
|
||||
This project is under heavy development, there will be continous functions, features and api changes.
|
||||
|
||||
# Features
|
||||
|
||||
[x] Upload assets(videos/images)
|
||||
@@ -36,11 +36,15 @@ This project is under heavy development, there will be continous functions, feat
|
||||
|
||||
[x] Auto Backup
|
||||
|
||||
[x] Support HEIC/HEIF Backup
|
||||
|
||||
[x] Extract and display EXIF info
|
||||
|
||||
# Development
|
||||
|
||||
You can use docker compose for development, there are several services that compose Immich
|
||||
|
||||
1. The server
|
||||
1. NestJs
|
||||
2. PostgreSQL
|
||||
3. Redis
|
||||
4. Nginx
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()));
|
||||
118
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
Normal file
118
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
Normal 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()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
Normal file
57
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
Normal 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);
|
||||
}
|
||||
85
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
Normal file
85
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> {
|
||||
bool isFetching = false;
|
||||
|
||||
// Get All assets
|
||||
getImmichAssets() async {
|
||||
getAllAssets() async {
|
||||
GetAllAssetResponse? res = await _assetService.getAllAsset();
|
||||
nextPageKey = res?.nextPageKey;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.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_with_exif.model.dart';
|
||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||
|
||||
class AssetService {
|
||||
@@ -58,4 +59,21 @@ class AssetService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
75
mobile/lib/modules/home/ui/control_bottom_app_bar.dart
Normal file
75
mobile/lib/modules/home/ui/control_bottom_app_bar.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
|
||||
|
||||
class ControlBottomAppBar extends StatelessWidget {
|
||||
const ControlBottomAppBar({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: MediaQuery.of(context).size.height * 0.15,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
|
||||
color: Colors.grey[300]?.withOpacity(0.98),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ControlBoxButton(
|
||||
iconData: Icons.delete_forever_rounded,
|
||||
label: "Delete",
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return const DeleteDialog();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ControlBoxButton extends StatelessWidget {
|
||||
const ControlBoxButton({Key? key, required this.label, required this.iconData, required this.onPressed})
|
||||
: super(key: key);
|
||||
|
||||
final String label;
|
||||
final IconData iconData;
|
||||
final Function onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 60,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
onPressed();
|
||||
},
|
||||
icon: Icon(iconData, size: 30),
|
||||
),
|
||||
Text(label)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,31 @@ class DailyTitleText extends ConsumerWidget {
|
||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
|
||||
|
||||
void _handleTitleIconClick() {
|
||||
if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedDateGroup.length == 1 &&
|
||||
selectedItems.length <= assetGroup.length) {
|
||||
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
|
||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||
} else if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedItems.length != assetGroup.length) {
|
||||
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
|
||||
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
|
||||
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable && selectedDateGroup.contains(dateText) && selectedDateGroup.length > 1) {
|
||||
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
|
||||
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
|
||||
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
|
||||
ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup);
|
||||
} else {
|
||||
ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet());
|
||||
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
|
||||
}
|
||||
}
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 29.0, bottom: 29.0, left: 12.0, right: 12.0),
|
||||
@@ -39,33 +64,16 @@ class DailyTitleText extends ConsumerWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedDateGroup.length == 1 &&
|
||||
selectedItems.length == assetGroup.length) {
|
||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||
} else if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedItems.length != assetGroup.length) {
|
||||
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
|
||||
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedDateGroup.length > 1) {
|
||||
ref.watch(homePageStateProvider.notifier).removeSelectedDateGroup(dateText);
|
||||
ref.watch(homePageStateProvider.notifier).removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
|
||||
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
|
||||
ref.watch(homePageStateProvider.notifier).addMultipleSelectedItems(assetGroup);
|
||||
} else {
|
||||
ref.watch(homePageStateProvider.notifier).enableMultiSelect(assetGroup.toSet());
|
||||
ref.watch(homePageStateProvider.notifier).addSelectedDateGroup(dateText);
|
||||
}
|
||||
},
|
||||
onTap: _handleTitleIconClick,
|
||||
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
|
||||
? const Icon(Icons.check_circle_rounded)
|
||||
: const Icon(Icons.check_circle_outline_rounded),
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
33
mobile/lib/modules/home/ui/delete_diaglog.dart
Normal file
33
mobile/lib/modules/home/ui/delete_diaglog.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DeleteDialog extends StatelessWidget {
|
||||
const DeleteDialog({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Colors.grey[200],
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
title: const Text("Delete Permanently"),
|
||||
content: const Text("These items will be permanently deleted from Immich and from your device"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
"Cancel",
|
||||
style: TextStyle(color: Colors.blueGrey),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: Text(
|
||||
"Delete",
|
||||
style: TextStyle(color: Colors.red[400]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
47
mobile/lib/modules/home/ui/disable_multi_select_button.dart
Normal file
47
mobile/lib/modules/home/ui/disable_multi_select_button.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
|
||||
class DisableMultiSelectButton extends ConsumerWidget {
|
||||
const DisableMultiSelectButton({
|
||||
Key? key,
|
||||
required this.onPressed,
|
||||
required this.selectedItemCount,
|
||||
}) : super(key: key);
|
||||
|
||||
final Function onPressed;
|
||||
final int selectedItemCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 46),
|
||||
child: Material(
|
||||
elevation: 20,
|
||||
borderRadius: BorderRadius.circular(35),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(35),
|
||||
color: Colors.grey[100],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
onPressed();
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: Text(
|
||||
selectedItemCount.toString(),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.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/shared/models/backup_state.model.dart';
|
||||
@@ -20,79 +23,89 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final BackUpState _backupState = ref.watch(backupProvider);
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
|
||||
sliver: SliverAppBar(
|
||||
centerTitle: true,
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
backgroundColor: Colors.grey[200],
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
leading: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.account_circle_rounded),
|
||||
onPressed: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
},
|
||||
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'IMMICH',
|
||||
style: GoogleFonts.snowburstOne(
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? Positioned(
|
||||
top: 10,
|
||||
right: 12,
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
width: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.backup_rounded),
|
||||
tooltip: 'Backup Controller',
|
||||
onPressed: () async {
|
||||
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
|
||||
|
||||
if (onPop == true) {
|
||||
onPopBack!();
|
||||
}
|
||||
},
|
||||
),
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? Positioned(
|
||||
bottom: 5,
|
||||
child: Text(
|
||||
_backupState.backingUpAssetCount.toString(),
|
||||
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
: Container()
|
||||
],
|
||||
),
|
||||
],
|
||||
bool _isEnableAutoBackup = ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||
return SliverAppBar(
|
||||
centerTitle: true,
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
backgroundColor: Colors.grey[200],
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
leading: Builder(
|
||||
builder: (BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.account_circle_rounded),
|
||||
onPressed: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
},
|
||||
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
|
||||
);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'IMMICH',
|
||||
style: GoogleFonts.snowburstOne(
|
||||
textStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? Positioned(
|
||||
top: 10,
|
||||
right: 12,
|
||||
child: SizedBox(
|
||||
height: 8,
|
||||
width: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
IconButton(
|
||||
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',
|
||||
onPressed: () async {
|
||||
var onPop = await AutoRouter.of(context).push(const BackupControllerRoute());
|
||||
|
||||
if (onPop == true) {
|
||||
onPopBack!();
|
||||
}
|
||||
},
|
||||
),
|
||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||
? Positioned(
|
||||
bottom: 5,
|
||||
child: Text(
|
||||
_backupState.backingUpAssetCount.toString(),
|
||||
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
: Container()
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ class ProfileDrawer extends ConsumerWidget {
|
||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||
|
||||
if (res) {
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
AutoRouter.of(context).popUntilRoot();
|
||||
ref.read(assetProvider.notifier).clearAllAsset();
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -56,6 +56,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false',
|
||||
heroTag: asset.id,
|
||||
thumbnailUrl: thumbnailRequestUrl,
|
||||
asset: asset,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||
@@ -9,6 +12,7 @@ import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer.dart';
|
||||
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({Key? key}) : super(key: key);
|
||||
@@ -18,6 +22,8 @@ class HomePage extends HookConsumerWidget {
|
||||
ScrollController _scrollController = useScrollController();
|
||||
List<ImmichAssetGroupByDate> _assetGroup = ref.watch(assetProvider);
|
||||
List<Widget> _imageGridGroup = [];
|
||||
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var homePageState = ref.watch(homePageStateProvider);
|
||||
|
||||
_scrollControllerCallback() {
|
||||
var endOfPage = _scrollController.position.maxScrollExtent;
|
||||
@@ -28,10 +34,9 @@ class HomePage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
ref.read(assetProvider.notifier).getImmichAssets();
|
||||
ref.read(assetProvider.notifier).getAllAssets();
|
||||
|
||||
_scrollController.addListener(_scrollControllerCallback);
|
||||
|
||||
return () {
|
||||
_scrollController.removeListener(_scrollControllerCallback);
|
||||
};
|
||||
@@ -45,7 +50,7 @@ class HomePage extends HookConsumerWidget {
|
||||
if (_imageGridGroup.isNotEmpty && _imageGridGroup.length < 20) {
|
||||
ref.read(assetProvider.notifier).getOlderAsset();
|
||||
} else if (_imageGridGroup.isEmpty) {
|
||||
ref.read(assetProvider.notifier).getImmichAssets();
|
||||
ref.read(assetProvider.notifier).getAllAssets();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +77,10 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
// Add Daily Title Group
|
||||
_imageGridGroup.add(
|
||||
DailyTitleText(isoDate: dateTitle, assetGroup: assetGroup),
|
||||
DailyTitleText(
|
||||
isoDate: dateTitle,
|
||||
assetGroup: assetGroup,
|
||||
),
|
||||
);
|
||||
|
||||
// Add Image Group
|
||||
@@ -85,25 +93,49 @@ class HomePage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
ImmichSliverAppBar(
|
||||
imageGridGroup: _imageGridGroup,
|
||||
onPopBack: onPopBackFromBackupPage,
|
||||
bottom: !isMultiSelectEnable,
|
||||
top: !isMultiSelectEnable,
|
||||
child: Stack(
|
||||
children: [
|
||||
DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
controller: _scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAnimatedSwitcher(
|
||||
child: isMultiSelectEnable
|
||||
? const SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 70,
|
||||
child: null,
|
||||
),
|
||||
)
|
||||
: ImmichSliverAppBar(
|
||||
imageGridGroup: _imageGridGroup,
|
||||
onPopBack: onPopBackFromBackupPage,
|
||||
),
|
||||
duration: const Duration(milliseconds: 350),
|
||||
),
|
||||
..._imageGridGroup
|
||||
],
|
||||
),
|
||||
..._imageGridGroup,
|
||||
],
|
||||
),
|
||||
),
|
||||
isMultiSelectEnable
|
||||
? DisableMultiSelectButton(
|
||||
onPressed: ref.watch(homePageStateProvider.notifier).disableMultiSelect,
|
||||
selectedItemCount: homePageState.selectedItems.length,
|
||||
)
|
||||
: Container(),
|
||||
isMultiSelectEnable ? const ControlBottomAppBar() : Container(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
// key: _scaffoldKey,
|
||||
drawer: const ProfileDrawer(),
|
||||
body: _buildBody(),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final usernameController = useTextEditingController(text: 'testuser@email.com');
|
||||
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(
|
||||
child: ConstrainedBox(
|
||||
@@ -126,7 +126,7 @@ class LoginButton extends ConsumerWidget {
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Error logging you in, check server url, emald and password!",
|
||||
msg: "Error logging you in, check server url, email and password!",
|
||||
toastType: ToastType.error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.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';
|
||||
|
||||
class LoginPage extends HookConsumerWidget {
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'package:flutter/widgets.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/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/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';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
@@ -41,7 +41,8 @@ class _$AppRouter extends RootStackRouter {
|
||||
key: args.key,
|
||||
imageUrl: args.imageUrl,
|
||||
heroTag: args.heroTag,
|
||||
thumbnailUrl: args.thumbnailUrl));
|
||||
thumbnailUrl: args.thumbnailUrl,
|
||||
asset: args.asset));
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
@@ -96,14 +97,16 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
{Key? key,
|
||||
required String imageUrl,
|
||||
required String heroTag,
|
||||
required String thumbnailUrl})
|
||||
required String thumbnailUrl,
|
||||
required ImmichAsset asset})
|
||||
: super(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page',
|
||||
args: ImageViewerRouteArgs(
|
||||
key: key,
|
||||
imageUrl: imageUrl,
|
||||
heroTag: heroTag,
|
||||
thumbnailUrl: thumbnailUrl));
|
||||
thumbnailUrl: thumbnailUrl,
|
||||
asset: asset));
|
||||
|
||||
static const String name = 'ImageViewerRoute';
|
||||
}
|
||||
@@ -113,7 +116,8 @@ class ImageViewerRouteArgs {
|
||||
{this.key,
|
||||
required this.imageUrl,
|
||||
required this.heroTag,
|
||||
required this.thumbnailUrl});
|
||||
required this.thumbnailUrl,
|
||||
required this.asset});
|
||||
|
||||
final Key? key;
|
||||
|
||||
@@ -123,9 +127,11 @@ class ImageViewerRouteArgs {
|
||||
|
||||
final String thumbnailUrl;
|
||||
|
||||
final ImmichAsset asset;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl}';
|
||||
return 'ImageViewerRouteArgs{key: $key, imageUrl: $imageUrl, heroTag: $heroTag, thumbnailUrl: $thumbnailUrl, asset: $asset}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
187
mobile/lib/shared/models/exif.model.dart
Normal file
187
mobile/lib/shared/models/exif.model.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
133
mobile/lib/shared/models/immich_asset_with_exif.model.dart
Normal file
133
mobile/lib/shared/models/immich_asset_with_exif.model.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -37,20 +37,12 @@ class BackupService {
|
||||
for (var entity in assetList) {
|
||||
try {
|
||||
if (entity.type == AssetType.video) {
|
||||
file = await entity.file;
|
||||
file = await entity.originFile;
|
||||
} else {
|
||||
file = await entity.file.timeout(const Duration(seconds: 5));
|
||||
file = await entity.originFile.timeout(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
if (file != null) {
|
||||
// reading exif
|
||||
// var exifData = await readExifFromFile(file);
|
||||
|
||||
// for (String key in exifData.keys) {
|
||||
// debugPrint("- $key (${exifData[key]?.tagType}): ${exifData[key]}");
|
||||
// }
|
||||
|
||||
// debugPrint("------------------");
|
||||
String originalFileName = await entity.titleAsync;
|
||||
String fileNameWithoutPath = originalFileName.toString().split(".")[0];
|
||||
var fileExtension = p.extension(file.path);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class FileHelper {
|
||||
static getMimeType(String filePath) {
|
||||
debugPrint(filePath);
|
||||
var fileExtension = p.extension(filePath).split(".")[1];
|
||||
|
||||
switch (fileExtension.toLowerCase()) {
|
||||
@@ -28,6 +26,12 @@ class FileHelper {
|
||||
case 'avi':
|
||||
return {"type": "video", "subType": "x-msvideo"};
|
||||
|
||||
case 'heic':
|
||||
return {"type": "image", "subType": "heic"};
|
||||
|
||||
case 'heif':
|
||||
return {"type": "image", "subType": "heif"};
|
||||
|
||||
default:
|
||||
return {"type": "unsupport", "subType": "unsupport"};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ build:
|
||||
flutter packages pub run build_runner build
|
||||
|
||||
watch:
|
||||
flutter packages pub run build_runner watch
|
||||
flutter packages pub run build_runner watch --delete-conflicting-outputs
|
||||
|
||||
create_app_icon:
|
||||
flutter pub run flutter_launcher_icons:main
|
||||
@@ -50,6 +50,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
badges:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: badges
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -513,13 +520,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -639,6 +639,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -721,6 +728,13 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
sliver_tools:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sliver_tools
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -818,7 +832,7 @@ packages:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.8"
|
||||
version: "0.4.3"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -30,6 +30,9 @@ dependencies:
|
||||
fluttertoast: ^8.0.8
|
||||
video_player: ^2.2.18
|
||||
chewie: ^1.2.2
|
||||
sliver_tools: ^0.2.5
|
||||
badges: ^2.0.2
|
||||
photo_view: ^0.13.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
##################################
|
||||
# DEVELOPMENT
|
||||
##################################
|
||||
FROM node:16-bullseye-slim AS development
|
||||
FROM node:16-alpine3.14 AS development
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -9,8 +9,7 @@ WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||
RUN apk add --update-cache build-base python3 libheif vips-dev vips ffmpeg
|
||||
|
||||
RUN npm install
|
||||
|
||||
@@ -18,44 +17,25 @@ COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Clean up commands
|
||||
RUN apt-get autoremove -y && apt-get clean && \
|
||||
rm -rf /usr/local/src/*
|
||||
|
||||
RUN apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
|
||||
##################################
|
||||
#################################
|
||||
# PRODUCTION
|
||||
##################################
|
||||
# FROM node:16-bullseye-slim as production
|
||||
# ARG DEBIAN_FRONTEND=noninteractive
|
||||
# ARG NODE_ENV=production
|
||||
# ENV NODE_ENV=${NODE_ENV}
|
||||
#################################
|
||||
FROM node:16-alpine3.14 AS production
|
||||
|
||||
# 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
|
||||
# RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# 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
|
||||
# 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"]
|
||||
CMD ["node", "dist/main"]
|
||||
850
server/package-lock.json
generated
850
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,16 +31,12 @@
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/platform-fastify": "^8.2.6",
|
||||
"@nestjs/typeorm": "^8.0.3",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||
"@tensorflow/tfjs": "^3.13.0",
|
||||
"@tensorflow/tfjs-converter": "^3.13.0",
|
||||
"@tensorflow/tfjs-core": "^3.13.0",
|
||||
"@tensorflow/tfjs-node": "^3.13.0",
|
||||
"bcrypt": "^5.0.1",
|
||||
"bull": "^4.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"dotenv": "^14.2.0",
|
||||
"exifr": "^7.1.3",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"joi": "^17.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -50,7 +46,7 @@
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"sharp": "^0.29.3",
|
||||
"sharp": "0.28",
|
||||
"systeminformation": "^5.11.0",
|
||||
"typeorm": "^0.2.41"
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import { promisify } from 'util';
|
||||
import { stat } from 'fs';
|
||||
import { pipeline } from 'stream';
|
||||
import { GetNewAssetQueryDto } from './dto/get-new-asset-query.dto';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -37,8 +38,9 @@ const fileInfo = promisify(stat);
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
constructor(
|
||||
private readonly assetService: AssetService,
|
||||
private readonly assetOptimizeService: AssetOptimizeService,
|
||||
private assetService: AssetService,
|
||||
private assetOptimizeService: AssetOptimizeService,
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
) {}
|
||||
|
||||
@Post('upload')
|
||||
@@ -53,6 +55,7 @@ export class AssetController {
|
||||
|
||||
if (savedAsset && savedAsset.type == AssetType.IMAGE) {
|
||||
await this.assetOptimizeService.resizeImage(savedAsset);
|
||||
await this.backgroundTaskService.extractExif(savedAsset, file.originalname, file.size);
|
||||
}
|
||||
|
||||
if (savedAsset && savedAsset.type == AssetType.VIDEO) {
|
||||
@@ -155,4 +158,9 @@ export class AssetController {
|
||||
async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) {
|
||||
return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
|
||||
}
|
||||
|
||||
@Get('/assetById/:assetId')
|
||||
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
|
||||
return this.assetService.getAssetById(authUser, assetId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { AssetEntity } from './entities/asset.entity';
|
||||
import { ImageOptimizeModule } from '../../modules/image-optimize/image-optimize.module';
|
||||
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -18,7 +20,7 @@ import { BullModule } from '@nestjs/bull';
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'machine-learning',
|
||||
name: 'background-task',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
@@ -27,9 +29,10 @@ import { BullModule } from '@nestjs/bull';
|
||||
}),
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
ImageOptimizeModule,
|
||||
BackgroundTaskModule,
|
||||
],
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService, AssetOptimizeService],
|
||||
providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
|
||||
exports: [],
|
||||
})
|
||||
export class AssetModule {}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AssetEntity, AssetType } from './entities/asset.entity';
|
||||
import _ from 'lodash';
|
||||
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
|
||||
import { Greater } from '@tensorflow/tfjs-core';
|
||||
|
||||
@Injectable()
|
||||
export class AssetService {
|
||||
@@ -113,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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
48
server/src/api-v1/asset/dto/create-exif.dto.ts
Normal file
48
server/src/api-v1/asset/dto/create-exif.dto.ts
Normal 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;
|
||||
}
|
||||
4
server/src/api-v1/asset/dto/update-exif.dto.ts
Normal file
4
server/src/api-v1/asset/dto/update-exif.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExifDto } from './create-exif.dto';
|
||||
|
||||
export class UpdateExifDto extends PartialType(CreateExifDto) {}
|
||||
@@ -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')
|
||||
@Unique(['deviceAssetId', 'userId', 'deviceId'])
|
||||
@@ -38,6 +39,9 @@ export class AssetEntity {
|
||||
|
||||
@Column({ nullable: true })
|
||||
duration: string;
|
||||
|
||||
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
||||
exifInfo: ExifEntity;
|
||||
}
|
||||
|
||||
export enum AssetType {
|
||||
|
||||
67
server/src/api-v1/asset/entities/exif.entity.ts
Normal file
67
server/src/api-v1/asset/entities/exif.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { immichAppConfig } from './config/app.config';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { ImageOptimizeModule } from './modules/image-optimize/image-optimize.module';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,7 +30,6 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
redis: {
|
||||
host: 'immich_redis',
|
||||
port: 6379,
|
||||
// password: configService.get('REDIS_PASSWORD'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
@@ -38,6 +38,8 @@ import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
ImageOptimizeModule,
|
||||
|
||||
ServerInfoModule,
|
||||
|
||||
BackgroundTaskModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
||||
@@ -11,7 +11,7 @@ export const multerConfig = {
|
||||
|
||||
export const multerOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime)$/)) {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif)$/)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||
|
||||
24
server/src/modules/background-task/background-task.module.ts
Normal file
24
server/src/modules/background-task/background-task.module.ts
Normal 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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { AssetService } from '../../api-v1/asset/asset.service';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import { ImageOptimizeProcessor } from './image-optimize.processor';
|
||||
import { AssetOptimizeService } from './image-optimize.service';
|
||||
import { MachineLearningProcessor } from './machine-learning.processor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -19,18 +18,10 @@ import { MachineLearningProcessor } from './machine-learning.processor';
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: 'machine-learning',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
}),
|
||||
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
],
|
||||
providers: [AssetOptimizeService, ImageOptimizeProcessor, MachineLearningProcessor],
|
||||
providers: [AssetOptimizeService, ImageOptimizeProcessor],
|
||||
exports: [AssetOptimizeService],
|
||||
})
|
||||
export class ImageOptimizeModule {}
|
||||
|
||||
@@ -4,16 +4,16 @@ import { Job, Queue } from 'bull';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import sharp from 'sharp';
|
||||
import fs, { existsSync, mkdirSync } from 'fs';
|
||||
import { existsSync, mkdirSync, readFile } from 'fs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
@Processor('optimize')
|
||||
export class ImageOptimizeProcessor {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectQueue('machine-learning') private machineLearningQueue: Queue,
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -32,30 +32,42 @@ export class ImageOptimizeProcessor {
|
||||
mkdirSync(resizeDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.readFile(savedAsset.originalPath, (err, data) => {
|
||||
readFile(savedAsset.originalPath, async (err, data) => {
|
||||
if (err) {
|
||||
console.error('Error Reading File');
|
||||
}
|
||||
|
||||
sharp(data)
|
||||
.resize(512, 512, { fit: 'outside' })
|
||||
.toFile(resizePath, async (err, info) => {
|
||||
if (err) {
|
||||
console.error('Error resizing file ', err);
|
||||
return;
|
||||
}
|
||||
if (savedAsset.mimeType == 'image/heic' || savedAsset.mimeType == 'image/heif') {
|
||||
let desitnation = '';
|
||||
if (savedAsset.mimeType == 'image/heic') {
|
||||
desitnation = resizePath.replace('.HEIC', '.jpeg');
|
||||
} else {
|
||||
desitnation = resizePath.replace('.HEIF', '.jpeg');
|
||||
}
|
||||
|
||||
await this.assetRepository.update(savedAsset, { resizePath: resizePath });
|
||||
sharp(data)
|
||||
.toFormat('jpeg')
|
||||
.resize(512, 512, { fit: 'outside' })
|
||||
.toFile(desitnation, async (err, info) => {
|
||||
if (err) {
|
||||
console.error('Error resizing file ', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send file to object detection after resizing
|
||||
// const detectionJob = await this.machineLearningQueue.add(
|
||||
// 'object-detection',
|
||||
// {
|
||||
// resizePath,
|
||||
// },
|
||||
// { jobId: randomUUID() },
|
||||
// );
|
||||
});
|
||||
await this.assetRepository.update(savedAsset, { resizePath: desitnation });
|
||||
});
|
||||
} else {
|
||||
sharp(data)
|
||||
.resize(512, 512, { fit: 'outside' })
|
||||
.toFile(resizePath, async (err, info) => {
|
||||
if (err) {
|
||||
console.error('Error resizing file ', err);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.assetRepository.update(savedAsset, { resizePath: resizePath });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return 'ok';
|
||||
|
||||
@@ -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(
|
||||
'get-video-thumbnail',
|
||||
{
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job } from 'bull';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||
import fs from 'fs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as tfnode from '@tensorflow/tfjs-node';
|
||||
import * as cocoSsd from '@tensorflow-models/coco-ssd';
|
||||
|
||||
@Processor('machine-learning')
|
||||
export class MachineLearningProcessor {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Process('object-detection')
|
||||
async handleOptimization(job: Job) {
|
||||
try {
|
||||
const { resizePath }: { resizePath: string } = job.data;
|
||||
|
||||
const image = fs.readFileSync(resizePath);
|
||||
const decodedImage = tfnode.node.decodeImage(image, 3) as tfnode.Tensor3D;
|
||||
const model = await cocoSsd.load();
|
||||
const predictions = await model.detect(decodedImage);
|
||||
console.log('\n\nstart predictions ------------------ ');
|
||||
for (var result of predictions) {
|
||||
console.log(`Found ${result.class} with score ${result.score}`);
|
||||
}
|
||||
console.log('end predictions ------------------\n\n');
|
||||
|
||||
return 'ok';
|
||||
} catch (e) {
|
||||
console.log('Error object detection ', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user