1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

Merge branch 'main' into feat/notification-email-template

This commit is contained in:
Tim Van Onckelen 2024-11-18 11:43:00 +01:00 committed by GitHub
commit c110bc0b07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 587 additions and 387 deletions

View File

@ -98,6 +98,10 @@ SELECT * FROM "move_history";
SELECT * FROM "users";
```
```sql title="Get owner info from asset ID"
SELECT "users".* FROM "users" JOIN "assets" ON "users"."id" = "assets"."ownerId" WHERE "assets"."id" = 'fa310b01-2f26-4b7a-9042-d578226e021f';
```
## System Config
```sql title="Custom settings"

View File

@ -163,11 +163,15 @@ describe('/server', () => {
expect(body).toEqual({
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [
{
quotaSizeInBytes: null,
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'Immich Admin',
userId: admin.userId,
videos: 0,
@ -176,6 +180,8 @@ describe('/server', () => {
quotaSizeInBytes: null,
photos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
userName: 'User 1',
userId: nonAdmin.userId,
videos: 0,

View File

@ -21,6 +21,7 @@ const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
const Color red400 = Color(0xFFEF5350);
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
ImmichColorPreset.indigo: ImmichTheme(

View File

@ -18,6 +18,9 @@ class CurrentUploadAsset {
this.iCloudAsset,
});
@pragma('vm:prefer-inline')
bool get isIcloudAsset => iCloudAsset != null && iCloudAsset!;
CurrentUploadAsset copyWith({
String? id,
DateTime? fileCreatedAt,

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/services/partner.service.dart';
@ -9,9 +10,19 @@ import 'package:isar/isar.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
final query = db.users.filter().isPartnerSharedWithEqualTo(true);
query.findAll().then((partners) => state = partners);
query.watch().listen((partners) => state = partners);
Function eq = const ListEquality<User>().equals;
final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById();
query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
}
Future<bool> updatePartner(User partner, {required bool inTimeline}) {
@ -31,9 +42,19 @@ final partnerSharedWithProvider =
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
PartnerSharedByNotifier(Isar db) : super([]) {
final query = db.users.filter().isPartnerSharedByEqualTo(true);
query.findAll().then((partners) => state = partners);
streamSub = query.watch().listen((partners) => state = partners);
Function eq = const ListEquality<User>().equals;
final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById();
query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
streamSub = query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
}
late final StreamSubscription<List<User>> streamSub;

View File

@ -0,0 +1,102 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupAssetInfoTable extends ConsumerWidget {
const BackupAssetInfoTable({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final asset = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.currentUploadAsset),
)
: ref.watch(backupProvider.select((value) => value.currentUploadAsset));
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(
color: context.colorScheme.outlineVariant,
width: 1,
),
children: [
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
'backup_controller_page_filename',
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [asset.fileName, asset.fileType.toLowerCase()],
),
),
),
],
),
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_created",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [_getAssetCreationDate(asset)],
),
),
),
],
),
TableRow(
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_id",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(args: [asset.id]),
),
),
],
),
],
),
);
}
@pragma('vm:prefer-inline')
String _getAssetCreationDate(CurrentUploadAsset asset) {
return DateFormat.yMMMMd().format(asset.fileCreatedAt.toLocal());
}
}

View File

@ -1,296 +1,43 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
import 'package:immich_mobile/widgets/backup/asset_info_table.dart';
import 'package:immich_mobile/widgets/backup/error_chip.dart';
import 'package:immich_mobile/widgets/backup/icloud_download_progress_bar.dart';
import 'package:immich_mobile/widgets/backup/upload_progress_bar.dart';
import 'package:immich_mobile/widgets/backup/upload_stats.dart';
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
class CurrentUploadingAssetInfoBox extends StatelessWidget {
const CurrentUploadingAssetInfoBox({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var isManualUpload = ref.watch(backupProvider).backupProgress ==
BackUpProgressEnum.manualInProgress;
var asset = !isManualUpload
? ref.watch(backupProvider).currentUploadAsset
: ref.watch(manualUploadProvider).currentUploadAsset;
var uploadProgress = !isManualUpload
? ref.watch(backupProvider).progressInPercentage
: ref.watch(manualUploadProvider).progressInPercentage;
var uploadFileProgress = !isManualUpload
? ref.watch(backupProvider).progressInFileSize
: ref.watch(manualUploadProvider).progressInFileSize;
var uploadFileSpeed = !isManualUpload
? ref.watch(backupProvider).progressInFileSpeed
: ref.watch(manualUploadProvider).progressInFileSpeed;
var iCloudDownloadProgress =
ref.watch(backupProvider).iCloudDownloadProgress;
final isShowThumbnail = useState(false);
String formatUploadFileSpeed(double uploadFileSpeed) {
if (uploadFileSpeed < 1024) {
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
} else if (uploadFileSpeed < 1024 * 1024) {
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
} else {
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
}
}
String getAssetCreationDate() {
return DateFormat.yMMMMd().format(
DateTime.parse(
asset.fileCreatedAt.toString(),
).toLocal(),
);
}
Widget buildErrorChip() {
return ActionChip(
avatar: Icon(
Icons.info,
color: Colors.red[400],
),
elevation: 1,
visualDensity: VisualDensity.compact,
label: Text(
"backup_controller_page_failed",
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
fontSize: 11,
),
).tr(
args: [ref.watch(errorBackupListProvider).length.toString()],
),
backgroundColor: Colors.white,
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
);
}
Widget buildAssetInfoTable() {
return Table(
border: TableBorder.all(
color: context.colorScheme.outlineVariant,
width: 1,
),
Widget build(BuildContext context) {
return ListTile(
isThreeLine: true,
leading: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 30,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
'backup_controller_page_filename',
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [asset.fileName, asset.fileType.toLowerCase()],
),
),
),
],
),
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_created",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
args: [getAssetCreationDate()],
),
),
),
],
),
TableRow(
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_id",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(args: [asset.id]),
),
),
],
),
Text(
"backup_controller_page_uploading_file_info",
style: context.textTheme.titleSmall,
).tr(),
const BackupErrorChip(),
],
),
subtitle: Column(
children: [
if (Platform.isIOS) const IcloudDownloadProgressBar(),
const BackupUploadProgressBar(),
const BackupUploadStats(),
const BackupAssetInfoTable(),
],
);
}
buildiCloudDownloadProgerssBar() {
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
"iCloud Download",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${iCloudDownloadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
],
),
);
}
return const SizedBox();
}
buildUploadProgressBar() {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
if (asset.iCloudAsset != null && asset.iCloudAsset!)
SizedBox(
width: 110,
child: Text(
"Immich Upload",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
),
],
),
);
}
buildUploadStats() {
return Padding(
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
uploadFileProgress,
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
Text(
formatUploadFileSpeed(uploadFileSpeed),
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
],
),
);
}
return FutureBuilder<Asset?>(
future: ref.read(assetMediaRepositoryProvider).get(asset.id),
builder: (context, thumbnail) => ListTile(
isThreeLine: true,
leading: AnimatedCrossFade(
alignment: Alignment.centerLeft,
firstChild: GestureDetector(
onTap: () => isShowThumbnail.value = false,
child: thumbnail.hasData
? ClipRRect(
borderRadius: BorderRadius.circular(5),
child: ImmichThumbnail(
asset: thumbnail.data,
width: 50,
height: 50,
),
)
: const SizedBox(
width: 50,
height: 50,
child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
),
),
),
secondChild: GestureDetector(
onTap: () => isShowThumbnail.value = true,
child: Icon(
Icons.image_outlined,
color: context.primaryColor,
size: 30,
),
),
crossFadeState: isShowThumbnail.value
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"backup_controller_page_uploading_file_info",
style: context.textTheme.titleSmall,
).tr(),
if (ref.watch(errorBackupListProvider).isNotEmpty) buildErrorChip(),
],
),
subtitle: Column(
children: [
if (Platform.isIOS) buildiCloudDownloadProgerssBar(),
buildUploadProgressBar(),
buildUploadStats(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: buildAssetInfoTable(),
),
],
),
),
);
}

View File

@ -0,0 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/error_chip_text.dart';
class BackupErrorChip extends ConsumerWidget {
const BackupErrorChip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasErrors =
ref.watch(errorBackupListProvider.select((value) => value.isNotEmpty));
if (!hasErrors) {
return const SizedBox();
}
return ActionChip(
avatar: const Icon(
Icons.info,
color: red400,
),
elevation: 1,
visualDensity: VisualDensity.compact,
label: const BackupErrorChipText(),
backgroundColor: Colors.white,
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
);
}
}

View File

@ -0,0 +1,28 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
class BackupErrorChipText extends ConsumerWidget {
const BackupErrorChipText({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(errorBackupListProvider).length;
if (count == 0) {
return const SizedBox();
}
return const Text(
"backup_controller_page_failed",
style: TextStyle(
color: red400,
fontWeight: FontWeight.bold,
fontSize: 11,
),
).tr(
args: [count.toString()],
);
}
}

View File

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class IcloudDownloadProgressBar extends ConsumerWidget {
const IcloudDownloadProgressBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final isIcloudAsset = isManualUpload
? ref.watch(
manualUploadProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
)
: ref.watch(
backupProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
);
if (!isIcloudAsset) {
return const SizedBox();
}
final iCloudDownloadProgress = ref
.watch(backupProvider.select((value) => value.iCloudDownloadProgress));
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
"iCloud Download",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: iCloudDownloadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${iCloudDownloadProgress ~/ 1}%",
style: const TextStyle(fontSize: 12),
),
],
),
);
}
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupUploadProgressBar extends ConsumerWidget {
const BackupUploadProgressBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final isIcloudAsset = isManualUpload
? ref.watch(
manualUploadProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
)
: ref.watch(
backupProvider
.select((value) => value.currentUploadAsset.isIcloudAsset),
);
final uploadProgress = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.progressInPercentage),
)
: ref.watch(
backupProvider.select((value) => value.progressInPercentage),
);
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
if (isIcloudAsset)
SizedBox(
width: 110,
child: Text(
"Immich Upload",
style: context.textTheme.labelSmall,
),
),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
),
],
),
);
}
}

View File

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupUploadStats extends ConsumerWidget {
const BackupUploadStats({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select(
(value) => value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final uploadFileProgress = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.progressInFileSize),
)
: ref.watch(backupProvider.select((value) => value.progressInFileSize));
final uploadFileSpeed = isManualUpload
? ref.watch(
manualUploadProvider.select((value) => value.progressInFileSpeed),
)
: ref.watch(
backupProvider.select((value) => value.progressInFileSpeed),
);
return Padding(
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
uploadFileProgress,
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
Text(
_formatUploadFileSpeed(uploadFileSpeed),
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
],
),
);
}
@pragma('vm:prefer-inline')
String _formatUploadFileSpeed(double uploadFileSpeed) {
if (uploadFileSpeed < 1024) {
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
} else if (uploadFileSpeed < 1024 * 1024) {
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
} else {
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
}
}
}

Binary file not shown.

View File

@ -11017,7 +11017,9 @@
{
"photos": 1,
"videos": 1,
"diskUsageRaw": 1
"diskUsageRaw": 2,
"usagePhotos": 1,
"usageVideos": 1
}
],
"items": {
@ -11026,6 +11028,16 @@
"title": "Array of usage for each user",
"type": "array"
},
"usagePhotos": {
"default": 0,
"format": "int64",
"type": "integer"
},
"usageVideos": {
"default": 0,
"format": "int64",
"type": "integer"
},
"videos": {
"default": 0,
"type": "integer"
@ -11035,6 +11047,8 @@
"photos",
"usage",
"usageByUser",
"usagePhotos",
"usageVideos",
"videos"
],
"type": "object"
@ -12614,6 +12628,14 @@
"format": "int64",
"type": "integer"
},
"usagePhotos": {
"format": "int64",
"type": "integer"
},
"usageVideos": {
"format": "int64",
"type": "integer"
},
"userId": {
"type": "string"
},
@ -12628,6 +12650,8 @@
"photos",
"quotaSizeInBytes",
"usage",
"usagePhotos",
"usageVideos",
"userId",
"userName",
"videos"

View File

@ -976,6 +976,8 @@ export type UsageByUserDto = {
photos: number;
quotaSizeInBytes: number | null;
usage: number;
usagePhotos: number;
usageVideos: number;
userId: string;
userName: string;
videos: number;
@ -984,6 +986,8 @@ export type ServerStatsResponseDto = {
photos: number;
usage: number;
usageByUser: UsageByUserDto[];
usagePhotos: number;
usageVideos: number;
videos: number;
};
export type ServerStorageResponseDto = {

View File

@ -86,6 +86,10 @@ export class UsageByUserDto {
@ApiProperty({ type: 'integer', format: 'int64' })
usage!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usagePhotos!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usageVideos!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null;
}
@ -99,6 +103,12 @@ export class ServerStatsResponseDto {
@ApiProperty({ type: 'integer', format: 'int64' })
usage = 0;
@ApiProperty({ type: 'integer', format: 'int64' })
usagePhotos = 0;
@ApiProperty({ type: 'integer', format: 'int64' })
usageVideos = 0;
@ApiProperty({
isArray: true,
type: UsageByUserDto,
@ -107,7 +117,9 @@ export class ServerStatsResponseDto {
{
photos: 1,
videos: 1,
diskUsageRaw: 1,
diskUsageRaw: 2,
usagePhotos: 1,
usageVideos: 1,
},
],
})

View File

@ -11,6 +11,8 @@ export interface UserStatsQueryResponse {
photos: number;
videos: number;
usage: number;
usagePhotos: number;
usageVideos: number;
quotaSizeInBytes: number | null;
}

View File

@ -140,7 +140,23 @@ SELECT
"assets"."libraryId" IS NULL
),
0
) AS "usage"
) AS "usage",
COALESCE(
SUM("exif"."fileSizeInByte") FILTER (
WHERE
"assets"."libraryId" IS NULL
AND "assets"."type" = 'IMAGE'
),
0
) AS "usagePhotos",
COALESCE(
SUM("exif"."fileSizeInByte") FILTER (
WHERE
"assets"."libraryId" IS NULL
AND "assets"."type" = 'VIDEO'
),
0
) AS "usageVideos"
FROM
"users" "users"
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id"

View File

@ -108,6 +108,14 @@ export class UserRepository implements IUserRepository {
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage')
.addSelect(
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`,
'usagePhotos',
)
.addSelect(
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`,
'usageVideos',
)
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
@ -119,6 +127,8 @@ export class UserRepository implements IUserRepository {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
stat.usagePhotos = Number(stat.usagePhotos);
stat.usageVideos = Number(stat.usageVideos);
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
}

View File

@ -185,6 +185,8 @@ describe(ServerService.name, () => {
photos: 10,
videos: 11,
usage: 12_345,
usagePhotos: 1,
usageVideos: 11_345,
quotaSizeInBytes: 0,
},
{
@ -193,6 +195,8 @@ describe(ServerService.name, () => {
photos: 10,
videos: 20,
usage: 123_456,
usagePhotos: 100,
usageVideos: 23_456,
quotaSizeInBytes: 0,
},
{
@ -201,6 +205,8 @@ describe(ServerService.name, () => {
photos: 100,
videos: 0,
usage: 987_654,
usagePhotos: 900,
usageVideos: 87_654,
quotaSizeInBytes: 0,
},
]);
@ -209,11 +215,15 @@ describe(ServerService.name, () => {
photos: 120,
videos: 31,
usage: 1_123_455,
usagePhotos: 1001,
usageVideos: 122_455,
usageByUser: [
{
photos: 10,
quotaSizeInBytes: 0,
usage: 12_345,
usagePhotos: 1,
usageVideos: 11_345,
userName: '1 User',
userId: 'user1',
videos: 11,
@ -222,6 +232,8 @@ describe(ServerService.name, () => {
photos: 10,
quotaSizeInBytes: 0,
usage: 123_456,
usagePhotos: 100,
usageVideos: 23_456,
userName: '2 User',
userId: 'user2',
videos: 20,
@ -230,6 +242,8 @@ describe(ServerService.name, () => {
photos: 100,
quotaSizeInBytes: 0,
usage: 987_654,
usagePhotos: 900,
usageVideos: 87_654,
userName: '3 User',
userId: 'user3',
videos: 0,

View File

@ -126,11 +126,16 @@ export class ServerService extends BaseService {
usage.photos = user.photos;
usage.videos = user.videos;
usage.usage = user.usage;
usage.usagePhotos = user.usagePhotos;
usage.usageVideos = user.usageVideos;
usage.quotaSizeInBytes = user.quotaSizeInBytes;
serverStats.photos += usage.photos;
serverStats.videos += usage.videos;
serverStats.usage += usage.usage;
serverStats.usagePhotos += usage.usagePhotos;
serverStats.usageVideos += usage.usageVideos;
serverStats.usageByUser.push(usage);
}

View File

@ -16,6 +16,8 @@
photos: 0,
videos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [],
},
}: Props = $props();
@ -105,8 +107,12 @@
class="flex h-[50px] w-full place-items-center text-center odd:bg-immich-gray even:bg-immich-bg odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50"
>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.photos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.videos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm"
>{user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)})</td
>
<td class="w-1/4 text-ellipsis px-2 text-sm"
>{user.videos.toLocaleString($locale)} ({getByteUnitString(user.usageVideos, $locale, 0)})</td
>
<td class="w-1/4 text-ellipsis px-2 text-sm">
{getByteUnitString(user.usage, $locale, 0)}
{#if user.quotaSizeInBytes}

View File

@ -58,7 +58,6 @@
const handleToggle = (user: UserResponseDto) => {
if (Object.keys(selectedUsers).includes(user.id)) {
delete selectedUsers[user.id];
selectedUsers = selectedUsers;
} else {
selectedUsers[user.id] = { user, role: AlbumUserRole.Editor };
}
@ -67,7 +66,6 @@
const handleChangeRole = (user: UserResponseDto, role: AlbumUserRole | 'none') => {
if (role === 'none') {
delete selectedUsers[user.id];
selectedUsers = selectedUsers;
} else {
selectedUsers[user.id].role = role;
}

View File

@ -110,7 +110,6 @@
try {
await deleteActivity({ id: reaction.id });
reactions.splice(index, 1);
reactions = reactions;
if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
onDeleteLike();
} else {
@ -143,8 +142,6 @@
message = '';
onAddComment();
// Re-render the activity feed
reactions = reactions;
} catch (error) {
handleError(error, $t('errors.unable_to_add_comment'));
} finally {

View File

@ -43,10 +43,10 @@
import DetailPanel from './detail-panel.svelte';
import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import PanoramaViewer from './panorama-viewer.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
interface Props {
assetStore?: AssetStore | null;
@ -512,7 +512,7 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<PanoramaViewer {asset} />
<ImagePanoramaViewer {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}

View File

@ -0,0 +1,32 @@
<script lang="ts">
import { getAssetOriginalUrl, getKey } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
const { asset }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ id, size: AssetMediaSize.Preview, key: getKey() });
return URL.createObjectURL(data);
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
panorama={data}
originalImageUrl={isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : undefined}
/>
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
</div>

View File

@ -1,54 +0,0 @@
<script lang="ts">
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getKey } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, AssetTypeEnum, viewAsset, type AssetResponseDto } from '@immich/sdk';
import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
interface Props {
asset: { id: string; type: AssetTypeEnum.Video } | AssetResponseDto;
}
let { asset }: Props = $props();
const photoSphereConfigs =
asset.type === AssetTypeEnum.Video
? ([
import('@photo-sphere-viewer/equirectangular-video-adapter').then(
({ EquirectangularVideoAdapter }) => EquirectangularVideoAdapter,
),
import('@photo-sphere-viewer/video-plugin').then(({ VideoPlugin }) => [VideoPlugin]),
true,
import('@photo-sphere-viewer/video-plugin/index.css'),
] as [PromiseLike<AdapterConstructor>, Promise<PluginConstructor[]>, true, unknown])
: ([undefined, [], false] as [undefined, [], false]);
const originalImageUrl =
asset.type === AssetTypeEnum.Image && isWebCompatibleImage(asset) ? getAssetOriginalUrl(asset.id) : null;
const loadAssetData = async () => {
if (asset.type === AssetTypeEnum.Video) {
return { source: getAssetOriginalUrl(asset.id) };
}
if (originalImageUrl && $alwaysLoadOriginalFile) {
return getAssetOriginalUrl(asset.id);
}
const data = await viewAsset({ id: asset.id, size: AssetMediaSize.Preview, key: getKey() });
const url = URL.createObjectURL(data);
return url;
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<!-- the photo sphere viewer is quite large, so lazy load it in parallel with loading the data -->
{#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])}
<LoadingSpinner />
{:then [data, module, adapter, plugins, navbar]}
<module.default panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} {originalImageUrl} />
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
</div>

View File

@ -12,7 +12,7 @@
interface Props {
panorama: string | { source: string };
originalImageUrl: string | null;
originalImageUrl?: string;
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;

View File

@ -0,0 +1,29 @@
<script lang="ts">
import { getAssetOriginalUrl } from '$lib/utils';
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n';
interface Props {
assetId: string;
}
const { assetId }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
import('@photo-sphere-viewer/equirectangular-video-adapter').then((module) => module.EquirectangularVideoAdapter),
import('@photo-sphere-viewer/video-plugin').then((module) => module.VideoPlugin),
import('@photo-sphere-viewer/video-plugin/index.css'),
]);
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer panorama={{ source: getAssetOriginalUrl(assetId) }} plugins={[videoPlugin]} {adapter} navbar />
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
</div>

View File

@ -1,8 +1,7 @@
<script lang="ts">
import { AssetTypeEnum } from '@immich/sdk';
import { ProjectionType } from '$lib/constants';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
interface Props {
assetId: string;
@ -30,7 +29,7 @@
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} />
<VideoPanoramaViewer {assetId} />
{:else}
<VideoNativeViewer
{loopVideo}

View File

@ -90,7 +90,6 @@
for (const person of people) {
person.isHidden = personIsHidden[person.id];
}
people = people;
onClose();
} catch (error) {

View File

@ -101,15 +101,9 @@
const handleReset = (id: string) => {
if (selectedPersonToReassign[id]) {
delete selectedPersonToReassign[id];
// trigger reactivity
selectedPersonToReassign = selectedPersonToReassign;
}
if (selectedPersonToCreate[id]) {
delete selectedPersonToCreate[id];
// trigger reactivity
selectedPersonToCreate = selectedPersonToCreate;
}
};

View File

@ -35,12 +35,10 @@
}
selectedIds.add(option.value);
selectedIds = selectedIds;
};
const handleRemove = (tag: string) => {
selectedIds.delete(tag);
selectedIds = selectedIds;
};
const onsubmit = (event: Event) => {

View File

@ -112,7 +112,6 @@
assets.findIndex((a) => a.id === action.asset.id),
1,
);
assets = assets;
if (assets.length === 0) {
await goto(AppRoute.PHOTOS);
} else if (currentViewAssetIndex === assets.length) {

View File

@ -7,7 +7,7 @@
export type SearchFilter = {
query: string;
queryType: 'smart' | 'metadata';
personIds: Set<string>;
personIds: SvelteSet<string>;
location: SearchLocationFilter;
camera: SearchCameraFilter;
date: SearchDateFilter;

View File

@ -9,9 +9,10 @@
import { handleError } from '$lib/utils/handle-error';
import { t } from 'svelte-i18n';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import type { SvelteSet } from 'svelte/reactivity';
interface Props {
selectedPeople: Set<string>;
selectedPeople: SvelteSet<string>;
}
let { selectedPeople = $bindable() }: Props = $props();
@ -43,7 +44,6 @@
} else {
selectedPeople.add(id);
}
selectedPeople = selectedPeople;
}
const filterPeople = (list: PersonResponseDto[], name: string) => {

View File

@ -117,7 +117,6 @@
await updatePartner({ id: partner.user.id, updatePartnerDto: { inTimeline } });
partner.inTimeline = inTimeline;
partners = partners;
} catch (error) {
handleError(error, $t('errors.unable_to_update_timeline_display_status'));
}

View File

@ -35,7 +35,6 @@
}
selectedAssetIds.add(suggestedAsset.id);
selectedAssetIds = selectedAssetIds;
});
onDestroy(() => {
@ -48,13 +47,10 @@
} else {
selectedAssetIds.add(asset.id);
}
selectedAssetIds = selectedAssetIds;
};
const onSelectNone = () => {
selectedAssetIds.clear();
selectedAssetIds = selectedAssetIds;
};
const onSelectAll = () => {

View File

@ -35,8 +35,6 @@
person.updatedAt = Date.now().toString();
}
});
// trigger reactivity
people = people;
});
});
</script>

View File

@ -74,9 +74,6 @@
person.updatedAt = new Date().toISOString();
}
}
// trigger reactivity
people = people;
});
});
@ -146,9 +143,6 @@
message: $t('change_name_successfully'),
type: NotificationType.Info,
});
// trigger reactivity
people = people;
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));
}

View File

@ -156,8 +156,6 @@
searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items);
searchResultAlbums = searchResultAlbums;
searchResultAssets = searchResultAssets;
nextPage = assets.nextPage ? Number(assets.nextPage) : null;
} catch (error) {