mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
Implemented delete asset on device and on database (#22)
* refactor serving file function asset service * Remove PhotoViewer for now since it creates a problem in 2.10 * Added error message for wrong decode file and logo for failed to load file * Fixed error when read stream cannot be created and crash server * Added method to get all assets as a raw array * Implemented cleaner way of grouping image * Implemented operation to delete assets in the database * Implemented delete on database operation * Implemented delete on device operation * Fixed issue display wrong information when the auto backup is enabled after deleting all assets
This commit is contained in:
parent
051c958c8b
commit
897d49f734
22 changed files with 518 additions and 10617 deletions
|
@ -53,19 +53,18 @@ You can use docker compose for development, there are several services that comp
|
||||||
|
|
||||||
Navigate to `server` directory and run
|
Navigate to `server` directory and run
|
||||||
|
|
||||||
```
|
````
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
|
||||||
|
|
||||||
Then populate the value in there.
|
Then populate the value in there.
|
||||||
|
|
||||||
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned the user that run the `docker-compose` command below.
|
Pay attention to the key `UPLOAD_LOCATION`, this directory must exist and is owned by the user that run the `docker-compose` command below.
|
||||||
|
|
||||||
To start, run
|
To start, run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f ./server/docker-compose.yml up
|
docker-compose -f ./server/docker-compose.yml up
|
||||||
```
|
````
|
||||||
|
|
||||||
To force rebuild node modules after installing new packages
|
To force rebuild node modules after installing new packages
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
@ -10,7 +9,6 @@ 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/modules/home/services/asset.service.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/models/immich_asset_with_exif.model.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
|
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
class ImageViewerPage extends HookConsumerWidget {
|
class ImageViewerPage extends HookConsumerWidget {
|
||||||
|
@ -35,6 +33,7 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
getAssetExif();
|
getAssetExif();
|
||||||
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
@ -60,12 +59,34 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
fadeInDuration: const Duration(milliseconds: 250),
|
fadeInDuration: const Duration(milliseconds: 250),
|
||||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
errorWidget: (context, url, error) => ConstrainedBox(
|
||||||
imageBuilder: (context, imageProvider) {
|
constraints: const BoxConstraints(maxWidth: 300),
|
||||||
return PhotoView(imageProvider: imageProvider);
|
child: Wrap(
|
||||||
},
|
spacing: 32,
|
||||||
|
runSpacing: 32,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"Failed To Render Image - Possibly Corrupted Data",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: Text(
|
||||||
|
error.toString(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// imageBuilder: (context, imageProvider) {
|
||||||
|
// return PhotoView(imageProvider: imageProvider);
|
||||||
|
// },
|
||||||
placeholder: (context, url) {
|
placeholder: (context, url) {
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
|
cacheKey: thumbnailUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
imageUrl: thumbnailUrl,
|
imageUrl: thumbnailUrl,
|
||||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||||
|
@ -74,7 +95,10 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||||
scale: 0.2,
|
scale: 0.2,
|
||||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => const Icon(Icons.error),
|
errorWidget: (context, url, error) => Icon(
|
||||||
|
Icons.error,
|
||||||
|
color: Colors.grey[300],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class DeleteAssetResponse {
|
||||||
|
final String id;
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
DeleteAssetResponse({
|
||||||
|
required this.id,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
DeleteAssetResponse copyWith({
|
||||||
|
String? id,
|
||||||
|
String? status,
|
||||||
|
}) {
|
||||||
|
return DeleteAssetResponse(
|
||||||
|
id: id ?? this.id,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'status': status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DeleteAssetResponse.fromMap(Map<String, dynamic> map) {
|
||||||
|
return DeleteAssetResponse(
|
||||||
|
id: map['id'] ?? '',
|
||||||
|
status: map['status'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory DeleteAssetResponse.fromJson(String source) => DeleteAssetResponse.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'DeleteAssetResponse(id: $id, status: $status)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is DeleteAssetResponse && other.id == id && other.status == status;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => id.hashCode ^ status.hashCode;
|
||||||
|
}
|
|
@ -1,99 +1,72 @@
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/models/get_all_asset_respose.model.dart';
|
import 'package:immich_mobile/modules/home/models/delete_asset_response.model.dart';
|
||||||
import 'package:immich_mobile/modules/home/services/asset.service.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.model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class AssetNotifier extends StateNotifier<List<ImmichAssetGroupByDate>> {
|
class AssetNotifier extends StateNotifier<List<ImmichAsset>> {
|
||||||
final AssetService _assetService = AssetService();
|
final AssetService _assetService = AssetService();
|
||||||
|
final DeviceInfoService _deviceInfoService = DeviceInfoService();
|
||||||
|
|
||||||
AssetNotifier() : super([]);
|
AssetNotifier() : super([]);
|
||||||
|
|
||||||
late String? nextPageKey = "";
|
getAllAsset() async {
|
||||||
bool isFetching = false;
|
List<ImmichAsset>? allAssets = await _assetService.getAllAsset();
|
||||||
|
|
||||||
// Get All assets
|
if (allAssets != null) {
|
||||||
getAllAssets() async {
|
allAssets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
||||||
GetAllAssetResponse? res = await _assetService.getAllAsset();
|
state = allAssets;
|
||||||
nextPageKey = res?.nextPageKey;
|
|
||||||
|
|
||||||
if (res != null) {
|
|
||||||
for (var assets in res.data) {
|
|
||||||
state = [...state, assets];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Asset From The Past
|
|
||||||
getOlderAsset() async {
|
|
||||||
if (nextPageKey != null && !isFetching) {
|
|
||||||
isFetching = true;
|
|
||||||
GetAllAssetResponse? res = await _assetService.getOlderAsset(nextPageKey);
|
|
||||||
|
|
||||||
if (res != null) {
|
|
||||||
nextPageKey = res.nextPageKey;
|
|
||||||
|
|
||||||
List<ImmichAssetGroupByDate> previousState = state;
|
|
||||||
List<ImmichAssetGroupByDate> currentState = [];
|
|
||||||
|
|
||||||
for (var assets in res.data) {
|
|
||||||
currentState = [...currentState, assets];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousState.last.date == currentState.first.date) {
|
|
||||||
previousState.last.assets = [...previousState.last.assets, ...currentState.first.assets];
|
|
||||||
state = [...previousState, ...currentState.sublist(1)];
|
|
||||||
} else {
|
|
||||||
state = [...previousState, ...currentState];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isFetching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get newer asset from the current time
|
|
||||||
getNewAsset() async {
|
|
||||||
if (state.isNotEmpty) {
|
|
||||||
var latestGroup = state.first;
|
|
||||||
|
|
||||||
// Sort the last asset group and put the lastest asset in front.
|
|
||||||
latestGroup.assets.sortByCompare<DateTime>((e) => DateTime.parse(e.createdAt), (a, b) => b.compareTo(a));
|
|
||||||
var latestAsset = latestGroup.assets.first;
|
|
||||||
var formatDateTemplate = 'y-MM-dd';
|
|
||||||
var latestAssetDateText = DateFormat(formatDateTemplate).format(DateTime.parse(latestAsset.createdAt));
|
|
||||||
|
|
||||||
List<ImmichAsset> newAssets = await _assetService.getNewAsset(latestAsset.createdAt);
|
|
||||||
|
|
||||||
if (newAssets.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grouping by data
|
|
||||||
var groupByDateList = groupBy<ImmichAsset, String>(
|
|
||||||
newAssets, (asset) => DateFormat(formatDateTemplate).format(DateTime.parse(asset.createdAt)));
|
|
||||||
|
|
||||||
groupByDateList.forEach((groupDateInFormattedText, assets) {
|
|
||||||
if (groupDateInFormattedText != latestAssetDateText) {
|
|
||||||
ImmichAssetGroupByDate newGroup = ImmichAssetGroupByDate(assets: assets, date: groupDateInFormattedText);
|
|
||||||
state = [newGroup, ...state];
|
|
||||||
} else {
|
|
||||||
latestGroup.assets.insertAll(0, assets);
|
|
||||||
|
|
||||||
state = [latestGroup, ...state.sublist(1)];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAllAsset() {
|
clearAllAsset() {
|
||||||
state = [];
|
state = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteAssets(Set<ImmichAsset> deleteAssets) async {
|
||||||
|
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
||||||
|
var deviceId = deviceInfo["deviceId"];
|
||||||
|
List<String> deleteIdList = [];
|
||||||
|
// Delete asset from device
|
||||||
|
for (var asset in deleteAssets) {
|
||||||
|
// Delete asset on device if present
|
||||||
|
if (asset.deviceId == deviceId) {
|
||||||
|
AssetEntity? localAsset = await AssetEntity.fromId(asset.deviceAssetId);
|
||||||
|
|
||||||
|
if (localAsset != null) {
|
||||||
|
deleteIdList.add(localAsset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<String> result = await PhotoManager.editor.deleteWithIds(deleteIdList);
|
||||||
|
print(result);
|
||||||
|
|
||||||
|
// Delete asset on server
|
||||||
|
List<DeleteAssetResponse>? deleteAssetResult = await _assetService.deleteAssets(deleteAssets);
|
||||||
|
if (deleteAssetResult == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var asset in deleteAssetResult) {
|
||||||
|
if (asset.status == 'success') {
|
||||||
|
state = state.where((immichAsset) => immichAsset.id != asset.id).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentLocalPageProvider = StateProvider<int>((ref) => 0);
|
final currentLocalPageProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAssetGroupByDate>>((ref) {
|
final assetProvider = StateNotifierProvider<AssetNotifier, List<ImmichAsset>>((ref) {
|
||||||
return AssetNotifier();
|
return AssetNotifier();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final assetGroupByDateTimeProvider = StateProvider((ref) {
|
||||||
|
var assetGroup = ref.watch(assetProvider);
|
||||||
|
|
||||||
|
return assetGroup.groupListsBy((element) => DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)));
|
||||||
|
});
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import 'dart:convert';
|
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/delete_asset_response.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/models/get_all_asset_response.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/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';
|
||||||
|
@ -9,7 +10,20 @@ import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
class AssetService {
|
class AssetService {
|
||||||
final NetworkService _networkService = NetworkService();
|
final NetworkService _networkService = NetworkService();
|
||||||
|
|
||||||
Future<GetAllAssetResponse?> getAllAsset() async {
|
Future<List<ImmichAsset>?> getAllAsset() async {
|
||||||
|
var res = await _networkService.getRequest(url: "asset/");
|
||||||
|
try {
|
||||||
|
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||||
|
|
||||||
|
List<ImmichAsset> result = List.from(decodedData.map((a) => ImmichAsset.fromMap(a)));
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error getAllAsset ${e.toString()}");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GetAllAssetResponse?> getAllAssetWithPagination() async {
|
||||||
var res = await _networkService.getRequest(url: "asset/all");
|
var res = await _networkService.getRequest(url: "asset/all");
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> decodedData = jsonDecode(res.toString());
|
Map<String, dynamic> decodedData = jsonDecode(res.toString());
|
||||||
|
@ -69,7 +83,27 @@ class AssetService {
|
||||||
Map<String, dynamic> decodedData = jsonDecode(res.toString());
|
Map<String, dynamic> decodedData = jsonDecode(res.toString());
|
||||||
|
|
||||||
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
|
ImmichAssetWithExif result = ImmichAssetWithExif.fromMap(decodedData);
|
||||||
print("result $result");
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error getAllAsset ${e.toString()}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<DeleteAssetResponse>?> deleteAssets(Set<ImmichAsset> deleteAssets) async {
|
||||||
|
try {
|
||||||
|
var payload = [];
|
||||||
|
|
||||||
|
for (var asset in deleteAssets) {
|
||||||
|
payload.add(asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = await _networkService.deleteRequest(url: "asset/", data: {"ids": payload});
|
||||||
|
|
||||||
|
List<dynamic> decodedData = jsonDecode(res.toString());
|
||||||
|
|
||||||
|
List<DeleteAssetResponse> result = List.from(decodedData.map((a) => DeleteAssetResponse.fromMap(a)));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error getAllAsset ${e.toString()}");
|
debugPrint("Error getAllAsset ${e.toString()}");
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||||
|
|
||||||
class DeleteDialog extends StatelessWidget {
|
class DeleteDialog extends ConsumerWidget {
|
||||||
const DeleteDialog({Key? key}) : super(key: key);
|
const DeleteDialog({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final homePageState = ref.watch(homePageStateProvider);
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: Colors.grey[200],
|
backgroundColor: Colors.grey[200],
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
@ -21,7 +26,12 @@ class DeleteDialog extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {},
|
onPressed: () {
|
||||||
|
ref.watch(assetProvider.notifier).deleteAssets(homePageState.selectedItems);
|
||||||
|
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
"Delete",
|
"Delete",
|
||||||
style: TextStyle(color: Colors.red[400]),
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
|
|
@ -3,7 +3,6 @@ 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/modules/login/providers/authentication.provider.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
import 'package:immich_mobile/modules/home/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.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/modules/login/providers/authentication.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/backup.provider.dart';
|
||||||
|
|
||||||
class ProfileDrawer extends ConsumerWidget {
|
class ProfileDrawer extends ConsumerWidget {
|
||||||
const ProfileDrawer({Key? key}) : super(key: key);
|
const ProfileDrawer({Key? key}) : super(key: key);
|
||||||
|
@ -57,6 +58,7 @@ class ProfileDrawer extends ConsumerWidget {
|
||||||
bool res = await ref.read(authenticationProvider.notifier).logout();
|
bool res = await ref.read(authenticationProvider.notifier).logout();
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
|
ref.watch(backupProvider.notifier).cancelBackup();
|
||||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||||
AutoRouter.of(context).popUntilRoot();
|
AutoRouter.of(context).popUntilRoot();
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.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/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
@ -25,6 +26,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||||
|
|
||||||
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
||||||
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
|
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||||
|
|
||||||
Widget _buildSelectionIcon(ImmichAsset asset) {
|
Widget _buildSelectionIcon(ImmichAsset asset) {
|
||||||
if (selectedAsset.contains(asset)) {
|
if (selectedAsset.contains(asset)) {
|
||||||
|
@ -42,6 +44,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
debugPrint("View ${asset.id}");
|
||||||
if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
|
if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length == 1) {
|
||||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||||
} else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
|
} else if (isMultiSelectEnable && selectedAsset.contains(asset) && selectedAsset.length > 1) {
|
||||||
|
@ -99,9 +102,10 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||||
child: CircularProgressIndicator(value: downloadProgress.progress),
|
child: CircularProgressIndicator(value: downloadProgress.progress),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
debugPrint("Error Loading Thumbnail Widget $error");
|
return Icon(
|
||||||
cacheKey.value += 1;
|
Icons.image_not_supported_outlined,
|
||||||
return const Icon(Icons.error);
|
color: Theme.of(context).primaryColor,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -116,6 +120,15 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||||
)
|
)
|
||||||
: Container(),
|
: Container(),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 10,
|
||||||
|
bottom: 5,
|
||||||
|
child: Icon(
|
||||||
|
(deviceId != asset.deviceId) ? Icons.cloud_done_outlined : Icons.photo_library_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
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/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:immich_mobile/modules/home/providers/asset.provider.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
import 'package:sliver_tools/sliver_tools.dart';
|
||||||
|
|
||||||
|
@ -20,76 +19,51 @@ class HomePage extends HookConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
ScrollController _scrollController = useScrollController();
|
ScrollController _scrollController = useScrollController();
|
||||||
List<ImmichAssetGroupByDate> _assetGroup = ref.watch(assetProvider);
|
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||||
List<Widget> _imageGridGroup = [];
|
List<Widget> _imageGridGroup = [];
|
||||||
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
var isMultiSelectEnable = ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||||
var homePageState = ref.watch(homePageStateProvider);
|
var homePageState = ref.watch(homePageStateProvider);
|
||||||
|
|
||||||
_scrollControllerCallback() {
|
|
||||||
var endOfPage = _scrollController.position.maxScrollExtent;
|
|
||||||
|
|
||||||
if (_scrollController.offset >= endOfPage - (endOfPage * 0.1) && !_scrollController.position.outOfRange) {
|
|
||||||
ref.read(assetProvider.notifier).getOlderAsset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
ref.read(assetProvider.notifier).getAllAssets();
|
ref.read(assetProvider.notifier).getAllAsset();
|
||||||
|
return null;
|
||||||
_scrollController.addListener(_scrollControllerCallback);
|
|
||||||
return () {
|
|
||||||
_scrollController.removeListener(_scrollControllerCallback);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
onPopBackFromBackupPage() {
|
onPopBackFromBackupPage() {
|
||||||
ref.read(assetProvider.notifier).getNewAsset();
|
ref.read(assetProvider.notifier).getAllAsset();
|
||||||
// Remove and force getting new widget again if there is not many widget on screen.
|
|
||||||
// Otherwise do nothing.
|
|
||||||
|
|
||||||
if (_imageGridGroup.isNotEmpty && _imageGridGroup.length < 20) {
|
|
||||||
ref.read(assetProvider.notifier).getOlderAsset();
|
|
||||||
} else if (_imageGridGroup.isEmpty) {
|
|
||||||
ref.read(assetProvider.notifier).getAllAssets();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (_assetGroup.isNotEmpty) {
|
if (assetGroupByDateTime.isNotEmpty) {
|
||||||
String lastGroupDate = _assetGroup[0].date;
|
int? lastMonth;
|
||||||
|
|
||||||
for (var group in _assetGroup) {
|
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
|
||||||
var dateTitle = group.date;
|
DateTime parseDateGroup = DateTime.parse(dateGroup);
|
||||||
var assetGroup = group.assets;
|
int currentMonth = parseDateGroup.month;
|
||||||
|
|
||||||
int? currentMonth = DateTime.tryParse(dateTitle)?.month;
|
if (lastMonth != null) {
|
||||||
int? previousMonth = DateTime.tryParse(lastGroupDate)?.month;
|
if (currentMonth - lastMonth! != 0) {
|
||||||
|
|
||||||
// Add Monthly Title Group if started at the beginning of the month
|
|
||||||
|
|
||||||
if (currentMonth != null && previousMonth != null) {
|
|
||||||
if ((currentMonth - previousMonth) != 0) {
|
|
||||||
_imageGridGroup.add(
|
_imageGridGroup.add(
|
||||||
MonthlyTitleText(isoDate: dateTitle),
|
MonthlyTitleText(
|
||||||
|
isoDate: dateGroup,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Daily Title Group
|
|
||||||
_imageGridGroup.add(
|
_imageGridGroup.add(
|
||||||
DailyTitleText(
|
DailyTitleText(
|
||||||
isoDate: dateTitle,
|
isoDate: dateGroup,
|
||||||
assetGroup: assetGroup,
|
assetGroup: immichAssetList,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add Image Group
|
|
||||||
_imageGridGroup.add(
|
_imageGridGroup.add(
|
||||||
ImageGrid(assetGroup: assetGroup),
|
ImageGrid(assetGroup: immichAssetList),
|
||||||
);
|
);
|
||||||
//
|
|
||||||
lastGroupDate = dateTitle;
|
lastMonth = currentMonth;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
|
|
|
@ -15,36 +15,38 @@ 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.204:2283');
|
final serverEndpointController = useTextEditingController(text: 'http://192.168.1.103:2283');
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 300),
|
constraints: const BoxConstraints(maxWidth: 300),
|
||||||
child: Wrap(
|
child: SingleChildScrollView(
|
||||||
spacing: 32,
|
child: Wrap(
|
||||||
runSpacing: 32,
|
spacing: 32,
|
||||||
alignment: WrapAlignment.center,
|
runSpacing: 32,
|
||||||
children: [
|
alignment: WrapAlignment.center,
|
||||||
const Image(
|
children: [
|
||||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
const Image(
|
||||||
width: 128,
|
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||||
filterQuality: FilterQuality.high,
|
width: 128,
|
||||||
),
|
filterQuality: FilterQuality.high,
|
||||||
Text(
|
),
|
||||||
'IMMICH',
|
Text(
|
||||||
style: GoogleFonts.snowburstOne(
|
'IMMICH',
|
||||||
textStyle:
|
style: GoogleFonts.snowburstOne(
|
||||||
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
|
textStyle:
|
||||||
),
|
TextStyle(fontWeight: FontWeight.bold, fontSize: 48, color: Theme.of(context).primaryColor)),
|
||||||
EmailInput(controller: usernameController),
|
),
|
||||||
PasswordInput(controller: passwordController),
|
EmailInput(controller: usernameController),
|
||||||
ServerEndpointInput(controller: serverEndpointController),
|
PasswordInput(controller: passwordController),
|
||||||
LoginButton(
|
ServerEndpointInput(controller: serverEndpointController),
|
||||||
emailController: usernameController,
|
LoginButton(
|
||||||
passwordController: passwordController,
|
emailController: usernameController,
|
||||||
serverEndpointController: serverEndpointController,
|
passwordController: passwordController,
|
||||||
),
|
serverEndpointController: serverEndpointController,
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
@ -11,7 +13,7 @@ import 'package:immich_mobile/shared/services/backup.service.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class BackupNotifier extends StateNotifier<BackUpState> {
|
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
BackupNotifier(this.ref)
|
BackupNotifier({this.ref})
|
||||||
: super(
|
: super(
|
||||||
BackUpState(
|
BackUpState(
|
||||||
backupProgress: BackUpProgressEnum.idle,
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
|
@ -32,22 +34,25 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final Ref ref;
|
Ref? ref;
|
||||||
final BackupService _backupService = BackupService();
|
final BackupService _backupService = BackupService();
|
||||||
final ServerInfoService _serverInfoService = ServerInfoService();
|
final ServerInfoService _serverInfoService = ServerInfoService();
|
||||||
|
final StreamController _onAssetBackupStreamCtrl = StreamController.broadcast();
|
||||||
|
|
||||||
void getBackupInfo() async {
|
void getBackupInfo() async {
|
||||||
_updateServerInfo();
|
_updateServerInfo();
|
||||||
|
|
||||||
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common);
|
List<AssetPathEntity> list = await PhotoManager.getAssetPathList(onlyAll: true, type: RequestType.common);
|
||||||
|
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
|
||||||
|
|
||||||
if (list.isEmpty) {
|
if (list.isEmpty) {
|
||||||
debugPrint("No Asset On Device");
|
debugPrint("No Asset On Device");
|
||||||
|
state = state.copyWith(
|
||||||
|
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: didBackupAsset.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int totalAsset = list[0].assetCount;
|
int totalAsset = list[0].assetCount;
|
||||||
List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
|
|
||||||
|
|
||||||
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
|
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
|
||||||
}
|
}
|
||||||
|
@ -65,19 +70,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
List<AssetPathEntity> list =
|
List<AssetPathEntity> list =
|
||||||
await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
|
await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
|
||||||
|
|
||||||
|
// Get device assets info from database
|
||||||
|
// Compare and find different assets that has not been backing up
|
||||||
|
// Backup those assets
|
||||||
|
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
|
||||||
|
|
||||||
if (list.isEmpty) {
|
if (list.isEmpty) {
|
||||||
debugPrint("No Asset On Device - Abort Backup Process");
|
debugPrint("No Asset On Device - Abort Backup Process");
|
||||||
|
state = state.copyWith(
|
||||||
|
backupProgress: BackUpProgressEnum.idle, totalAssetCount: 0, assetOnDatabase: backupAsset.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int totalAsset = list[0].assetCount;
|
int totalAsset = list[0].assetCount;
|
||||||
List<AssetEntity> currentAssets = await list[0].getAssetListRange(start: 0, end: totalAsset);
|
List<AssetEntity> currentAssets = await list[0].getAssetListRange(start: 0, end: totalAsset);
|
||||||
|
|
||||||
// Get device assets info from database
|
|
||||||
// Compare and find different assets that has not been backing up
|
|
||||||
// Backup those assets
|
|
||||||
List<String> backupAsset = await _backupService.getDeviceBackupAsset();
|
|
||||||
|
|
||||||
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
|
state = state.copyWith(totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
|
||||||
// Remove item that has already been backed up
|
// Remove item that has already been backed up
|
||||||
for (var backupAssetId in backupAsset) {
|
for (var backupAssetId in backupAsset) {
|
||||||
|
@ -103,7 +110,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetUploaded() {
|
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||||
state =
|
state =
|
||||||
state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1);
|
state.copyWith(backingUpAssetCount: state.backingUpAssetCount - 1, assetOnDatabase: state.assetOnDatabase + 1);
|
||||||
|
|
||||||
|
@ -136,36 +143,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void resumeBackup() {
|
void resumeBackup() {
|
||||||
debugPrint("[resumeBackup]");
|
var authState = ref?.read(authenticationProvider);
|
||||||
var authState = ref.read(authenticationProvider);
|
|
||||||
|
|
||||||
// Check if user is login
|
// Check if user is login
|
||||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
|
|
||||||
// User has been logged out return
|
// User has been logged out return
|
||||||
if (accessKey == null || !authState.isAuthenticated) {
|
if (authState != null) {
|
||||||
debugPrint("[resumeBackup] not authenticated - abort");
|
if (accessKey == null || !authState.isAuthenticated) {
|
||||||
return;
|
debugPrint("[resumeBackup] not authenticated - abort");
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this device is enable backup by the user
|
|
||||||
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
|
||||||
// check if backup is alreayd in process - then return
|
|
||||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
|
||||||
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run backup
|
// Check if this device is enable backup by the user
|
||||||
debugPrint("[resumeBackup] Start back up");
|
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||||
startBackupProcess();
|
// check if backup is alreayd in process - then return
|
||||||
}
|
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||||
|
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint("[resumeBackup] User disables auto backup");
|
// Run backup
|
||||||
return;
|
debugPrint("[resumeBackup] Start back up");
|
||||||
|
startBackupProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||||
return BackupNotifier(ref);
|
return BackupNotifier(ref: ref);
|
||||||
});
|
});
|
||||||
|
|
|
@ -26,7 +26,7 @@ class BackupService {
|
||||||
return result.cast<String>();
|
return result.cast<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function singleAssetDoneCb,
|
backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
|
||||||
Function(int, int) uploadProgress) async {
|
Function(int, int) uploadProgress) async {
|
||||||
var dio = Dio();
|
var dio = Dio();
|
||||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||||
|
@ -77,7 +77,7 @@ class BackupService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.statusCode == 201) {
|
if (res.statusCode == 201) {
|
||||||
singleAssetDoneCb();
|
singleAssetDoneCb(entity.id, deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on DioError catch (e) {
|
} on DioError catch (e) {
|
||||||
|
|
|
@ -7,6 +7,24 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
|
import 'package:immich_mobile/utils/dio_http_interceptor.dart';
|
||||||
|
|
||||||
class NetworkService {
|
class NetworkService {
|
||||||
|
Future<dynamic> deleteRequest({required String url, dynamic data}) async {
|
||||||
|
try {
|
||||||
|
var dio = Dio();
|
||||||
|
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
||||||
|
|
||||||
|
var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||||
|
Response res = await dio.delete('$savedEndpoint/$url', data: data);
|
||||||
|
|
||||||
|
if (res.statusCode == 200) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
} on DioError catch (e) {
|
||||||
|
debugPrint("DioError: ${e.response}");
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("ERROR getRequest: ${e.toString()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<dynamic> getRequest({required String url}) async {
|
Future<dynamic> getRequest({required String url}) async {
|
||||||
try {
|
try {
|
||||||
var dio = Dio();
|
var dio = Dio();
|
||||||
|
|
|
@ -22,6 +22,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||||
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
||||||
ref.read(backupProvider.notifier).getBackupInfo();
|
ref.read(backupProvider.notifier).getBackupInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
Widget _buildStorageInformation() {
|
Widget _buildStorageInformation() {
|
||||||
|
|
10355
server/package-lock.json
generated
10355
server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -12,27 +12,22 @@ import {
|
||||||
Query,
|
Query,
|
||||||
Response,
|
Response,
|
||||||
Headers,
|
Headers,
|
||||||
BadRequestException,
|
Delete,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
import { AssetService } from './asset.service';
|
import { AssetService } from './asset.service';
|
||||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { multerOption } from '../../config/multer-option.config';
|
import { multerOption } from '../../config/multer-option.config';
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
import { createReadStream } from 'fs';
|
|
||||||
import { ServeFileDto } from './dto/serve-file.dto';
|
import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
import { AssetOptimizeService } from '../../modules/image-optimize/image-optimize.service';
|
||||||
import { AssetType } from './entities/asset.entity';
|
import { AssetEntity, AssetType } from './entities/asset.entity';
|
||||||
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||||
import { Response as Res } from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { promisify } from 'util';
|
|
||||||
import { stat } from 'fs';
|
|
||||||
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';
|
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||||
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
const fileInfo = promisify(stat);
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('asset')
|
@Controller('asset')
|
||||||
|
@ -73,75 +68,7 @@ export class AssetController {
|
||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
@Query(ValidationPipe) query: ServeFileDto,
|
@Query(ValidationPipe) query: ServeFileDto,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
let file = null;
|
return this.assetService.serveFile(authUser, query, res, headers);
|
||||||
const asset = await this.assetService.findOne(authUser, query.did, query.aid);
|
|
||||||
|
|
||||||
// Handle Sending Images
|
|
||||||
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
|
|
||||||
res.set({
|
|
||||||
'Content-Type': asset.mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (query.isThumb === 'false' || !query.isThumb) {
|
|
||||||
file = createReadStream(asset.originalPath);
|
|
||||||
} else {
|
|
||||||
file = createReadStream(asset.resizePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new StreamableFile(file);
|
|
||||||
} else if (asset.type == AssetType.VIDEO) {
|
|
||||||
// Handle Handling Video
|
|
||||||
const { size } = await fileInfo(asset.originalPath);
|
|
||||||
const range = headers.range;
|
|
||||||
|
|
||||||
if (range) {
|
|
||||||
/** Extracting Start and End value from Range Header */
|
|
||||||
let [start, end] = range.replace(/bytes=/, '').split('-');
|
|
||||||
start = parseInt(start, 10);
|
|
||||||
end = end ? parseInt(end, 10) : size - 1;
|
|
||||||
|
|
||||||
if (!isNaN(start) && isNaN(end)) {
|
|
||||||
start = start;
|
|
||||||
end = size - 1;
|
|
||||||
}
|
|
||||||
if (isNaN(start) && !isNaN(end)) {
|
|
||||||
start = size - end;
|
|
||||||
end = size - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle unavailable range request
|
|
||||||
if (start >= size || end >= size) {
|
|
||||||
console.error('Bad Request');
|
|
||||||
// Return the 416 Range Not Satisfiable.
|
|
||||||
res.status(416).set({
|
|
||||||
'Content-Range': `bytes */${size}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw new BadRequestException('Bad Request Range');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sending Partial Content With HTTP Code 206 */
|
|
||||||
|
|
||||||
res.status(206).set({
|
|
||||||
'Content-Range': `bytes ${start}-${end}/${size}`,
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Content-Length': end - start + 1,
|
|
||||||
'Content-Type': asset.mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
|
|
||||||
|
|
||||||
return new StreamableFile(videoStream);
|
|
||||||
} else {
|
|
||||||
res.set({
|
|
||||||
'Content-Type': asset.mimeType,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new StreamableFile(createReadStream(asset.originalPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('SHOULD NOT BE HERE');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/new')
|
@Get('/new')
|
||||||
|
@ -154,6 +81,11 @@ export class AssetController {
|
||||||
return await this.assetService.getAllAssets(authUser, query);
|
return await this.assetService.getAllAssets(authUser, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/')
|
||||||
|
async getAllAssetsNoPagination(@GetAuthUser() authUser: AuthUserDto) {
|
||||||
|
return await this.assetService.getAllAssetsNoPagination(authUser);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('/:deviceId')
|
@Get('/:deviceId')
|
||||||
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);
|
||||||
|
@ -163,4 +95,24 @@ export class AssetController {
|
||||||
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
|
async getAssetById(@GetAuthUser() authUser: AuthUserDto, @Param('assetId') assetId) {
|
||||||
return this.assetService.getAssetById(authUser, assetId);
|
return this.assetService.getAssetById(authUser, assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('/')
|
||||||
|
async deleteAssetById(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) assetIds: DeleteAssetDto) {
|
||||||
|
const deleteAssetList: AssetEntity[] = [];
|
||||||
|
|
||||||
|
assetIds.ids.forEach(async (id) => {
|
||||||
|
const assets = await this.assetService.getAssetById(authUser, id);
|
||||||
|
deleteAssetList.push(assets);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.assetService.deleteAssetById(authUser, assetIds);
|
||||||
|
|
||||||
|
result.forEach((res) => {
|
||||||
|
deleteAssetList.filter((a) => a.id == res.id && res.status == 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, StreamableFile } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { MoreThan, Repository } from 'typeorm';
|
import { MoreThan, Repository } from 'typeorm';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||||
import { AssetEntity, AssetType } from './entities/asset.entity';
|
import { AssetEntity, AssetType } from './entities/asset.entity';
|
||||||
import _ from 'lodash';
|
import _, { result } from 'lodash';
|
||||||
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
import { GetAllAssetQueryDto } from './dto/get-all-asset-query.dto';
|
||||||
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
|
import { GetAllAssetReponseDto } from './dto/get-all-asset-response.dto';
|
||||||
|
import { createReadStream, stat } from 'fs';
|
||||||
|
import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
|
import { Response as Res } from 'express';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
|
|
||||||
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetService {
|
export class AssetService {
|
||||||
|
@ -52,6 +59,20 @@ export class AssetService {
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAllAssetsNoPagination(authUser: AuthUserDto) {
|
||||||
|
try {
|
||||||
|
const assets = await this.assetRepository
|
||||||
|
.createQueryBuilder('a')
|
||||||
|
.where('a."userId" = :userId', { userId: authUser.id })
|
||||||
|
.orderBy('a."createdAt"::date', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return assets;
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error(e, 'getAllAssets');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> {
|
public async getAllAssets(authUser: AuthUserDto, query: GetAllAssetQueryDto): Promise<GetAllAssetReponseDto> {
|
||||||
try {
|
try {
|
||||||
const assets = await this.assetRepository
|
const assets = await this.assetRepository
|
||||||
|
@ -122,4 +143,104 @@ export class AssetService {
|
||||||
relations: ['exifInfo'],
|
relations: ['exifInfo'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
|
||||||
|
let file = null;
|
||||||
|
const asset = await this.findOne(authUser, query.did, query.aid);
|
||||||
|
|
||||||
|
// Handle Sending Images
|
||||||
|
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.isThumb === 'false' || !query.isThumb) {
|
||||||
|
file = createReadStream(asset.originalPath);
|
||||||
|
} else {
|
||||||
|
file = createReadStream(asset.resizePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
file.on('error', (error) => {
|
||||||
|
Logger.log(`Cannot create read stream ${error}`);
|
||||||
|
return new BadRequestException('Cannot Create Read Stream');
|
||||||
|
});
|
||||||
|
return new StreamableFile(file);
|
||||||
|
} else if (asset.type == AssetType.VIDEO) {
|
||||||
|
// Handle Handling Video
|
||||||
|
const { size } = await fileInfo(asset.originalPath);
|
||||||
|
const range = headers.range;
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
/** Extracting Start and End value from Range Header */
|
||||||
|
let [start, end] = range.replace(/bytes=/, '').split('-');
|
||||||
|
start = parseInt(start, 10);
|
||||||
|
end = end ? parseInt(end, 10) : size - 1;
|
||||||
|
|
||||||
|
if (!isNaN(start) && isNaN(end)) {
|
||||||
|
start = start;
|
||||||
|
end = size - 1;
|
||||||
|
}
|
||||||
|
if (isNaN(start) && !isNaN(end)) {
|
||||||
|
start = size - end;
|
||||||
|
end = size - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unavailable range request
|
||||||
|
if (start >= size || end >= size) {
|
||||||
|
console.error('Bad Request');
|
||||||
|
// Return the 416 Range Not Satisfiable.
|
||||||
|
res.status(416).set({
|
||||||
|
'Content-Range': `bytes */${size}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new BadRequestException('Bad Request Range');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sending Partial Content With HTTP Code 206 */
|
||||||
|
|
||||||
|
res.status(206).set({
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': end - start + 1,
|
||||||
|
'Content-Type': asset.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
|
||||||
|
|
||||||
|
return new StreamableFile(videoStream);
|
||||||
|
} else {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': asset.mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new StreamableFile(createReadStream(asset.originalPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto) {
|
||||||
|
let result = [];
|
||||||
|
|
||||||
|
const target = assetIds.ids;
|
||||||
|
for (let assetId of target) {
|
||||||
|
const res = await this.assetRepository.delete({
|
||||||
|
id: assetId,
|
||||||
|
userId: authUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.affected) {
|
||||||
|
result.push({
|
||||||
|
id: assetId,
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
id: assetId,
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
6
server/src/api-v1/asset/dto/delete-asset.dto.ts
Normal file
6
server/src/api-v1/asset/dto/delete-asset.dto.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeleteAssetDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
ids: string[];
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
import fs from 'fs';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
import { ExifEntity } from '../../api-v1/asset/entities/exif.entity';
|
||||||
|
|
||||||
|
@ -56,4 +57,23 @@ export class BackgroundTaskProcessor {
|
||||||
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
|
Logger.error(`Error extracting EXIF ${e.toString()}`, 'extractExif');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Process('delete-file-on-disk')
|
||||||
|
async deleteFileOnDisk(job) {
|
||||||
|
const { assets }: { assets: AssetEntity[] } = job.data;
|
||||||
|
|
||||||
|
assets.forEach(async (asset) => {
|
||||||
|
fs.unlink(asset.originalPath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log('error deleting ', asset.originalPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.unlink(asset.resizePath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log('error deleting ', asset.originalPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ export class BackgroundTaskService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
|
async extractExif(savedAsset: AssetEntity, fileName: string, fileSize: number) {
|
||||||
const job = await this.backgroundTaskQueue.add(
|
await this.backgroundTaskQueue.add(
|
||||||
'extract-exif',
|
'extract-exif',
|
||||||
{
|
{
|
||||||
savedAsset,
|
savedAsset,
|
||||||
|
@ -22,4 +22,14 @@ export class BackgroundTaskService {
|
||||||
{ jobId: randomUUID() },
|
{ jobId: randomUUID() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteFileOnDisk(assets: AssetEntity[]) {
|
||||||
|
await this.backgroundTaskQueue.add(
|
||||||
|
'delete-file-on-disk',
|
||||||
|
{
|
||||||
|
assets,
|
||||||
|
},
|
||||||
|
{ jobId: randomUUID() },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue