diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 787cebcf45..9d88ff1a98 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -258,5 +258,9 @@ "motion_photos_page_title": "Motion Photos", "search_page_motion_photos": "Motion Photos", "search_page_recently_added": "Recently added", - "search_page_categories": "Categories" + "search_page_categories": "Categories", + "search_page_screenshots": "Screenshots", + "search_page_selfies": "Selfies", + "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", + "search_suggestion_list_smart_search_hint_2": "m:your-search-term" } diff --git a/mobile/flutter_01.png b/mobile/flutter_01.png deleted file mode 100644 index e496e25ffb..0000000000 Binary files a/mobile/flutter_01.png and /dev/null differ diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 348fa69d15..13553e718c 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -243,6 +243,7 @@ class BackupService { ); req.headers["Authorization"] = "Bearer ${Store.get(StoreKey.accessToken)}"; + req.headers["Transfer-Encoding"] = "chunked"; req.fields['deviceAssetId'] = entity.id; req.fields['deviceId'] = deviceId; diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 12441be464..a216bdb038 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -454,6 +454,10 @@ class EmailInput extends StatelessWidget { labelText: 'login_form_label_email'.tr(), border: const OutlineInputBorder(), hintText: 'login_form_email_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), ), validator: _validateInput, autovalidateMode: AutovalidateMode.always, @@ -487,6 +491,10 @@ class PasswordInput extends StatelessWidget { labelText: 'login_form_label_password'.tr(), border: const OutlineInputBorder(), hintText: 'login_form_password_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), ), autofillHints: const [AutofillHints.password], keyboardType: TextInputType.text, diff --git a/mobile/lib/modules/search/providers/search_result_page.provider.dart b/mobile/lib/modules/search/providers/search_result_page.provider.dart index ab93f0670a..02d3ccd0f4 100644 --- a/mobile/lib/modules/search/providers/search_result_page.provider.dart +++ b/mobile/lib/modules/search/providers/search_result_page.provider.dart @@ -18,7 +18,7 @@ class SearchResultPageNotifier extends StateNotifier { final SearchService _searchService; - void search(String searchTerm) async { + void search(String searchTerm, {bool clipEnable = true}) async { state = state.copyWith( searchResult: [], isError: false, @@ -26,7 +26,10 @@ class SearchResultPageNotifier extends StateNotifier { isSuccess: false, ); - List? assets = await _searchService.searchAsset(searchTerm); + List? assets = await _searchService.searchAsset( + searchTerm, + clipEnable: clipEnable, + ); if (assets != null) { state = state.copyWith( diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart index 936f1bc295..746bb455fa 100644 --- a/mobile/lib/modules/search/services/search.service.dart +++ b/mobile/lib/modules/search/services/search.service.dart @@ -29,11 +29,16 @@ class SearchService { } } - Future?> searchAsset(String searchTerm) async { + Future?> searchAsset( + String searchTerm, { + bool clipEnable = true, + }) async { // TODO search in local DB: 1. when offline, 2. to find local assets try { - final SearchResponseDto? results = await _apiService.searchApi - .search(query: searchTerm, clip: true); + final SearchResponseDto? results = await _apiService.searchApi.search( + query: searchTerm, + clip: clipEnable, + ); if (results == null) { return null; } diff --git a/mobile/lib/modules/search/ui/curated_row.dart b/mobile/lib/modules/search/ui/curated_row.dart index 2c09828679..9f5130764f 100644 --- a/mobile/lib/modules/search/ui/curated_row.dart +++ b/mobile/lib/modules/search/ui/curated_row.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/shared/models/store.dart'; class CuratedRow extends StatelessWidget { final List content; final double imageSize; - + /// Callback with the content and the index when tapped final Function(CuratedContent, int)? onTap; @@ -19,7 +19,6 @@ class CuratedRow extends StatelessWidget { @override Widget build(BuildContext context) { - // Guard empty [content] if (content.isEmpty) { // Return empty thumbnail diff --git a/mobile/lib/modules/search/ui/explore_grid.dart b/mobile/lib/modules/search/ui/explore_grid.dart index b43899b1e2..788a05dd75 100644 --- a/mobile/lib/modules/search/ui/explore_grid.dart +++ b/mobile/lib/modules/search/ui/explore_grid.dart @@ -22,8 +22,7 @@ class ExploreGrid extends StatelessWidget { width: 100, child: ThumbnailWithInfo( textInfo: '', - onTap: () { - }, + onTap: () {}, ), ), ); @@ -42,9 +41,10 @@ class ExploreGrid extends StatelessWidget { return ThumbnailWithInfo( imageUrl: thumbnailRequestUrl, textInfo: content.label, + borderRadius: 0, onTap: () { AutoRouter.of(context).push( - SearchResultRoute(searchTerm: content.label), + SearchResultRoute(searchTerm: 'm:${content.label}'), ); }, ); @@ -52,5 +52,4 @@ class ExploreGrid extends StatelessWidget { itemCount: curatedContent.length, ); } - } diff --git a/mobile/lib/modules/search/ui/search_bar.dart b/mobile/lib/modules/search/ui/search_bar.dart index 0ef743f395..bec2a98427 100644 --- a/mobile/lib/modules/search/ui/search_bar.dart +++ b/mobile/lib/modules/search/ui/search_bar.dart @@ -30,7 +30,10 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget { }, icon: const Icon(Icons.arrow_back_ios_rounded), ) - : const Icon(Icons.search_rounded), + : const Icon( + Icons.search_rounded, + size: 20, + ), title: TextField( controller: searchTermController, focusNode: searchFocusNode, @@ -55,6 +58,8 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget { hintText: 'search_bar_hint'.tr(), hintStyle: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + fontWeight: FontWeight.w500, + fontSize: 14, ), enabledBorder: const UnderlineInputBorder( borderSide: BorderSide(color: Colors.transparent), diff --git a/mobile/lib/modules/search/ui/search_result_grid.dart b/mobile/lib/modules/search/ui/search_result_grid.dart new file mode 100644 index 0000000000..14ccfb2949 --- /dev/null +++ b/mobile/lib/modules/search/ui/search_result_grid.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; + +class SearchResultGrid extends HookConsumerWidget { + const SearchResultGrid({super.key, required this.assets}); + + final List assets; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 1, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: assets.length, + itemBuilder: (context, index) { + final asset = assets[index]; + return ThumbnailImage( + asset: asset, + assetList: assets, + useGrayBoxPlaceholder: true, + ); + }, + ); + } +} diff --git a/mobile/lib/modules/search/ui/search_suggestion_list.dart b/mobile/lib/modules/search/ui/search_suggestion_list.dart index 95feb12005..e4c6061953 100644 --- a/mobile/lib/modules/search/ui/search_suggestion_list.dart +++ b/mobile/lib/modules/search/ui/search_suggestion_list.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; @@ -12,6 +13,7 @@ class SearchSuggestionList extends ConsumerWidget { final searchTerm = ref.watch(searchPageStateProvider).searchTerm; final searchSuggestion = ref.watch(searchPageStateProvider).searchSuggestion; + var isDarkTheme = Theme.of(context).brightness == Brightness.dark; return Container( color: searchTerm.isEmpty @@ -19,13 +21,38 @@ class SearchSuggestionList extends ConsumerWidget { : Theme.of(context).scaffoldBackgroundColor, child: CustomScrollView( slivers: [ + SliverToBoxAdapter( + child: Container( + color: isDarkTheme ? Colors.grey[800] : Colors.grey[100], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: 'search_suggestion_list_smart_search_hint_1'.tr(), + style: Theme.of(context).textTheme.bodyMedium, + ), + TextSpan( + text: 'search_suggestion_list_smart_search_hint_2'.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ) + ], + ), + ), + ), + ), + ), SliverFillRemaining( hasScrollBody: true, child: ListView.builder( itemBuilder: ((context, index) { return ListTile( onTap: () { - onSubmitted(searchSuggestion[index]); + onSubmitted("m:${searchSuggestion[index]}"); }, title: Text(searchSuggestion[index]), ); diff --git a/mobile/lib/modules/search/ui/thumbnail_with_info.dart b/mobile/lib/modules/search/ui/thumbnail_with_info.dart index 3e5e37f9c7..1d297497fe 100644 --- a/mobile/lib/modules/search/ui/thumbnail_with_info.dart +++ b/mobile/lib/modules/search/ui/thumbnail_with_info.dart @@ -1,13 +1,16 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/utils/capitalize_first_letter.dart'; +// ignore: must_be_immutable class ThumbnailWithInfo extends StatelessWidget { - const ThumbnailWithInfo({ + ThumbnailWithInfo({ Key? key, required this.textInfo, this.imageUrl, this.noImageIcon, + this.borderRadius = 10, required this.onTap, }) : super(key: key); @@ -15,6 +18,7 @@ class ThumbnailWithInfo extends StatelessWidget { final String? imageUrl; final Function onTap; final IconData? noImageIcon; + double borderRadius; @override Widget build(BuildContext context) { @@ -29,12 +33,12 @@ class ThumbnailWithInfo extends StatelessWidget { children: [ Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(25), + borderRadius: BorderRadius.circular(borderRadius), color: isDarkMode ? Colors.grey[900] : Colors.grey[100], ), child: imageUrl != null ? ClipRRect( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(borderRadius), child: CachedNetworkImage( width: double.infinity, height: double.infinity, @@ -55,15 +59,32 @@ class ThumbnailWithInfo extends StatelessWidget { ), ), ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + color: Colors.white, + gradient: LinearGradient( + begin: FractionalOffset.topCenter, + end: FractionalOffset.bottomCenter, + colors: [ + Colors.grey.withOpacity(0.0), + textInfo == '' + ? Colors.black.withOpacity(0.1) + : Colors.black.withOpacity(0.5), + ], + stops: const [0.0, 1.0], + ), + ), + ), Positioned( bottom: 12, left: 14, child: Text( - textInfo, + textInfo == '' ? textInfo : textInfo.capitalizeFirstLetter(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, - fontSize: 12, + fontSize: 14, ), ), ), diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index 06cc5e687b..9f9628179f 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -27,6 +27,7 @@ class SearchPage extends HookConsumerWidget { ref.watch(getCuratedObjectProvider); var isDarkTheme = Theme.of(context).brightness == Brightness.dark; double imageSize = MediaQuery.of(context).size.width / 3; + TextStyle categoryTitleStyle = const TextStyle( fontWeight: FontWeight.bold, fontSize: 14.0, @@ -46,7 +47,11 @@ class SearchPage extends HookConsumerWidget { searchFocusNode.unfocus(); ref.watch(searchPageStateProvider.notifier).disableSearch(); - AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm)); + AutoRouter.of(context).push( + SearchResultRoute( + searchTerm: searchTerm, + ), + ); } buildPlaces() { @@ -67,7 +72,9 @@ class SearchPage extends HookConsumerWidget { imageSize: imageSize, onTap: (content, index) { AutoRouter.of(context).push( - SearchResultRoute(searchTerm: content.label), + SearchResultRoute( + searchTerm: 'm:${content.label}', + ), ); }, ), @@ -99,7 +106,9 @@ class SearchPage extends HookConsumerWidget { imageSize: imageSize, onTap: (content, index) { AutoRouter.of(context).push( - SearchResultRoute(searchTerm: content.label), + SearchResultRoute( + searchTerm: 'm:${content.label}', + ), ); }, ), @@ -131,7 +140,7 @@ class SearchPage extends HookConsumerWidget { children: [ Text( "search_page_places", - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), TextButton( child: Text( @@ -162,7 +171,7 @@ class SearchPage extends HookConsumerWidget { children: [ Text( "search_page_things", - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), TextButton( child: Text( @@ -186,7 +195,7 @@ class SearchPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( 'search_page_your_activity', - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), ), ListTile( @@ -201,13 +210,7 @@ class SearchPage extends HookConsumerWidget { const FavoritesRoute(), ), ), - const Padding( - padding: EdgeInsets.only( - left: 72, - right: 16, - ), - child: Divider(), - ), + const CategoryDivider(), ListTile( leading: Icon( Icons.schedule_outlined, @@ -226,9 +229,36 @@ class SearchPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text( 'search_page_categories', - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleSmall, ).tr(), ), + ListTile( + title: Text('Screenshots', style: categoryTitleStyle).tr(), + leading: Icon( + Icons.screenshot, + color: categoryIconColor, + ), + onTap: () => AutoRouter.of(context).push( + SearchResultRoute( + searchTerm: 'screenshots', + ), + ), + ), + const CategoryDivider(), + ListTile( + title: Text('search_page_selfies', style: categoryTitleStyle) + .tr(), + leading: Icon( + Icons.photo_camera_front_outlined, + color: categoryIconColor, + ), + onTap: () => AutoRouter.of(context).push( + SearchResultRoute( + searchTerm: 'selfies', + ), + ), + ), + const CategoryDivider(), ListTile( title: Text('search_page_videos', style: categoryTitleStyle) .tr(), @@ -240,13 +270,7 @@ class SearchPage extends HookConsumerWidget { const AllVideosRoute(), ), ), - const Padding( - padding: EdgeInsets.only( - left: 72, - right: 16, - ), - child: Divider(), - ), + const CategoryDivider(), ListTile( title: Text( 'search_page_motion_photos', @@ -270,3 +294,20 @@ class SearchPage extends HookConsumerWidget { ); } } + +class CategoryDivider extends StatelessWidget { + const CategoryDivider({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only( + left: 72, + right: 16, + ), + child: Divider( + height: 0, + ), + ); + } +} diff --git a/mobile/lib/modules/search/views/search_result_page.dart b/mobile/lib/modules/search/views/search_result_page.dart index 83b4b0deac..d6b1ea9a9e 100644 --- a/mobile/lib/modules/search/views/search_result_page.dart +++ b/mobile/lib/modules/search/views/search_result_page.dart @@ -6,12 +6,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart'; import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart'; +import 'package:immich_mobile/modules/search/ui/search_result_grid.dart'; import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; +class SearchType { + SearchType({required this.isClip, required this.searchTerm}); + + final bool isClip; + final String searchTerm; +} + +SearchType _getSearchType(String searchTerm) { + if (searchTerm.startsWith('m:')) { + return SearchType(isClip: false, searchTerm: searchTerm.substring(2)); + } else { + return SearchType(isClip: true, searchTerm: searchTerm); + } +} + class SearchResultPage extends HookConsumerWidget { - const SearchResultPage({Key? key, required this.searchTerm}) - : super(key: key); + const SearchResultPage({ + Key? key, + required this.searchTerm, + }) : super(key: key); final String searchTerm; @@ -20,6 +38,8 @@ class SearchResultPage extends HookConsumerWidget { final searchTermController = useTextEditingController(text: ""); final isNewSearch = useState(false); final currentSearchTerm = useState(searchTerm); + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + final isDisplayDateGroup = useState(true); FocusNode? searchFocusNode; @@ -27,9 +47,16 @@ class SearchResultPage extends HookConsumerWidget { () { searchFocusNode = FocusNode(); + var searchType = _getSearchType(searchTerm); + searchType.isClip + ? isDisplayDateGroup.value = false + : isDisplayDateGroup.value = true; + Future.delayed( Duration.zero, - () => ref.read(searchResultPageProvider.notifier).search(searchTerm), + () => ref + .read(searchResultPageProvider.notifier) + .search(searchType.searchTerm, clipEnable: searchType.isClip), ); return () => searchFocusNode?.dispose(); }, @@ -41,7 +68,15 @@ class SearchResultPage extends HookConsumerWidget { searchFocusNode?.unfocus(); isNewSearch.value = false; currentSearchTerm.value = newSearchTerm; - ref.watch(searchResultPageProvider.notifier).search(newSearchTerm); + + var searchType = _getSearchType(newSearchTerm); + searchType.isClip + ? isDisplayDateGroup.value = false + : isDisplayDateGroup.value = true; + + ref + .watch(searchResultPageProvider.notifier) + .search(searchType.searchTerm, clipEnable: searchType.isClip); } buildTextField() { @@ -74,6 +109,12 @@ class SearchResultPage extends HookConsumerWidget { focusedBorder: const UnderlineInputBorder( borderSide: BorderSide(color: Colors.transparent), ), + hintStyle: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16.0, + color: + isDarkTheme ? Colors.grey[500] : Colors.black.withOpacity(0.5), + ), ), ); } @@ -121,11 +162,16 @@ class SearchResultPage extends HookConsumerWidget { return const Center(child: ImmichLoadingIndicator()); } - if (searchResultPageState.isSuccess) { - return ImmichAssetGrid( + if (isDisplayDateGroup.value) { + return ImmichAssetGrid( assets: allSearchAssets, - ); + ); + } else { + return SearchResultGrid( + assets: allSearchAssets, + ); + } } return const SizedBox(); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d8a9ada72d..02b7ce5452 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -144,14 +144,9 @@ class _$AppRouter extends RootStackRouter { ); }, RecentlyAddedRoute.name: (routeData) { - return CustomPage( + return MaterialPageX( routeData: routeData, child: const RecentlyAddedPage(), - transitionsBuilder: TransitionsBuilders.noTransition, - durationInMilliseconds: 200, - reverseDurationInMilliseconds: 200, - opaque: true, - barrierDismissible: false, ); }, AssetSelectionRoute.name: (routeData) { diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 77571781f8..b7e8559f1e 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -79,6 +79,7 @@ ThemeData immichLightTheme = ThemeData( ), titleSmall: TextStyle( fontSize: 16.0, + fontWeight: FontWeight.bold, ), titleMedium: TextStyle( fontSize: 18.0, @@ -176,6 +177,7 @@ ThemeData immichDarkTheme = ThemeData( ), titleSmall: const TextStyle( fontSize: 16.0, + fontWeight: FontWeight.bold, ), titleMedium: const TextStyle( fontSize: 18.0, @@ -185,7 +187,6 @@ ThemeData immichDarkTheme = ThemeData( fontSize: 26.0, fontWeight: FontWeight.bold, ), - ), cardColor: Colors.grey[900], elevatedButtonTheme: ElevatedButtonThemeData( diff --git a/mobile/openapi/lib/model/job_command.dart b/mobile/openapi/lib/model/job_command.dart index 4ebdec26b4..6da91e2326 100644 --- a/mobile/openapi/lib/model/job_command.dart +++ b/mobile/openapi/lib/model/job_command.dart @@ -25,12 +25,14 @@ class JobCommand { static const start = JobCommand._(r'start'); static const pause = JobCommand._(r'pause'); + static const resume = JobCommand._(r'resume'); static const empty = JobCommand._(r'empty'); /// List of all possible values in this [enum][JobCommand]. static const values = [ start, pause, + resume, empty, ]; @@ -72,6 +74,7 @@ class JobCommandTypeTransformer { switch (data.toString()) { case r'start': return JobCommand.start; case r'pause': return JobCommand.pause; + case r'resume': return JobCommand.resume; case r'empty': return JobCommand.empty; default: if (!allowNull) { diff --git a/mobile/test/favorite_provider_test.mocks.dart b/mobile/test/favorite_provider_test.mocks.dart index 0447cba0f2..a42bc13429 100644 --- a/mobile/test/favorite_provider_test.mocks.dart +++ b/mobile/test/favorite_provider_test.mocks.dart @@ -187,6 +187,16 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier { returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); @override + _i5.Future getAllAsset({bool? clear = false}) => (super.noSuchMethod( + Invocation.method( + #getAllAsset, + [], + {#clear: clear}, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + @override _i5.Future clearAllAsset() => (super.noSuchMethod( Invocation.method( #clearAllAsset, diff --git a/server/apps/cli/src/app.module.ts b/server/apps/cli/src/app.module.ts index 142ead9b91..fe0e35bb51 100644 --- a/server/apps/cli/src/app.module.ts +++ b/server/apps/cli/src/app.module.ts @@ -6,11 +6,7 @@ import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from './comma import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command'; @Module({ - imports: [ - DomainModule.register({ - imports: [InfraModule], - }), - ], + imports: [DomainModule.register({ imports: [InfraModule] })], providers: [ ResetAdminPasswordCommand, PromptPasswordQuestions, diff --git a/server/apps/immich/src/api-v1/album/album-repository.ts b/server/apps/immich/src/api-v1/album/album-repository.ts index bcd779940a..a107a26d69 100644 --- a/server/apps/immich/src/api-v1/album/album-repository.ts +++ b/server/apps/immich/src/api-v1/album/album-repository.ts @@ -1,4 +1,5 @@ -import { AlbumEntity, AssetEntity, dataSource, UserEntity } from '@app/infra'; +import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities'; +import { dataSource } from '@app/infra/db/config'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/server/apps/immich/src/api-v1/album/album.module.ts b/server/apps/immich/src/api-v1/album/album.module.ts index 1e66fd2b2d..68f77a0467 100644 --- a/server/apps/immich/src/api-v1/album/album.module.ts +++ b/server/apps/immich/src/api-v1/album/album.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { AlbumService } from './album.service'; import { AlbumController } from './album.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AlbumEntity, AssetEntity } from '@app/infra'; +import { AlbumEntity, AssetEntity } from '@app/infra/db/entities'; import { AlbumRepository, IAlbumRepository } from './album-repository'; import { DownloadModule } from '../../modules/download/download.module'; diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index a7536ace80..2d2b066078 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -1,7 +1,7 @@ import { AlbumService } from './album.service'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; -import { AlbumEntity, UserEntity } from '@app/infra'; +import { AlbumEntity, UserEntity } from '@app/infra/db/entities'; import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { IAlbumRepository } from './album-repository'; diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index 1c64df874e..08ab9a951b 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { CreateAlbumDto } from './dto/create-album.dto'; -import { AlbumEntity, SharedLinkType } from '@app/infra'; +import { AlbumEntity, SharedLinkType } from '@app/infra/db/entities'; import { AddUsersDto } from './dto/add-users.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { UpdateAlbumDto } from './dto/update-album.dto'; diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index c4f8e33649..66a8bd6312 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -1,6 +1,6 @@ import { SearchPropertiesDto } from './dto/search-properties.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; -import { AssetEntity, AssetType } from '@app/infra'; +import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm/repository/Repository'; diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index d3a45d2d34..0b28657c48 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { AssetService } from './asset.service'; import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AssetEntity } from '@app/infra'; +import { AssetEntity } from '@app/infra/db/entities'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { DownloadModule } from '../../modules/download/download.module'; import { TagModule } from '../tag/tag.module'; diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index e88f2651ae..5eefb06202 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -1,7 +1,7 @@ import { IAssetRepository } from './asset-repository'; import { AssetService } from './asset.service'; import { QueryFailedError, Repository } from 'typeorm'; -import { AssetEntity, AssetType } from '@app/infra'; +import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { CreateAssetDto } from './dto/create-asset.dto'; import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto'; import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto'; diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 0c9b2300d8..5927315647 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -12,7 +12,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { QueryFailedError, Repository } from 'typeorm'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { AssetEntity, AssetType, SharedLinkType, SystemConfig } from '@app/infra'; +import { AssetEntity, AssetType, SharedLinkType, SystemConfig } from '@app/infra/db/entities'; import { constants, createReadStream, stat } from 'fs'; import { ServeFileDto } from './dto/serve-file.dto'; import { Response as Res } from 'express'; diff --git a/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts b/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts index 66d8ec8835..527d52d85d 100644 --- a/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts @@ -1,4 +1,4 @@ -import { AssetType } from '@app/infra'; +import { AssetType } from '@app/infra/db/entities'; import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; import { ImmichFile } from '../../../config/asset-upload.config'; diff --git a/server/apps/immich/src/api-v1/tag/dto/create-tag.dto.ts b/server/apps/immich/src/api-v1/tag/dto/create-tag.dto.ts index 8e7425da22..beeb9530dd 100644 --- a/server/apps/immich/src/api-v1/tag/dto/create-tag.dto.ts +++ b/server/apps/immich/src/api-v1/tag/dto/create-tag.dto.ts @@ -1,4 +1,4 @@ -import { TagType } from '@app/infra'; +import { TagType } from '@app/infra/db/entities'; import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; diff --git a/server/apps/immich/src/api-v1/tag/tag.module.ts b/server/apps/immich/src/api-v1/tag/tag.module.ts index a44b055b0a..a7ac3885d9 100644 --- a/server/apps/immich/src/api-v1/tag/tag.module.ts +++ b/server/apps/immich/src/api-v1/tag/tag.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { TagService } from './tag.service'; import { TagController } from './tag.controller'; -import { TagEntity } from '@app/infra'; +import { TagEntity } from '@app/infra/db/entities'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TagRepository, ITagRepository } from './tag.repository'; diff --git a/server/apps/immich/src/api-v1/tag/tag.repository.ts b/server/apps/immich/src/api-v1/tag/tag.repository.ts index 09391d4e27..02750e5891 100644 --- a/server/apps/immich/src/api-v1/tag/tag.repository.ts +++ b/server/apps/immich/src/api-v1/tag/tag.repository.ts @@ -1,4 +1,4 @@ -import { TagEntity, TagType } from '@app/infra'; +import { TagEntity, TagType } from '@app/infra/db/entities'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; diff --git a/server/apps/immich/src/api-v1/tag/tag.service.spec.ts b/server/apps/immich/src/api-v1/tag/tag.service.spec.ts index 877f60087d..b17f587f91 100644 --- a/server/apps/immich/src/api-v1/tag/tag.service.spec.ts +++ b/server/apps/immich/src/api-v1/tag/tag.service.spec.ts @@ -1,4 +1,4 @@ -import { TagEntity, TagType, UserEntity } from '@app/infra'; +import { TagEntity, TagType, UserEntity } from '@app/infra/db/entities'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { ITagRepository } from './tag.repository'; import { TagService } from './tag.service'; diff --git a/server/apps/immich/src/api-v1/tag/tag.service.ts b/server/apps/immich/src/api-v1/tag/tag.service.ts index 9a7288b1fd..dbb6cf3ed9 100644 --- a/server/apps/immich/src/api-v1/tag/tag.service.ts +++ b/server/apps/immich/src/api-v1/tag/tag.service.ts @@ -1,4 +1,4 @@ -import { TagEntity } from '@app/infra'; +import { TagEntity } from '@app/infra/db/entities'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { CreateTagDto } from './dto/create-tag.dto'; diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 1d82ed3d91..a10c13b6e2 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -1,7 +1,5 @@ -import { immichAppConfig } from '@app/domain'; import { Module, OnModuleInit } from '@nestjs/common'; import { AssetModule } from './api-v1/asset/asset.module'; -import { ConfigModule } from '@nestjs/config'; import { AlbumModule } from './api-v1/album/album.module'; import { AppController } from './app.controller'; import { ScheduleModule } from '@nestjs/schedule'; @@ -27,7 +25,6 @@ import { AppCronJobs } from './app.cron-jobs'; @Module({ imports: [ - ConfigModule.forRoot(immichAppConfig), DomainModule.register({ imports: [InfraModule] }), AssetModule, AlbumModule, diff --git a/server/apps/immich/src/modules/download/download.service.ts b/server/apps/immich/src/modules/download/download.service.ts index 011b173cc9..2413173885 100644 --- a/server/apps/immich/src/modules/download/download.service.ts +++ b/server/apps/immich/src/modules/download/download.service.ts @@ -1,4 +1,4 @@ -import { AssetEntity } from '@app/infra'; +import { AssetEntity } from '@app/infra/db/entities'; import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; import archiver from 'archiver'; import { extname } from 'path'; diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index d315a87776..b3eadfad11 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -1,8 +1,7 @@ -import { immichAppConfig } from '@app/domain'; import { DomainModule } from '@app/domain'; -import { ExifEntity, InfraModule } from '@app/infra'; +import { InfraModule } from '@app/infra'; +import { ExifEntity } from '@app/infra/db/entities'; import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BackgroundTaskProcessor, @@ -17,7 +16,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' @Module({ imports: [ - ConfigModule.forRoot(immichAppConfig), + // DomainModule.register({ imports: [InfraModule] }), TypeOrmModule.forFeature([ExifEntity]), ], diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 095fc2206e..4373ec08b5 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -10,7 +10,7 @@ import { QueueName, WithoutProperty, } from '@app/domain'; -import { AssetEntity, AssetType, ExifEntity } from '@app/infra'; +import { AssetEntity, AssetType, ExifEntity } from '@app/infra/db/entities'; import { Process, Processor } from '@nestjs/bull'; import { Inject, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index e02009fcd0..f8fd857597 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -11,7 +11,7 @@ import { SystemConfigService, WithoutProperty, } from '@app/domain'; -import { AssetEntity, AssetType } from '@app/infra'; +import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { Process, Processor } from '@nestjs/bull'; import { Inject, Logger } from '@nestjs/common'; import { Job } from 'bull'; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index fe357c2756..1e5f48f163 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4141,6 +4141,7 @@ "enum": [ "start", "pause", + "resume", "empty" ] }, diff --git a/server/libs/domain/src/album/album.service.ts b/server/libs/domain/src/album/album.service.ts index 5f963e9de3..05789a71e1 100644 --- a/server/libs/domain/src/album/album.service.ts +++ b/server/libs/domain/src/album/album.service.ts @@ -1,4 +1,4 @@ -import { AlbumEntity } from '@app/infra'; +import { AlbumEntity } from '@app/infra/db/entities'; import { Inject, Injectable } from '@nestjs/common'; import { IAssetRepository } from '../asset'; import { AuthUserDto } from '../auth'; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 712b5eab84..c79490a460 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -1,4 +1,4 @@ -import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common'; +import { DynamicModule, Global, Module, ModuleMetadata, OnApplicationShutdown, Provider } from '@nestjs/common'; import { AlbumService } from './album'; import { APIKeyService } from './api-key'; import { AssetService } from './asset'; @@ -44,7 +44,9 @@ const providers: Provider[] = [ @Global() @Module({}) -export class DomainModule { +export class DomainModule implements OnApplicationShutdown { + constructor(private searchService: SearchService) {} + static register(options: Pick): DynamicModule { return { module: DomainModule, @@ -53,4 +55,8 @@ export class DomainModule { exports: [...providers], }; } + + onApplicationShutdown() { + this.searchService.teardown(); + } } diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index 2a75cd3d3b..d9d9f99725 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -12,6 +12,7 @@ export enum QueueName { export enum JobCommand { START = 'start', PAUSE = 'pause', + RESUME = 'resume', EMPTY = 'empty', } diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index ea6d6309ea..1c5594ec83 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -69,6 +69,7 @@ export const IJobRepository = 'IJobRepository'; export interface IJobRepository { queue(item: JobItem): Promise; pause(name: QueueName): Promise; + resume(name: QueueName): Promise; empty(name: QueueName): Promise; isActive(name: QueueName): Promise; getJobCounts(name: QueueName): Promise; diff --git a/server/libs/domain/src/job/job.service.spec.ts b/server/libs/domain/src/job/job.service.spec.ts index 42ce5e5144..9129b0a183 100644 --- a/server/libs/domain/src/job/job.service.spec.ts +++ b/server/libs/domain/src/job/job.service.spec.ts @@ -93,6 +93,12 @@ describe(JobService.name, () => { expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); }); + it('should handle a resume command', async () => { + await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false }); + + expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + }); + it('should handle an empty command', async () => { await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false }); diff --git a/server/libs/domain/src/job/job.service.ts b/server/libs/domain/src/job/job.service.ts index 2ca036280a..ccb1709aae 100644 --- a/server/libs/domain/src/job/job.service.ts +++ b/server/libs/domain/src/job/job.service.ts @@ -21,6 +21,9 @@ export class JobService { case JobCommand.PAUSE: return this.jobRepository.pause(queueName); + case JobCommand.RESUME: + return this.jobRepository.resume(queueName); + case JobCommand.EMPTY: return this.jobRepository.empty(queueName); } diff --git a/server/libs/domain/test/job.repository.mock.ts b/server/libs/domain/test/job.repository.mock.ts index 5b2d9547b2..a6c8fff2a1 100644 --- a/server/libs/domain/test/job.repository.mock.ts +++ b/server/libs/domain/test/job.repository.mock.ts @@ -4,6 +4,7 @@ export const newJobRepositoryMock = (): jest.Mocked => { return { empty: jest.fn(), pause: jest.fn(), + resume: jest.fn(), queue: jest.fn().mockImplementation(() => Promise.resolve()), isActive: jest.fn(), getJobCounts: jest.fn(), diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index 6633a165e6..c3dcb4987e 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -8,6 +8,7 @@ import { IKeyRepository, IMachineLearningRepository, IMediaRepository, + immichAppConfig, ISearchRepository, ISharedLinkRepository, ISmartInfoRepository, @@ -19,6 +20,7 @@ import { } from '@app/domain'; import { BullModule } from '@nestjs/bull'; import { Global, Module, Provider } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CryptoRepository } from './auth/crypto.repository'; import { CommunicationGateway, CommunicationRepository } from './communication'; @@ -71,6 +73,8 @@ const providers: Provider[] = [ @Global() @Module({ imports: [ + ConfigModule.forRoot(immichAppConfig), + TypeOrmModule.forRoot(databaseConfig), TypeOrmModule.forFeature([ AssetEntity, @@ -83,6 +87,7 @@ const providers: Provider[] = [ SystemConfigEntity, UserTokenEntity, ]), + BullModule.forRootAsync({ useFactory: async () => ({ prefix: 'immich_bull', diff --git a/server/libs/infra/src/job/job.repository.ts b/server/libs/infra/src/job/job.repository.ts index 1fbacd840a..0371ab50df 100644 --- a/server/libs/infra/src/job/job.repository.ts +++ b/server/libs/infra/src/job/job.repository.ts @@ -45,6 +45,10 @@ export class JobRepository implements IJobRepository { return this.queueMap[name].pause(); } + resume(name: QueueName) { + return this.queueMap[name].resume(); + } + empty(name: QueueName) { return this.queueMap[name].empty(); } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 19546ee548..c139fef28f 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1222,6 +1222,7 @@ export interface GetAssetCountByTimeBucketDto { export const JobCommand = { Start: 'start', Pause: 'pause', + Resume: 'resume', Empty: 'empty' } as const;