Compare commits

...

32 Commits

Author SHA1 Message Date
Alex
09a2dcca7a
Merge 39017922a9555b938e6d6d12396ea17e7b20908f into 63437529e1224f5e7879ce567b1a4502fb97573b 2024-10-01 23:12:57 +02:00
Jason Rasmussen
63437529e1
refactor(server): config file env (#13100) 2024-10-01 16:03:55 -04:00
Alex
39017922a9
fixed build 2024-09-30 11:34:55 +07:00
Alex
8e90913e0f
merge main 2024-09-30 11:19:36 +07:00
Alex
4c9a7a7ed5
wip 2024-09-11 14:26:36 -05:00
Alex
e69bcf3f70
add albums to dedicated item menu 2024-09-11 13:56:31 -05:00
Alex
bee7cd9683
add albums to dedicated item menu 2024-09-11 11:22:51 -05:00
Alex
4d45e36ea0
wip 2024-09-11 08:46:59 -05:00
Alex
701b2be633
place collections 2024-09-10 15:12:39 -05:00
Alex
8475cfb7a5
people collection page 2024-09-10 10:50:49 -05:00
Alex
c2e4d91b69
Merge branch 'main' of github.com:immich-app/immich into mobile/collections 2024-09-10 09:11:09 -05:00
Alex
0a9d8ac380
swipe to go back from album view 2024-09-09 14:58:15 -05:00
Alex
ad84a6e8c2
Merge branch 'main' of github.com:immich-app/immich into mobile/collections 2024-09-09 14:19:42 -05:00
Alex
bb50ccb1ca
local albums 2024-09-08 14:41:00 -05:00
Alex
f73deae77c
Merge branch 'main' of github.com:immich-app/immich into mobile/collections 2024-09-08 09:53:57 -05:00
Alex Tran
c26baea530
album on collections page does not change 2024-09-08 00:23:20 -05:00
Alex Tran
27d390a756
wip 2024-09-07 21:51:46 -05:00
Alex
9266197cd2
wip 2024-09-07 16:07:31 -05:00
Alex
3417330bdf
better album tile 2024-09-07 15:48:22 -05:00
Alex
2dc73c2987
quick filter for album 2024-09-07 14:48:32 -05:00
Alex
2320e7a0d7
smooth transition 2024-09-06 22:53:04 -05:00
Alex
77bfa5d445
search album 2024-09-06 22:46:38 -05:00
Alex
31f2b94396
better anchor menu 2024-09-06 22:05:52 -05:00
Alex
424de03204
wip: album collections page 2024-09-06 17:42:01 -05:00
Alex
30a3f827a2
wip: album collections page 2024-09-06 17:17:06 -05:00
Alex
34ea42c005
wip: album collesction page 2024-09-06 16:16:06 -05:00
Alex
27d5b134ac
Update on rounting 2024-09-06 16:04:22 -05:00
Alex
746354b779
Added rounting mechanism 2024-09-06 14:47:09 -05:00
Alex
9d6a177547
Added collection pages 2024-09-06 14:42:02 -05:00
Alex
c886fcab74
Added share partner button 2024-09-06 13:59:32 -05:00
Alex
14a5e982e6
Added people collections 2024-09-06 11:29:29 -05:00
Alex
a1df3878a9
add collection page 2024-09-05 15:29:33 -05:00
64 changed files with 1877 additions and 175 deletions

View File

@ -1,4 +1,15 @@
{
"collections": "Collections",
"on_this_device": "On this device",
"add_a_name": "Add a name",
"places": "Places",
"albums": "Albums",
"people": "People",
"shared_links": "Shared links",
"trash": "Trash",
"archived": "Archived",
"favorites": "Favorites",
"search_albums": "Search albums",
"action_common_back": "Back",
"action_common_cancel": "Cancel",
"action_common_clear": "Clear",

View File

@ -0,0 +1,405 @@
import 'dart:async';
import 'dart:math';
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/album/albumv2.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
enum QuickFilterMode {
all,
sharedWithMe,
myAlbums,
}
@RoutePage()
class AlbumsCollectionPage extends HookConsumerWidget {
const AlbumsCollectionPage({super.key, this.showImmichAppbar = false});
final bool showImmichAppbar;
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums =
ref.watch(albumProviderV2).where((album) => album.isRemote).toList();
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
final sorted = albumSortOption.sortFn(albums, albumSortIsReverse);
final isGrid = useState(false);
final searchController = useTextEditingController();
final debounceTimer = useRef<Timer?>(null);
final filterMode = useState(QuickFilterMode.all);
final userId = ref.watch(currentUserProvider)?.id;
toggleViewMode() {
isGrid.value = !isGrid.value;
}
onSearch(String value) {
debounceTimer.value?.cancel();
debounceTimer.value = Timer(const Duration(milliseconds: 300), () {
filterMode.value = QuickFilterMode.all;
ref.read(albumProviderV2.notifier).searchAlbums(value);
});
}
changeFilter(QuickFilterMode mode) {
filterMode.value = mode;
ref.read(albumProviderV2.notifier).filterAlbums(mode);
}
useEffect(
() {
searchController.addListener(() {
onSearch(searchController.text);
});
return () {
searchController.removeListener(() {
onSearch(searchController.text);
});
debounceTimer.value?.cancel();
};
},
[],
);
return Scaffold(
appBar: AppBar(
title: showImmichAppbar
? null
: Text(
"${'albums'.tr()} ${albums.length}",
),
bottom: showImmichAppbar
? const PreferredSize(
preferredSize: Size.fromHeight(0),
child: ImmichAppBar(),
)
: null,
),
body: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(18.0),
children: [
SearchBar(
backgroundColor: WidgetStatePropertyAll(
context.colorScheme.surfaceContainer,
),
autoFocus: false,
hintText: "search_albums".tr(),
onChanged: onSearch,
elevation: const WidgetStatePropertyAll(0.25),
controller: searchController,
leading: const Icon(Icons.search_rounded),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 16),
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: context.colorScheme.onSurface.withAlpha(10),
width: 0.5,
),
),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
QuickFilterButton(
label: 'All',
isSelected: filterMode.value == QuickFilterMode.all,
onTap: () => changeFilter(QuickFilterMode.all),
),
QuickFilterButton(
label: 'Shared with me',
isSelected: filterMode.value == QuickFilterMode.sharedWithMe,
onTap: () => changeFilter(QuickFilterMode.sharedWithMe),
),
QuickFilterButton(
label: 'My albums',
isSelected: filterMode.value == QuickFilterMode.myAlbums,
onTap: () => changeFilter(QuickFilterMode.myAlbums),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SortButton(),
IconButton(
icon: Icon(
isGrid.value
? Icons.view_list_rounded
: Icons.grid_view_outlined,
size: 24,
),
onPressed: toggleViewMode,
),
],
),
const SizedBox(height: 5),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: isGrid.value
? GridView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
itemBuilder: (context, index) {
return AlbumThumbnailCard(
album: sorted[index],
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sorted[index].id),
),
showOwner: true,
);
},
itemCount: sorted.length,
)
: ListView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemCount: sorted.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
title: Text(
sorted[index].name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: sorted[index].ownerId == userId
? Text(
'${sorted[index].assetCount} items',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color:
context.colorScheme.onSurfaceSecondary,
),
)
: sorted[index].ownerName != null
? Text(
'${sorted[index].assetCount} items • ${'album_thumbnail_shared_by'.tr(
args: [
sorted[index].ownerName!,
],
)}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium
?.copyWith(
color: context
.colorScheme.onSurfaceSecondary,
),
)
: null,
onTap: () => context.pushRoute(
AlbumViewerRoute(albumId: sorted[index].id),
),
leadingPadding: const EdgeInsets.only(
right: 16,
),
leading: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(15)),
child: ImmichThumbnail(
asset: sorted[index].thumbnail.value,
width: 80,
height: 80,
),
),
// minVerticalPadding: 1,
),
);
},
),
),
],
),
);
}
}
class QuickFilterButton extends StatelessWidget {
const QuickFilterButton({
super.key,
required this.isSelected,
required this.onTap,
required this.label,
});
final bool isSelected;
final VoidCallback onTap;
final String label;
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: onTap,
icon: isSelected
? Icon(
Icons.check_rounded,
color: context.colorScheme.onPrimary,
size: 18,
)
: const SizedBox.shrink(),
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
isSelected ? context.colorScheme.primary : Colors.transparent,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: context.colorScheme.onSurface.withAlpha(25),
width: 1,
),
),
),
),
label: Text(
label,
style: TextStyle(
color: isSelected
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
fontSize: 14,
),
),
);
}
}
class SortButton extends ConsumerWidget {
const SortButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
return MenuAnchor(
style: MenuStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
consumeOutsideTap: true,
menuChildren: AlbumSortMode.values
.map(
(mode) => MenuItemButton(
leadingIcon: albumSortOption == mode
? albumSortIsReverse
? Icon(
Icons.keyboard_arrow_down,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: Icon(
Icons.keyboard_arrow_up_rounded,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: const Icon(Icons.abc, color: Colors.transparent),
onPressed: () {
final selected = albumSortOption == mode;
// Switch direction
if (selected) {
ref
.read(albumSortOrderProvider.notifier)
.changeSortDirection(!albumSortIsReverse);
} else {
ref
.read(albumSortByOptionsProvider.notifier)
.changeSortMode(mode);
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all(const EdgeInsets.all(8)),
backgroundColor: WidgetStateProperty.all(
albumSortOption == mode
? context.colorScheme.primary
: Colors.transparent,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
child: Text(
mode.label.tr(),
style: context.textTheme.bodyMedium?.copyWith(
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
),
),
),
)
.toList(),
builder: (context, controller, child) {
return GestureDetector(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Transform.rotate(
angle: 90 * pi / 180,
child: Icon(
Icons.compare_arrows_rounded,
size: 18,
color: context.colorScheme.onSurface.withAlpha(225),
),
),
),
Text(
albumSortOption.label.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225),
),
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,55 @@
import 'package:auto_route/auto_route.dart';
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/providers/album/albumv2.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class LocalAlbumsCollectionPage extends HookConsumerWidget {
const LocalAlbumsCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return Scaffold(
appBar: AppBar(
title: Text('on_this_device'.tr()),
),
body: ListView.builder(
padding: const EdgeInsets.all(18.0),
itemCount: albums.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: ListTile(
contentPadding: const EdgeInsets.all(0),
dense: false,
visualDensity: VisualDensity.comfortable,
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: ImmichThumbnail(
asset: albums[index].thumbnail.value,
width: 60,
height: 90,
),
),
minVerticalPadding: 1,
title: Text(
albums[index].name,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Text('${albums[index].assetCount} items'),
onTap: () => context
.pushRoute(AlbumViewerRoute(albumId: albums[index].id)),
),
);
},
),
);
}
}

View File

@ -0,0 +1,310 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/albumv2.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/share_partner_button.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class CollectionsPage extends ConsumerWidget {
const CollectionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
return Scaffold(
appBar: const ImmichAppBar(
showUploadButton: false,
actions: [CreateNewButton(), SharePartnerButton()],
),
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const FavoritesRoute()),
icon: Icons.favorite_outline_rounded,
label: 'favorites'.tr(),
),
const SizedBox(width: 8),
ActionButton(
onPressed: () => context.pushRoute(const ArchiveRoute()),
icon: Icons.archive_outlined,
label: 'archived'.tr(),
),
],
),
const SizedBox(height: 8),
Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const SharedLinkRoute()),
icon: Icons.link_outlined,
label: 'shared_links'.tr(),
),
const SizedBox(width: 8),
trashEnabled
? ActionButton(
onPressed: () => context.pushRoute(const TrashRoute()),
icon: Icons.delete_outline_rounded,
label: 'trash'.tr(),
)
: const SizedBox.shrink(),
],
),
const SizedBox(height: 24),
const Wrap(
spacing: 8,
runSpacing: 16,
children: [
PeopleCollectionCard(),
AlbumsCollectionCard(),
AlbumsCollectionCard(isLocal: true),
PlacesCollectionCard(),
],
),
],
),
),
);
}
}
class PeopleCollectionCard extends ConsumerWidget {
const PeopleCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final size = MediaQuery.of(context).size.width * 0.5 - 20;
return GestureDetector(
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: people.widgetWhen(
onData: (people) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'people'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class AlbumsCollectionCard extends HookConsumerWidget {
final bool isLocal;
const AlbumsCollectionCard({super.key, this.isLocal = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = isLocal
? ref.watch(localAlbumsProvider)
: ref.watch(remoteAlbumsProvider);
final size = MediaQuery.of(context).size.width * 0.5 - 20;
return GestureDetector(
onTap: () => isLocal
? context.pushRoute(
const LocalAlbumsCollectionRoute(),
)
: context.pushRoute(AlbumsCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.take(4).map((album) {
return AlbumThumbnailCard(
album: album,
showTitle: false,
);
}).toList(),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
isLocal ? 'on_this_device'.tr() : 'albums'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class PlacesCollectionCard extends StatelessWidget {
const PlacesCollectionCard({super.key});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size.width * 0.5 - 20;
return GestureDetector(
onTap: () => context.pushRoute(const PlacesCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}
class ActionButton extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final String label;
const ActionButton({
super.key,
required this.onPressed,
required this.icon,
required this.label,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: FilledButton.icon(
onPressed: onPressed,
label: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(
label,
style: TextStyle(
color: context.colorScheme.onSurface,
fontSize: 14,
),
),
),
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
backgroundColor: context.colorScheme.surfaceContainerLow,
alignment: Alignment.centerLeft,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(25)),
side: BorderSide(
color: context.colorScheme.onSurface.withAlpha(10),
width: 1,
),
),
),
icon: Icon(
icon,
color: context.primaryColor,
),
),
);
}
}
class CreateNewButton extends StatelessWidget {
const CreateNewButton({super.key});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {},
borderRadius: const BorderRadius.all(Radius.circular(25)),
child: const Icon(
Icons.add,
size: 32,
),
);
}
}

View File

@ -0,0 +1,104 @@
import 'package:auto_route/auto_route.dart';
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/providers/search/people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@RoutePage()
class PeopleCollectionPage extends HookConsumerWidget {
const PeopleCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
showNameEditModel(
String personId,
String personName,
) {
return showDialog(
context: context,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
return Scaffold(
appBar: AppBar(
title: Text('people'.tr()),
),
body: people.when(
data: (people) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.85,
),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Column(
children: [
GestureDetector(
onTap: () {
context.pushRoute(
PersonResultRoute(
personId: person.id,
personName: person.name,
),
);
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: 96 / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: headers,
),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModel(person.id, person.name),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,
),
)
: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
person.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
),
],
);
},
);
},
error: (error, stack) => const Text("error"),
loading: () => const CircularProgressIndicator(),
),
);
}
}

View File

@ -0,0 +1,125 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class PlacesCollectionPage extends HookConsumerWidget {
const PlacesCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(getAllPlacesProvider);
return Scaffold(
appBar: AppBar(
title: Text('places'.tr()),
),
body: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 200,
width: context.width,
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(const MapRoute()),
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
places.when(
data: (places) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: places.length,
itemBuilder: (context, index) {
final place = places[index];
return PlaceTile(id: place.id, name: place.label);
},
);
},
error: (error, stask) => const Text('Error getting places'),
loading: () => const CircularProgressIndicator(),
),
],
),
);
}
}
class PlaceTile extends StatelessWidget {
const PlaceTile({super.key, required this.id, required this.name});
final String id;
final String name;
@override
Widget build(BuildContext context) {
final thumbnailUrl =
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail';
void navigateToPlace() {
context.pushRoute(
SearchInputRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(
city: name,
),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
),
),
);
}
return LargeLeadingTile(
onTap: () => navigateToPlace(),
title: Text(
name,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
leading: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
width: 80,
height: 80,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
),
),
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class LargeLeadingTile extends StatelessWidget {
const LargeLeadingTile({
super.key,
required this.leading,
required this.onTap,
required this.title,
this.subtitle,
this.leadingPadding = const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16.0,
),
});
final Widget leading;
final VoidCallback onTap;
final Widget title;
final Widget? subtitle;
final EdgeInsetsGeometry leadingPadding;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: leadingPadding,
child: leading,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.6,
child: title,
),
subtitle ?? const SizedBox.shrink(),
],
),
],
),
);
}
}

View File

@ -76,24 +76,36 @@ class TabControllerPage extends HookConsumerWidget {
selectedIcon: const Icon(Icons.photo_library),
label: const Text('tab_controller_nav_photos').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.photo_album_outlined),
selectedIcon: const Icon(Icons.photo_album),
label: const Text('albums').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.space_dashboard_outlined),
selectedIcon: const Icon(Icons.space_dashboard_rounded),
label: const Text('collections').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search),
label: const Text('tab_controller_nav_search').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.share_rounded),
selectedIcon: const Icon(Icons.share),
label: const Text('tab_controller_nav_sharing').tr(),
),
NavigationRailDestination(
padding: const EdgeInsets.all(4),
icon: const Icon(Icons.photo_album_outlined),
selectedIcon: const Icon(Icons.photo_album),
label: const Text('tab_controller_nav_library').tr(),
),
// NavigationRailDestination(
// padding: const EdgeInsets.all(4),
// icon: const Icon(Icons.share_rounded),
// selectedIcon: const Icon(Icons.share),
// label: const Text('tab_controller_nav_sharing').tr(),
// ),
// NavigationRailDestination(
// padding: const EdgeInsets.all(4),
// icon: const Icon(Icons.photo_album_outlined),
// selectedIcon: const Icon(Icons.photo_album),
// label: const Text('tab_controller_nav_library').tr(),
// ),
],
);
}
@ -125,27 +137,7 @@ class TabControllerPage extends HookConsumerWidget {
),
),
NavigationDestination(
label: 'tab_controller_nav_search'.tr(),
icon: const Icon(
Icons.search_rounded,
),
selectedIcon: Icon(
Icons.search,
color: context.primaryColor,
),
),
NavigationDestination(
label: 'tab_controller_nav_sharing'.tr(),
icon: const Icon(
Icons.group_outlined,
),
selectedIcon: Icon(
Icons.group,
color: context.primaryColor,
),
),
NavigationDestination(
label: 'tab_controller_nav_library'.tr(),
label: 'albums'.tr(),
icon: const Icon(
Icons.photo_album_outlined,
),
@ -156,17 +148,63 @@ class TabControllerPage extends HookConsumerWidget {
),
),
),
NavigationDestination(
label: 'collections'.tr(),
icon: const Icon(
Icons.space_dashboard_outlined,
),
selectedIcon: buildIcon(
Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
),
),
NavigationDestination(
label: 'tab_controller_nav_search'.tr(),
icon: const Icon(
Icons.search_rounded,
),
selectedIcon: Icon(
Icons.search,
color: context.primaryColor,
),
),
// NavigationDestination(
// label: 'tab_controller_nav_sharing'.tr(),
// icon: const Icon(
// Icons.group_outlined,
// ),
// selectedIcon: Icon(
// Icons.group,
// color: context.primaryColor,
// ),
// ),
// NavigationDestination(
// label: 'tab_controller_nav_library'.tr(),
// icon: const Icon(
// Icons.photo_album_outlined,
// ),
// selectedIcon: buildIcon(
// Icon(
// Icons.photo_album_rounded,
// color: context.primaryColor,
// ),
// ),
// ),
],
);
}
final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter(
routes: const [
PhotosRoute(),
SearchRoute(),
SharingRoute(),
LibraryRoute(),
routes: [
const PhotosRoute(),
AlbumsCollectionRoute(showImmichAppbar: true),
const CollectionsRoute(),
// SharingRoute(),
// LibraryRoute(),
const SearchRoute(),
],
duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition(

View File

@ -184,7 +184,7 @@ class LibraryPage extends HookConsumerWidget {
final sorted = albumSortOption.sortFn(remote, albumSortIsReverse);
final local = albums.where((a) => a.isLocal).toList();
Widget? shareTrashButton() {
Widget shareTrashButton() {
return trashEnabled
? InkWell(
onTap: () => context.pushRoute(const TrashRoute()),
@ -195,12 +195,12 @@ class LibraryPage extends HookConsumerWidget {
semanticLabel: 'profile_drawer_trash'.tr(),
),
)
: null;
: const SizedBox.shrink();
}
return Scaffold(
appBar: ImmichAppBar(
action: shareTrashButton(),
actions: [shareTrashButton()],
),
body: CustomScrollView(
slivers: [

View File

@ -92,6 +92,7 @@ class PersonResultPage extends HookConsumerWidget {
Text(
name.value,
style: context.textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
),
],
),
@ -125,9 +126,11 @@ class PersonResultPage extends HookConsumerWidget {
headers: ApiService.getRequestHeaders(),
),
),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: buildTitleBlock(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: buildTitleBlock(),
),
),
],
),

View File

@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/widgets/common/share_partner_button.dart';
import 'package:immich_mobile/widgets/partner/partner_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -215,25 +216,13 @@ class SharingPage extends HookConsumerWidget {
);
}
Widget sharePartnerButton() {
return InkWell(
onTap: () => context.pushRoute(const PartnerRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Icon(
Icons.swap_horizontal_circle_rounded,
size: 25,
semanticLabel: 'partner_page_title'.tr(),
),
);
}
return RefreshIndicator(
onRefresh: () async {
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
},
child: Scaffold(
appBar: ImmichAppBar(
action: sharePartnerButton(),
appBar: const ImmichAppBar(
actions: [SharePartnerButton()],
),
body: CustomScrollView(
slivers: [

View File

@ -12,7 +12,7 @@ import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, Isar db) : super([]) {
AlbumNotifier(this._albumService, this.db) : super([]) {
final query = db.albums
.filter()
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
@ -25,6 +25,7 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
}
final AlbumService _albumService;
final Isar db;
late final StreamSubscription<List<Album>> _streamSub;
Future<void> getAllAlbums() => Future.wait([
@ -64,6 +65,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
_streamSub.cancel();
super.dispose();
}
void searchAlbums(String value) async {
final query = db.albums
.filter()
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId))
.nameContains(value, caseSensitive: false);
final albums = await query.findAll();
state = albums;
}
}
final albumProvider =

View File

@ -0,0 +1,178 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/pages/collections/albums/albums_collection.page.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
class AlbumNotifierV2 extends StateNotifier<List<Album>> {
AlbumNotifierV2(this._albumService, this.db) : super([]) {
final query = db.albums.where();
query.findAll().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = query.watch().listen((data) => state = data);
}
final AlbumService _albumService;
final Isar db;
late final StreamSubscription<List<Album>> _streamSub;
Future<void> refreshAlbums() async {
// Future.wait([
// _albumService.refreshDeviceAlbums(),
// _albumService.refreshAllRemoteAlbums(),
// ]);
await _albumService.refreshDeviceAlbums();
await _albumService.refreshRemoteAlbums(isShared: false);
await _albumService.refreshRemoteAlbums(isShared: true);
}
Future<void> getDeviceAlbums() {
return _albumService.refreshDeviceAlbums();
}
Future<bool> deleteAlbum(Album album) {
return _albumService.deleteAlbum(album);
}
Future<Album?> createAlbum(
String albumTitle,
Set<Asset> assets,
) {
return _albumService.createAlbum(albumTitle, assets, []);
}
Future<Album?> getAlbumByName(String albumName, {bool remoteOnly = false}) {
return _albumService.getAlbumByName(albumName, remoteOnly);
}
/// Create an album on the server with the same name as the selected album for backup
/// First this will check if the album already exists on the server with name
/// If it does not exist, it will create the album on the server
Future<void> createSyncAlbum(
String albumName,
) async {
final album = await getAlbumByName(albumName, remoteOnly: true);
if (album != null) {
return;
}
await createAlbum(albumName, {});
}
void searchAlbums(String value) async {
final query = db.albums
.filter()
.remoteIdIsNotNull()
.nameContains(value, caseSensitive: false);
final albums = await query.findAll();
state = albums;
}
void filterAlbums(QuickFilterMode mode) async {
switch (mode) {
case QuickFilterMode.all:
state = await db.albums.filter().remoteIdIsNotNull().findAll();
return;
case QuickFilterMode.sharedWithMe:
state = await db.albums
.filter()
.remoteIdIsNotNull()
.owner(
(q) =>
q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId),
)
.findAll();
return;
case QuickFilterMode.myAlbums:
state = await db.albums
.filter()
.remoteIdIsNotNull()
.owner(
(q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId),
)
.findAll();
return;
}
}
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final albumProviderV2 =
StateNotifierProvider.autoDispose<AlbumNotifierV2, List<Album>>((ref) {
return AlbumNotifierV2(
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
);
});
class RemoteAlbumsNotifier extends StateNotifier<List<Album>> {
RemoteAlbumsNotifier(this.db) : super([]) {
final query = db.albums.filter().remoteIdIsNotNull();
query.findAll().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = query.watch().listen((data) => state = data);
}
final Isar db;
late final StreamSubscription<List<Album>> _streamSub;
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
class LocalAlbumsNotifier extends StateNotifier<List<Album>> {
LocalAlbumsNotifier(this.db) : super([]) {
final query = db.albums.filter().not().remoteIdIsNotNull();
query.findAll().then((value) {
if (mounted) {
state = value;
}
});
_streamSub = query.watch().listen((data) => state = data);
}
final Isar db;
late final StreamSubscription<List<Album>> _streamSub;
@override
void dispose() {
_streamSub.cancel();
super.dispose();
}
}
final localAlbumsProvider =
StateNotifierProvider.autoDispose<LocalAlbumsNotifier, List<Album>>((ref) {
return LocalAlbumsNotifier(ref.watch(dbProvider));
});
final remoteAlbumsProvider =
StateNotifierProvider.autoDispose<RemoteAlbumsNotifier, List<Album>>((ref) {
return RemoteAlbumsNotifier(ref.watch(dbProvider));
});

View File

@ -63,6 +63,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
case TabEnum.library:
_ref.read(albumProvider.notifier).getAllAlbums();
case TabEnum.collections:
// nothing to do
}
}

View File

@ -1,11 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum TabEnum {
home,
search,
sharing,
library,
}
enum TabEnum { home, search, sharing, library, collections }
/// Provides the currently active tab
final tabProvider = StateProvider<TabEnum>(

View File

@ -13,6 +13,11 @@ import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
import 'package:immich_mobile/pages/collections/albums/albums_collection.page.dart';
import 'package:immich_mobile/pages/collections/albums/local_albums_collection.page.dart';
import 'package:immich_mobile/pages/collections/people/people_collection.page.dart';
import 'package:immich_mobile/pages/collections/places/places_collection.part.dart';
import 'package:immich_mobile/pages/collections/collections.page.dart';
import 'package:immich_mobile/pages/common/activities.page.dart';
import 'package:immich_mobile/pages/common/album_additional_shared_user_selection.page.dart';
import 'package:immich_mobile/pages/common/album_asset_selection.page.dart';
@ -113,6 +118,14 @@ class AppRouter extends RootStackRouter {
page: LibraryRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: CollectionsRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: AlbumsCollectionRoute.page,
guards: [_authGuard, _duplicateGuard],
),
],
transitionsBuilder: TransitionsBuilders.fadeIn,
),
@ -135,7 +148,11 @@ class AppRouter extends RootStackRouter {
),
AutoRoute(page: EditImageRoute.page),
AutoRoute(page: CropImageRoute.page),
AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute(
page: FavoritesRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(
page: AllMotionPhotosRoute.page,
@ -181,8 +198,16 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: ArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute(
page: ArchiveRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: PartnerRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(
page: PartnerDetailRoute.page,
guards: [_authGuard, _duplicateGuard],
@ -198,10 +223,15 @@ class AppRouter extends RootStackRouter {
page: AlbumOptionsRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(
CustomRoute(
page: TrashRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: SharedLinkRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(
page: SharedLinkEditRoute.page,
@ -230,6 +260,26 @@ class AppRouter extends RootStackRouter {
page: HeaderSettingsRoute.page,
guards: [_duplicateGuard],
),
CustomRoute(
page: PeopleCollectionRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: AlbumsCollectionRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: LocalAlbumsCollectionRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: PlacesCollectionRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
];
}

View File

@ -319,6 +319,53 @@ class AlbumViewerRouteArgs {
}
}
/// generated route for
/// [AlbumsCollectionPage]
class AlbumsCollectionRoute extends PageRouteInfo<AlbumsCollectionRouteArgs> {
AlbumsCollectionRoute({
Key? key,
bool showImmichAppbar = false,
List<PageRouteInfo>? children,
}) : super(
AlbumsCollectionRoute.name,
args: AlbumsCollectionRouteArgs(
key: key,
showImmichAppbar: showImmichAppbar,
),
initialChildren: children,
);
static const String name = 'AlbumsCollectionRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<AlbumsCollectionRouteArgs>(
orElse: () => const AlbumsCollectionRouteArgs());
return AlbumsCollectionPage(
key: args.key,
showImmichAppbar: args.showImmichAppbar,
);
},
);
}
class AlbumsCollectionRouteArgs {
const AlbumsCollectionRouteArgs({
this.key,
this.showImmichAppbar = false,
});
final Key? key;
final bool showImmichAppbar;
@override
String toString() {
return 'AlbumsCollectionRouteArgs{key: $key, showImmichAppbar: $showImmichAppbar}';
}
}
/// generated route for
/// [AllMotionPhotosPage]
class AllMotionPhotosRoute extends PageRouteInfo<void> {
@ -555,6 +602,25 @@ class ChangePasswordRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [CollectionsPage]
class CollectionsRoute extends PageRouteInfo<void> {
const CollectionsRoute({List<PageRouteInfo>? children})
: super(
CollectionsRoute.name,
initialChildren: children,
);
static const String name = 'CollectionsRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const CollectionsPage();
},
);
}
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
@ -857,6 +923,25 @@ class LibraryRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [LocalAlbumsCollectionPage]
class LocalAlbumsCollectionRoute extends PageRouteInfo<void> {
const LocalAlbumsCollectionRoute({List<PageRouteInfo>? children})
: super(
LocalAlbumsCollectionRoute.name,
initialChildren: children,
);
static const String name = 'LocalAlbumsCollectionRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const LocalAlbumsCollectionPage();
},
);
}
/// generated route for
/// [LoginPage]
class LoginRoute extends PageRouteInfo<void> {
@ -1059,6 +1144,25 @@ class PartnerRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [PeopleCollectionPage]
class PeopleCollectionRoute extends PageRouteInfo<void> {
const PeopleCollectionRoute({List<PageRouteInfo>? children})
: super(
PeopleCollectionRoute.name,
initialChildren: children,
);
static const String name = 'PeopleCollectionRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const PeopleCollectionPage();
},
);
}
/// generated route for
/// [PermissionOnboardingPage]
class PermissionOnboardingRoute extends PageRouteInfo<void> {
@ -1149,6 +1253,25 @@ class PhotosRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [PlacesCollectionPage]
class PlacesCollectionRoute extends PageRouteInfo<void> {
const PlacesCollectionRoute({List<PageRouteInfo>? children})
: super(
PlacesCollectionRoute.name,
initialChildren: children,
);
static const String name = 'PlacesCollectionRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const PlacesCollectionPage();
},
);
}
/// generated route for
/// [RecentlyAddedPage]
class RecentlyAddedRoute extends PageRouteInfo<void> {

View File

@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/albumv2.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
@ -50,6 +51,10 @@ class TabNavigationObserver extends AutoRouterObserver {
ref.read(albumProvider.notifier).getAllAlbums();
}
if (route.name == 'CollectionsRoute') {
ref.read(albumProviderV2.notifier).refreshAlbums();
}
if (route.name == 'HomeRoute') {
ref.invalidate(memoryFutureProvider);
Future(() => ref.read(assetProvider.notifier).getAllAsset());

View File

@ -175,6 +175,49 @@ class AlbumService {
return changes;
}
/// V2
Future<bool> refreshAllRemoteAlbums() async {
if (!_remoteCompleter.isCompleted) {
// guard against concurrent calls
return _remoteCompleter.future;
}
_remoteCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final albumList = await Future.wait([
// _apiService.albumsApi.getAllAlbums(shared: true),
// _apiService.albumsApi.getAllAlbums(shared: false),
]);
// for (int i = 0; i < albumList.length; i++) {
// final albums = albumList[i];
// final isShared = i == 1;
// if (albums != null) {
// final hasChange = await _syncService.syncRemoteAlbumsToDb(
// albums,
// isShared: isShared,
// loadDetails: (dto) async => dto.assetCount == dto.assets.length
// ? dto
// : (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto,
// );
// if (hasChange) {
// changes = true;
// }
// }
// }
} catch (e) {
debugPrint("Error refreshing all albums: $e");
return false;
} finally {
_remoteCompleter.complete(changes);
}
debugPrint("refreshAllRemoteAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
Future<Album?> createAlbum(
String albumName,
Iterable<Asset> assets, [

View File

@ -12,12 +12,14 @@ class AlbumThumbnailCard extends StatelessWidget {
/// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album
final bool showOwner;
final bool showTitle;
const AlbumThumbnailCard({
super.key,
required this.album,
this.onTap,
this.showOwner = false,
this.showTitle = true,
});
final Album album;
@ -76,7 +78,7 @@ class AlbumThumbnailCard extends StatelessWidget {
: 'album_thumbnail_card_items'
.tr(args: ['${album.assetCount}']),
),
if (owner != null) const TextSpan(text: ' · '),
if (owner != null) const TextSpan(text: ' '),
if (owner != null) TextSpan(text: owner),
],
),
@ -102,21 +104,23 @@ class AlbumThumbnailCard extends StatelessWidget {
: buildAlbumThumbnail(),
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
album.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
if (showTitle) ...[
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
album.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
),
buildAlbumTextRow(),
buildAlbumTextRow(),
],
],
),
),

View File

@ -18,9 +18,10 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
final Widget? action;
final List<Widget>? actions;
final bool showUploadButton;
const ImmichAppBar({super.key, this.action});
const ImmichAppBar({super.key, this.actions, this.showUploadButton = true});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -184,12 +185,18 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
actions: [
if (action != null)
Padding(padding: const EdgeInsets.only(right: 20), child: action!),
Padding(
padding: const EdgeInsets.only(right: 20),
child: buildBackupIndicator(),
),
if (actions != null)
...actions!.map(
(action) => Padding(
padding: const EdgeInsets.only(right: 16),
child: action,
),
),
if (showUploadButton)
Padding(
padding: const EdgeInsets.only(right: 20),
child: buildBackupIndicator(),
),
Padding(
padding: const EdgeInsets.only(right: 20),
child: buildProfileIndicator(),

View File

@ -0,0 +1,21 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
class SharePartnerButton extends StatelessWidget {
const SharePartnerButton({super.key});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => context.pushRoute(const PartnerRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Icon(
Icons.swap_horizontal_circle_rounded,
size: 25,
semanticLabel: 'partner_page_title'.tr(),
),
);
}
}

View File

@ -13,6 +13,7 @@ class SearchMapThumbnail extends StatelessWidget {
});
final double size;
final bool showTitle = true;
@override
Widget build(BuildContext context) {

View File

@ -5,6 +5,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
@ -36,6 +37,7 @@ let instance: StorageCore | null;
export class StorageCore {
private constructor(
private assetRepository: IAssetRepository,
private configRepository: IConfigRepository,
private cryptoRepository: ICryptoRepository,
private moveRepository: IMoveRepository,
private personRepository: IPersonRepository,
@ -46,6 +48,7 @@ export class StorageCore {
static create(
assetRepository: IAssetRepository,
configRepository: IConfigRepository,
cryptoRepository: ICryptoRepository,
moveRepository: IMoveRepository,
personRepository: IPersonRepository,
@ -56,6 +59,7 @@ export class StorageCore {
if (!instance) {
instance = new StorageCore(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
@ -245,7 +249,11 @@ export class StorageCore {
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
return false;
}
const repos = { metadataRepo: this.systemMetadataRepository, logger: this.logger };
const repos = {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
const config = await getConfig(repos, { withCache: true });
if (assetInfo && config.storageTemplate.hashVerificationEnabled) {
const { checksum } = assetInfo;

View File

@ -3,6 +3,7 @@ import { VectorExtension } from 'src/interfaces/database.interface';
export const IConfigRepository = 'IConfigRepository';
export interface EnvData {
configFile?: string;
database: {
skipMigrations: boolean;
vectorExtension: VectorExtension;

View File

@ -6,6 +6,7 @@ import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
export class ConfigRepository implements IConfigRepository {
getEnv(): EnvData {
return {
configFile: process.env.IMMICH_CONFIG_FILE,
database: {
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: getVectorExtension(),

View File

@ -4,6 +4,7 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -19,6 +20,7 @@ import { partnerStub } from 'test/fixtures/partner.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -45,6 +47,7 @@ describe(AssetService.name, () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
@ -66,6 +69,7 @@ describe(AssetService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
@ -77,6 +81,7 @@ describe(AssetService.name, () => {
sut = new AssetService(
accessMock,
assetMock,
configMock,
jobMock,
systemMock,
userMock,

View File

@ -22,6 +22,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
IAssetDeleteJob,
@ -46,6 +47,7 @@ export class AssetService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@ -54,7 +56,7 @@ export class AssetService extends BaseService {
@Inject(IStackRepository) private stackRepository: IStackRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AssetService.name);
}

View File

@ -5,6 +5,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -20,6 +21,7 @@ import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -57,6 +59,7 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => {
let sut: AuthService;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let userMock: Mocked<IUserRepository>;
@ -89,6 +92,7 @@ describe('AuthService', () => {
}),
} as any);
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
@ -98,7 +102,17 @@ describe('AuthService', () => {
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
sut = new AuthService(
configMock,
cryptoMock,
eventMock,
systemMock,
loggerMock,
userMock,
sessionMock,
shareMock,
keyMock,
);
});
it('should be defined', () => {

View File

@ -31,6 +31,7 @@ import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -72,6 +73,7 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@ -81,7 +83,7 @@ export class AuthService extends BaseService {
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AuthService.name);
custom.setHttpOptionsDefaults({ timeout: 30_000 });

View File

@ -1,32 +1,30 @@
import { Inject } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getConfig, updateConfig } from 'src/utils/config';
export class BaseService {
constructor(
@Inject(IConfigRepository) protected configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) protected logger: ILoggerRepository,
) {}
private get repos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
}
getConfig(options: { withCache: boolean }) {
return getConfig(
{
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
},
options,
);
return getConfig(this.repos, options);
}
updateConfig(newConfig: SystemConfig) {
return updateConfig(
{
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
},
newConfig,
);
return updateConfig(this.repos, newConfig);
}
}

View File

@ -1,9 +1,11 @@
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { CliService } from 'src/services/cli.service';
import { userStub } from 'test/fixtures/user.stub';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
@ -13,18 +15,20 @@ import { Mocked, describe, it } from 'vitest';
describe(CliService.name, () => {
let sut: CliService;
let userMock: Mocked<IUserRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new CliService(cryptoMock, systemMock, userMock, loggerMock);
sut = new CliService(configMock, cryptoMock, systemMock, userMock, loggerMock);
});
describe('resetAdminPassword', () => {

View File

@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -10,12 +11,13 @@ import { BaseService } from 'src/services/base.service';
@Injectable()
export class CliService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(CliService.name);
}

View File

@ -1,4 +1,5 @@
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -8,6 +9,7 @@ import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -20,6 +22,7 @@ vitest.useFakeTimers();
describe(SearchService.name, () => {
let sut: DuplicateService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let searchMock: Mocked<ISearchRepository>;
let loggerMock: Mocked<ILoggerRepository>;
@ -28,13 +31,14 @@ describe(SearchService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new DuplicateService(systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock);
sut = new DuplicateService(configMock, systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock);
});
it('should work', () => {

View File

@ -4,6 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
@ -24,6 +25,7 @@ import { usePagination } from 'src/utils/pagination';
@Injectable()
export class DuplicateService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@ -31,7 +33,7 @@ export class DuplicateService extends BaseService {
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(DuplicateService.name);
}

View File

@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { defaults } from 'src/config';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
IJobRepository,
@ -18,6 +19,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { JobService } from 'src/services/job.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -37,6 +39,7 @@ const makeMockHandlers = (status: JobStatus) => {
describe(JobService.name, () => {
let sut: JobService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let personMock: Mocked<IPersonRepository>;
@ -46,13 +49,14 @@ describe(JobService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
personMock = newPersonRepositoryMock();
metricMock = newMetricRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new JobService(assetMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock);
sut = new JobService(assetMock, configMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock);
});
it('should work', () => {

View File

@ -5,6 +5,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ManualJobName } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import {
ConcurrentQueueName,
@ -49,6 +50,7 @@ export class JobService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@ -56,7 +58,7 @@ export class JobService extends BaseService {
@Inject(IMetricRepository) private metricRepository: IMetricRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(JobService.name);
}

View File

@ -5,6 +5,7 @@ import { mapLibrary } from 'src/dtos/library.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import {
@ -26,6 +27,7 @@ import { libraryStub } from 'test/fixtures/library.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
@ -43,15 +45,17 @@ describe(LibraryService.name, () => {
let sut: LibraryService;
let assetMock: Mocked<IAssetRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let jobMock: Mocked<IJobRepository>;
let libraryMock: Mocked<ILibraryRepository>;
let storageMock: Mocked<IStorageRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
libraryMock = newLibraryRepositoryMock();
assetMock = newAssetRepositoryMock();
@ -63,12 +67,13 @@ describe(LibraryService.name, () => {
sut = new LibraryService(
assetMock,
systemMock,
configMock,
cryptoMock,
databaseMock,
jobMock,
libraryMock,
storageMock,
databaseMock,
systemMock,
loggerMock,
);

View File

@ -18,6 +18,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
@ -48,15 +49,16 @@ export class LibraryService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private repository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(LibraryService.name);
}

View File

@ -12,6 +12,7 @@ import {
VideoCodec,
} from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -26,6 +27,7 @@ import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -39,6 +41,7 @@ import { Mocked } from 'vitest';
describe(MediaService.name, () => {
let sut: MediaService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>;
@ -50,6 +53,7 @@ describe(MediaService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock();
@ -61,6 +65,7 @@ describe(MediaService.name, () => {
sut = new MediaService(
assetMock,
configMock,
personMock,
jobMock,
mediaMock,

View File

@ -18,6 +18,7 @@ import {
VideoContainer,
} from 'src/enum';
import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
@ -55,6 +56,7 @@ export class MediaService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@ -64,10 +66,11 @@ export class MediaService extends BaseService {
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MediaService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,

View File

@ -6,6 +6,7 @@ import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@ -29,6 +30,7 @@ import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
@ -46,10 +48,14 @@ import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(MetadataService.name, () => {
let sut: MetadataService;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoRepository: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let mapMock: Mocked<IMapRepository>;
let metadataMock: Mocked<IMetadataRepository>;
@ -57,16 +63,15 @@ describe(MetadataService.name, () => {
let mediaMock: Mocked<IMediaRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let eventMock: Mocked<IEventRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let tagMock: Mocked<ITagRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let tagMock: Mocked<ITagRepository>;
let sut: MetadataService;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoRepository = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
mapMock = newMapRepositoryMock();
@ -85,9 +90,10 @@ describe(MetadataService.name, () => {
sut = new MetadataService(
albumMock,
assetMock,
eventMock,
configMock,
cryptoRepository,
databaseMock,
eventMock,
jobMock,
mapMock,
mediaMock,

View File

@ -15,6 +15,7 @@ import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
@ -103,9 +104,10 @@ export class MetadataService extends BaseService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMapRepository) private mapRepository: IMapRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@ -118,10 +120,11 @@ export class MetadataService extends BaseService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MetadataService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,

View File

@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -18,6 +19,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -66,6 +68,7 @@ const configs = {
describe(NotificationService.name, () => {
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
@ -77,6 +80,7 @@ describe(NotificationService.name, () => {
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
@ -85,6 +89,7 @@ describe(NotificationService.name, () => {
userMock = newUserRepositoryMock();
sut = new NotificationService(
configMock,
eventMock,
systemMock,
notificationMock,

View File

@ -5,6 +5,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import {
IEmailJob,
@ -28,6 +29,7 @@ import { getPreferences } from 'src/utils/preferences';
@Injectable()
export class NotificationService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@ -37,7 +39,7 @@ export class NotificationService extends BaseService {
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(NotificationService.name);
}

View File

@ -4,6 +4,7 @@ import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -23,6 +24,7 @@ import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -67,6 +69,7 @@ const detectFaceMock: DetectedFaces = {
describe(PersonService.name, () => {
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>;
@ -82,6 +85,7 @@ describe(PersonService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
machineLearningMock = newMachineLearningRepositoryMock();
@ -92,9 +96,11 @@ describe(PersonService.name, () => {
searchMock = newSearchRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new PersonService(
accessMock,
assetMock,
configMock,
machineLearningMock,
moveMock,
mediaMock,

View File

@ -33,6 +33,7 @@ import {
} from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
@ -70,6 +71,7 @@ export class PersonService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@ -81,10 +83,11 @@ export class PersonService extends BaseService {
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(PersonService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
repository,

View File

@ -1,6 +1,7 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
@ -12,6 +13,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
@ -25,6 +27,7 @@ vitest.useFakeTimers();
describe(SearchService.name, () => {
let sut: SearchService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let machineMock: Mocked<IMachineLearningRepository>;
let personMock: Mocked<IPersonRepository>;
@ -34,6 +37,7 @@ describe(SearchService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
personMock = newPersonRepositoryMock();
@ -41,7 +45,16 @@ describe(SearchService.name, () => {
partnerMock = newPartnerRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SearchService(systemMock, machineMock, personMock, searchMock, assetMock, partnerMock, loggerMock);
sut = new SearchService(
configMock,
systemMock,
machineMock,
personMock,
searchMock,
assetMock,
partnerMock,
loggerMock,
);
});
it('should work', () => {

View File

@ -17,6 +17,7 @@ import {
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
@ -30,6 +31,7 @@ import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class SearchService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@ -38,7 +40,7 @@ export class SearchService extends BaseService {
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SearchService.name);
}

View File

@ -1,4 +1,5 @@
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@ -6,6 +7,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerService } from 'src/services/server.service';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
@ -16,6 +18,7 @@ import { Mocked } from 'vitest';
describe(ServerService.name, () => {
let sut: ServerService;
let configMock: Mocked<IConfigRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>;
@ -24,6 +27,7 @@ describe(ServerService.name, () => {
let cryptoMock: Mocked<ICryptoRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
@ -31,7 +35,7 @@ describe(ServerService.name, () => {
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
sut = new ServerService(configMock, userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
});
it('should work', () => {

View File

@ -15,6 +15,7 @@ import {
UsageByUserDto,
} from 'src/dtos/server.dto';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@ -23,13 +24,13 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { asHumanReadable } from 'src/utils/bytes';
import { isUsingConfigFile } from 'src/utils/config';
import { mimeTypes } from 'src/utils/mime-types';
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class ServerService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@ -37,7 +38,7 @@ export class ServerService extends BaseService {
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(ServerService.name);
}
@ -91,6 +92,7 @@ export class ServerService extends BaseService {
async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } =
await this.getConfig({ withCache: false });
const { configFile } = this.configRepository.getEnv();
return {
smartSearch: isSmartSearchEnabled(machineLearning),
@ -105,7 +107,7 @@ export class ServerService extends BaseService {
oauth: oauth.enabled,
oauthAutoLaunch: oauth.autoLaunch,
passwordLogin: passwordLogin.enabled,
configFile: isUsingConfigFile(),
configFile: !!configFile,
email: notifications.smtp.enabled,
};
}

View File

@ -3,6 +3,7 @@ import _ from 'lodash';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { SharedLinkType } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -13,6 +14,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
@ -22,6 +24,7 @@ import { Mocked } from 'vitest';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;
let accessMock: IAccessRepositoryMock;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
@ -29,12 +32,13 @@ describe(SharedLinkService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
logMock = newLoggerRepositoryMock();
sut = new SharedLinkService(accessMock, cryptoMock, logMock, shareMock, systemMock);
sut = new SharedLinkService(accessMock, configMock, cryptoMock, logMock, shareMock, systemMock);
});
it('should work', () => {

View File

@ -15,6 +15,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -27,12 +28,13 @@ import { OpenGraphTags } from 'src/utils/misc';
export class SharedLinkService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SharedLinkService.name);
}

View File

@ -1,5 +1,6 @@
import { SystemConfig } from 'src/config';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -11,6 +12,7 @@ import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -22,6 +24,7 @@ import { Mocked } from 'vitest';
describe(SmartInfoService.name, () => {
let sut: SmartInfoService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>;
let searchMock: Mocked<ISearchRepository>;
@ -31,13 +34,24 @@ describe(SmartInfoService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
jobMock = newJobRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, systemMock, loggerMock);
sut = new SmartInfoService(
assetMock,
configMock,
databaseMock,
jobMock,
machineMock,
searchMock,
systemMock,
loggerMock,
);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
});

View File

@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import {
@ -26,6 +27,7 @@ import { usePagination } from 'src/utils/pagination';
export class SmartInfoService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@ -33,7 +35,7 @@ export class SmartInfoService extends BaseService {
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SmartInfoService.name);
}

View File

@ -4,6 +4,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { JobStatus } from 'src/interfaces/job.interface';
@ -19,6 +20,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -33,6 +35,7 @@ describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let moveMock: Mocked<IMoveRepository>;
@ -49,6 +52,7 @@ describe(StorageTemplateService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
albumMock = newAlbumRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
moveMock = newMoveRepositoryMock();
@ -63,6 +67,7 @@ describe(StorageTemplateService.name, () => {
sut = new StorageTemplateService(
albumMock,
assetMock,
configMock,
systemMock,
moveMock,
personMock,

View File

@ -18,6 +18,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
@ -63,6 +64,7 @@ export class StorageTemplateService extends BaseService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@ -72,10 +74,11 @@ export class StorageTemplateService extends BaseService {
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(StorageTemplateService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,

View File

@ -12,11 +12,13 @@ import {
VideoCodec,
VideoContainer,
} from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemConfigService } from 'src/services/system-config.service';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
@ -187,16 +189,18 @@ const updatedConfig = Object.freeze<SystemConfig>({
describe(SystemConfigService.name, () => {
let sut: SystemConfigService;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let eventMock: Mocked<IEventRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
delete process.env.IMMICH_CONFIG_FILE;
systemMock = newSystemMetadataRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SystemConfigService(systemMock, eventMock, loggerMock);
sut = new SystemConfigService(configMock, eventMock, systemMock, loggerMock);
});
it('should work', () => {
@ -231,8 +235,7 @@ describe(SystemConfigService.name, () => {
});
it('should load the config from a json file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
@ -241,7 +244,7 @@ describe(SystemConfigService.name, () => {
});
it('should log errors with the config file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`);
@ -256,7 +259,7 @@ describe(SystemConfigService.name, () => {
});
it('should load the config from a yaml file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = `
ffmpeg:
crf: 30
@ -275,7 +278,7 @@ describe(SystemConfigService.name, () => {
});
it('should accept an empty configuration file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
@ -284,7 +287,7 @@ describe(SystemConfigService.name, () => {
});
it('should allow underscores in the machine learning url', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { machineLearning: { url: 'immich_machine_learning' } };
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
@ -300,7 +303,7 @@ describe(SystemConfigService.name, () => {
for (const { should, externalDomain, result } of externalDomainTests) {
it(`should normalize an external domain ${should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { server: { externalDomain } };
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
@ -310,7 +313,7 @@ describe(SystemConfigService.name, () => {
}
it('should warn for unknown options in yaml', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = `
unknownOption: true
`;
@ -331,7 +334,7 @@ describe(SystemConfigService.name, () => {
for (const test of tests) {
it(`should ${test.should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.warn) {
@ -390,7 +393,7 @@ describe(SystemConfigService.name, () => {
});
it('should throw an error if a config file is in use', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(systemMock.set).not.toHaveBeenCalled();

View File

@ -15,21 +15,23 @@ import {
import { OnEvent } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
import { LogLevel } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { clearConfigCache, isUsingConfigFile } from 'src/utils/config';
import { clearConfigCache } from 'src/utils/config';
import { toPlainObject } from 'src/utils/object';
@Injectable()
export class SystemConfigService extends BaseService {
constructor(
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SystemConfigService.name);
}
@ -67,7 +69,8 @@ export class SystemConfigService extends BaseService {
}
async updateSystemConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
if (isUsingConfigFile()) {
const { configFile } = this.configRepository.getEnv();
if (configFile) {
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
}

View File

@ -2,6 +2,7 @@ import { BadRequestException, InternalServerErrorException, NotFoundException }
import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -14,6 +15,7 @@ import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -34,6 +36,7 @@ describe(UserService.name, () => {
let cryptoRepositoryMock: Mocked<ICryptoRepository>;
let albumMock: Mocked<IAlbumRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
@ -41,14 +44,24 @@ describe(UserService.name, () => {
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new UserService(albumMock, cryptoRepositoryMock, jobMock, storageMock, systemMock, userMock, loggerMock);
sut = new UserService(
albumMock,
configMock,
cryptoRepositoryMock,
jobMock,
storageMock,
systemMock,
userMock,
loggerMock,
);
userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),

View File

@ -12,6 +12,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -26,6 +27,7 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti
export class UserService extends BaseService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@ -33,7 +35,7 @@ export class UserService extends BaseService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(UserService.name);
}

View File

@ -1,6 +1,7 @@
import { DateTime } from 'luxon';
import { serverVersion } from 'src/constants';
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -9,6 +10,7 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { VersionService } from 'src/services/version.service';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
@ -30,6 +32,7 @@ const mockRelease = (version: string) => ({
describe(VersionService.name, () => {
let sut: VersionService;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
@ -39,6 +42,7 @@ describe(VersionService.name, () => {
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
@ -47,7 +51,16 @@ describe(VersionService.name, () => {
versionMock = newVersionHistoryRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new VersionService(databaseMock, eventMock, jobMock, serverMock, systemMock, versionMock, loggerMock);
sut = new VersionService(
configMock,
databaseMock,
eventMock,
jobMock,
serverMock,
systemMock,
versionMock,
loggerMock,
);
});
it('should work', () => {

View File

@ -6,6 +6,7 @@ import { OnEvent } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -27,6 +28,7 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re
@Injectable()
export class VersionService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@ -35,7 +37,7 @@ export class VersionService extends BaseService {
@Inject(IVersionHistoryRepository) private versionRepository: IVersionHistoryRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(VersionService.name);
}

View File

@ -6,6 +6,7 @@ import * as _ from 'lodash';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -15,6 +16,7 @@ import { DeepPartial } from 'typeorm';
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
type RepoDeps = {
configRepo: IConfigRepository;
metadataRepo: ISystemMetadataRepository;
logger: ILoggerRepository;
};
@ -28,10 +30,6 @@ export const clearConfigCache = () => {
lastUpdated = null;
};
export const isUsingConfigFile = () => {
return !!process.env.IMMICH_CONFIG_FILE;
};
export const getConfig = async (repos: RepoDeps, { withCache }: { withCache: boolean }): Promise<SystemConfig> => {
if (!withCache || !config) {
const timestamp = lastUpdated;
@ -80,11 +78,12 @@ const loadFromFile = async ({ metadataRepo, logger }: RepoDeps, filepath: string
};
const buildConfig = async (repos: RepoDeps) => {
const { metadataRepo, logger } = repos;
const { configRepo, metadataRepo, logger } = repos;
const { configFile } = configRepo.getEnv();
// load partial
const partial = isUsingConfigFile()
? await loadFromFile(repos, process.env.IMMICH_CONFIG_FILE as string)
const partial = configFile
? await loadFromFile(repos, configFile)
: await metadataRepo.get(SystemMetadataKey.SYSTEM_CONFIG);
// merge with defaults
@ -106,7 +105,7 @@ const buildConfig = async (repos: RepoDeps) => {
// validate full config
const errors = await validate(plainToInstance(SystemConfigDto, config));
if (errors.length > 0) {
if (isUsingConfigFile()) {
if (configFile) {
throw new Error(`Invalid value(s) in file: ${errors}`);
} else {
logger.error('Validation error', errors);