Compare commits

...

38 Commits

Author SHA1 Message Date
Alex
b3993dbdad
Merge 39017922a9555b938e6d6d12396ea17e7b20908f into 4d20b11f256c40e3894c229ed638d7ea04ebdc44 2024-10-01 20:23:24 +02:00
Jason Rasmussen
4d20b11f25
feat: track upgrade history (#13097) 2024-10-01 13:33:58 -04:00
renovate[bot]
1c3603e23b
chore(deps): update grafana/grafana docker tag to v11.2.1 (#13094)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 13:06:45 -04:00
renovate[bot]
eb3ac09e0d
chore(deps): update dependency svelte-check to v4.0.3 (#13090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 13:05:33 -04:00
Jason Rasmussen
305fc77ebe
feat(server): better mount checks (#13092) 2024-10-01 13:04:37 -04:00
Zack Pollard
d46e50213a
fix(server): offline assets don't restore when coming back online (#13087) 2024-10-01 14:03:19 +01:00
renovate[bot]
49486f2d26
chore(deps): update base-image to v20241001 (major) (#13089)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 12:26:00 +00:00
renovate[bot]
eac189a9e5
chore(deps): update dependency prettier-plugin-svelte to v3.2.7 (#13088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 12:25:08 +00: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
58 changed files with 2232 additions and 186 deletions

View File

@ -91,7 +91,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:11.2.0-ubuntu@sha256:8e2c13739563c3da9d45de96c6bcb63ba617cac8c571c060112c7fc8ad6914e9 image: grafana/grafana:11.2.1-ubuntu@sha256:b90c0fdc482913de7a55fe96539bf9e3c4fbcee835d0c2dffc59152bc3964ff7
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@ -0,0 +1,47 @@
# System Integrity
## Folder checks
:::info
The folders considered for these checks include: `upload/`, `library/`, `thumbs/`, `encoded-video/`, `profile/`
:::
When Immich starts, it performs a series of checks in order to validate that it can read and write files to the volume mounts used by the storage system. If it cannot perform all the required operations, it will fail to start. The checks include:
- Creating an initial hidden file (`.immich`) in each folder
- Reading a hidden file (`.immich`) in each folder
- Overwriting a hidden file (`.immich`) in each folder
The checks are designed to catch the following situations:
- Incorrect permissions (cannot read/write files)
- Missing volume mount (`.immich` files should exist, but are missing)
### Common issues
:::note
`.immich` files serve as markers and help keep track of volume mounts being used by Immich. Except for the situations listed below, they should never be manually created or deleted.
:::
#### Missing `.immich` files
```
Verifying system mount folder checks (enabled=true)
...
ENOENT: no such file or directory, open 'upload/encoded-video/.immich'
```
The above error messages show that the server has previously (successfully) written `.immich` files to each folder, but now does not detect them. This could be because any of the following:
- Permission error - unable to read the file, but it exists
- File does not exist - volume mount has changed and should be corrected
- File does not exist - user manually deleted it and should be manually re-created (`touch .immich`)
- File does not exist - user restored from a backup, but did not restore each folder (user should restore all folders or manually create `.immich` in any missing folders)
### Ignoring the checks
The checks are designed to catch common problems that we have seen users have in the past, but if you want to disable them you can set the following environment variable:
```
IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
```

View File

@ -42,6 +42,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | | `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | | `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

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_back": "Back",
"action_common_cancel": "Cancel", "action_common_cancel": "Cancel",
"action_common_clear": "Clear", "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), selectedIcon: const Icon(Icons.photo_library),
label: const Text('tab_controller_nav_photos').tr(), 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( NavigationRailDestination(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
icon: const Icon(Icons.search_rounded), icon: const Icon(Icons.search_rounded),
selectedIcon: const Icon(Icons.search), selectedIcon: const Icon(Icons.search),
label: const Text('tab_controller_nav_search').tr(), label: const Text('tab_controller_nav_search').tr(),
), ),
NavigationRailDestination( // NavigationRailDestination(
padding: const EdgeInsets.all(4), // padding: const EdgeInsets.all(4),
icon: const Icon(Icons.share_rounded), // icon: const Icon(Icons.share_rounded),
selectedIcon: const Icon(Icons.share), // selectedIcon: const Icon(Icons.share),
label: const Text('tab_controller_nav_sharing').tr(), // label: const Text('tab_controller_nav_sharing').tr(),
), // ),
NavigationRailDestination( // NavigationRailDestination(
padding: const EdgeInsets.all(4), // padding: const EdgeInsets.all(4),
icon: const Icon(Icons.photo_album_outlined), // icon: const Icon(Icons.photo_album_outlined),
selectedIcon: const Icon(Icons.photo_album), // selectedIcon: const Icon(Icons.photo_album),
label: const Text('tab_controller_nav_library').tr(), // label: const Text('tab_controller_nav_library').tr(),
), // ),
], ],
); );
} }
@ -125,27 +137,7 @@ class TabControllerPage extends HookConsumerWidget {
), ),
), ),
NavigationDestination( NavigationDestination(
label: 'tab_controller_nav_search'.tr(), label: 'albums'.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( icon: const Icon(
Icons.photo_album_outlined, 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); final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter( return AutoTabsRouter(
routes: const [ routes: [
PhotosRoute(), const PhotosRoute(),
SearchRoute(), AlbumsCollectionRoute(showImmichAppbar: true),
SharingRoute(), const CollectionsRoute(),
LibraryRoute(), // SharingRoute(),
// LibraryRoute(),
const SearchRoute(),
], ],
duration: const Duration(milliseconds: 600), duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition( transitionBuilder: (context, child, animation) => FadeTransition(

View File

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

View File

@ -92,6 +92,7 @@ class PersonResultPage extends HookConsumerWidget {
Text( Text(
name.value, name.value,
style: context.textTheme.titleLarge, style: context.textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
@ -125,10 +126,12 @@ class PersonResultPage extends HookConsumerWidget {
headers: ApiService.getRequestHeaders(), headers: ApiService.getRequestHeaders(),
), ),
), ),
Padding( Expanded(
padding: const EdgeInsets.only(left: 16.0), child: Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0),
child: buildTitleBlock(), 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/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/providers/partner.provider.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/widgets/partner/partner_list.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/user.provider.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( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
}, },
child: Scaffold( child: Scaffold(
appBar: ImmichAppBar( appBar: const ImmichAppBar(
action: sharePartnerButton(), actions: [SharePartnerButton()],
), ),
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [

View File

@ -12,7 +12,7 @@ import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> { class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, Isar db) : super([]) { AlbumNotifier(this._albumService, this.db) : super([]) {
final query = db.albums final query = db.albums
.filter() .filter()
.owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)); .owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId));
@ -25,6 +25,7 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
} }
final AlbumService _albumService; final AlbumService _albumService;
final Isar db;
late final StreamSubscription<List<Album>> _streamSub; late final StreamSubscription<List<Album>> _streamSub;
Future<void> getAllAlbums() => Future.wait([ Future<void> getAllAlbums() => Future.wait([
@ -64,6 +65,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
_streamSub.cancel(); _streamSub.cancel();
super.dispose(); 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 = 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(); _ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
case TabEnum.library: case TabEnum.library:
_ref.read(albumProvider.notifier).getAllAlbums(); _ref.read(albumProvider.notifier).getAllAlbums();
case TabEnum.collections:
// nothing to do
} }
} }

View File

@ -1,11 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
enum TabEnum { enum TabEnum { home, search, sharing, library, collections }
home,
search,
sharing,
library,
}
/// Provides the currently active tab /// Provides the currently active tab
final tabProvider = StateProvider<TabEnum>( 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_controller.page.dart';
import 'package:immich_mobile/pages/backup/backup_options.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/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/activities.page.dart';
import 'package:immich_mobile/pages/common/album_additional_shared_user_selection.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'; import 'package:immich_mobile/pages/common/album_asset_selection.page.dart';
@ -113,6 +118,14 @@ class AppRouter extends RootStackRouter {
page: LibraryRoute.page, page: LibraryRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute(
page: CollectionsRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: AlbumsCollectionRoute.page,
guards: [_authGuard, _duplicateGuard],
),
], ],
transitionsBuilder: TransitionsBuilders.fadeIn, transitionsBuilder: TransitionsBuilders.fadeIn,
), ),
@ -135,7 +148,11 @@ class AppRouter extends RootStackRouter {
), ),
AutoRoute(page: EditImageRoute.page), AutoRoute(page: EditImageRoute.page),
AutoRoute(page: CropImageRoute.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: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute( AutoRoute(
page: AllMotionPhotosRoute.page, page: AllMotionPhotosRoute.page,
@ -181,8 +198,16 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: ArchiveRoute.page, guards: [_authGuard, _duplicateGuard]), CustomRoute(
AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]), page: ArchiveRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: PartnerRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute( AutoRoute(
page: PartnerDetailRoute.page, page: PartnerDetailRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
@ -198,10 +223,15 @@ class AppRouter extends RootStackRouter {
page: AlbumOptionsRoute.page, page: AlbumOptionsRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
), ),
AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]), CustomRoute(
AutoRoute( page: TrashRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: SharedLinkRoute.page, page: SharedLinkRoute.page,
guards: [_authGuard, _duplicateGuard], guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
), ),
AutoRoute( AutoRoute(
page: SharedLinkEditRoute.page, page: SharedLinkEditRoute.page,
@ -230,6 +260,26 @@ class AppRouter extends RootStackRouter {
page: HeaderSettingsRoute.page, page: HeaderSettingsRoute.page,
guards: [_duplicateGuard], 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 /// generated route for
/// [AllMotionPhotosPage] /// [AllMotionPhotosPage]
class AllMotionPhotosRoute extends PageRouteInfo<void> { 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 /// generated route for
/// [CreateAlbumPage] /// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> { 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 /// generated route for
/// [LoginPage] /// [LoginPage]
class LoginRoute extends PageRouteInfo<void> { 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 /// generated route for
/// [PermissionOnboardingPage] /// [PermissionOnboardingPage]
class PermissionOnboardingRoute extends PageRouteInfo<void> { 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 /// generated route for
/// [RecentlyAddedPage] /// [RecentlyAddedPage]
class RecentlyAddedRoute extends PageRouteInfo<void> { 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:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/album.provider.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/memory.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart';
@ -50,6 +51,10 @@ class TabNavigationObserver extends AutoRouterObserver {
ref.read(albumProvider.notifier).getAllAlbums(); ref.read(albumProvider.notifier).getAllAlbums();
} }
if (route.name == 'CollectionsRoute') {
ref.read(albumProviderV2.notifier).refreshAlbums();
}
if (route.name == 'HomeRoute') { if (route.name == 'HomeRoute') {
ref.invalidate(memoryFutureProvider); ref.invalidate(memoryFutureProvider);
Future(() => ref.read(assetProvider.notifier).getAllAsset()); Future(() => ref.read(assetProvider.notifier).getAllAsset());

View File

@ -175,6 +175,49 @@ class AlbumService {
return changes; 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( Future<Album?> createAlbum(
String albumName, String albumName,
Iterable<Asset> assets, [ 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") /// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album /// in the subtitle of the album
final bool showOwner; final bool showOwner;
final bool showTitle;
const AlbumThumbnailCard({ const AlbumThumbnailCard({
super.key, super.key,
required this.album, required this.album,
this.onTap, this.onTap,
this.showOwner = false, this.showOwner = false,
this.showTitle = true,
}); });
final Album album; final Album album;
@ -76,7 +78,7 @@ class AlbumThumbnailCard extends StatelessWidget {
: 'album_thumbnail_card_items' : 'album_thumbnail_card_items'
.tr(args: ['${album.assetCount}']), .tr(args: ['${album.assetCount}']),
), ),
if (owner != null) const TextSpan(text: ' · '), if (owner != null) const TextSpan(text: ' '),
if (owner != null) TextSpan(text: owner), if (owner != null) TextSpan(text: owner),
], ],
), ),
@ -102,6 +104,7 @@ class AlbumThumbnailCard extends StatelessWidget {
: buildAlbumThumbnail(), : buildAlbumThumbnail(),
), ),
), ),
if (showTitle) ...[
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: SizedBox( child: SizedBox(
@ -109,7 +112,7 @@ class AlbumThumbnailCard extends StatelessWidget {
child: Text( child: Text(
album.name, album.name,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith( style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface, color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@ -118,6 +121,7 @@ class AlbumThumbnailCard extends StatelessWidget {
), ),
buildAlbumTextRow(), buildAlbumTextRow(),
], ],
],
), ),
), ),
], ],

View File

@ -18,9 +18,10 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override @override
Size get preferredSize => const Size.fromHeight(kToolbarHeight); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -184,8 +185,14 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
}, },
), ),
actions: [ actions: [
if (action != null) if (actions != null)
Padding(padding: const EdgeInsets.only(right: 20), child: action!), ...actions!.map(
(action) => Padding(
padding: const EdgeInsets.only(right: 16),
child: action,
),
),
if (showUploadButton)
Padding( Padding(
padding: const EdgeInsets.only(right: 20), padding: const EdgeInsets.only(right: 20),
child: buildBackupIndicator(), child: buildBackupIndicator(),

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 double size;
final bool showTitle = true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -183,6 +183,7 @@ Class | Method | HTTP request | Description
*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | *ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage |
*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | *ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types |
*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | *ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme |
*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history |
*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | *ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping |
*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license |
*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions |
@ -400,6 +401,7 @@ Class | Method | HTTP request | Description
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerStorageResponseDto](doc//ServerStorageResponseDto.md) - [ServerStorageResponseDto](doc//ServerStorageResponseDto.md)
- [ServerThemeDto](doc//ServerThemeDto.md) - [ServerThemeDto](doc//ServerThemeDto.md)
- [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md)
- [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)

View File

@ -213,6 +213,7 @@ part 'model/server_ping_response.dart';
part 'model/server_stats_response_dto.dart'; part 'model/server_stats_response_dto.dart';
part 'model/server_storage_response_dto.dart'; part 'model/server_storage_response_dto.dart';
part 'model/server_theme_dto.dart'; part 'model/server_theme_dto.dart';
part 'model/server_version_history_response_dto.dart';
part 'model/server_version_response_dto.dart'; part 'model/server_version_response_dto.dart';
part 'model/session_response_dto.dart'; part 'model/session_response_dto.dart';
part 'model/shared_link_create_dto.dart'; part 'model/shared_link_create_dto.dart';

View File

@ -418,6 +418,50 @@ class ServerApi {
return null; return null;
} }
/// Performs an HTTP 'GET /server/version-history' operation and returns the [Response].
Future<Response> getVersionHistoryWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/server/version-history';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<List<ServerVersionHistoryResponseDto>?> getVersionHistory() async {
final response = await getVersionHistoryWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<ServerVersionHistoryResponseDto>') as List)
.cast<ServerVersionHistoryResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'GET /server/ping' operation and returns the [Response]. /// Performs an HTTP 'GET /server/ping' operation and returns the [Response].
Future<Response> pingServerWithHttpInfo() async { Future<Response> pingServerWithHttpInfo() async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations

View File

@ -480,6 +480,8 @@ class ApiClient {
return ServerStorageResponseDto.fromJson(value); return ServerStorageResponseDto.fromJson(value);
case 'ServerThemeDto': case 'ServerThemeDto':
return ServerThemeDto.fromJson(value); return ServerThemeDto.fromJson(value);
case 'ServerVersionHistoryResponseDto':
return ServerVersionHistoryResponseDto.fromJson(value);
case 'ServerVersionResponseDto': case 'ServerVersionResponseDto':
return ServerVersionResponseDto.fromJson(value); return ServerVersionResponseDto.fromJson(value);
case 'SessionResponseDto': case 'SessionResponseDto':

View File

@ -0,0 +1,115 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class ServerVersionHistoryResponseDto {
/// Returns a new [ServerVersionHistoryResponseDto] instance.
ServerVersionHistoryResponseDto({
required this.createdAt,
required this.id,
required this.version,
});
DateTime createdAt;
String id;
String version;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionHistoryResponseDto &&
other.createdAt == createdAt &&
other.id == id &&
other.version == version;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(createdAt.hashCode) +
(id.hashCode) +
(version.hashCode);
@override
String toString() => 'ServerVersionHistoryResponseDto[createdAt=$createdAt, id=$id, version=$version]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'id'] = this.id;
json[r'version'] = this.version;
return json;
}
/// Returns a new [ServerVersionHistoryResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ServerVersionHistoryResponseDto? fromJson(dynamic value) {
upgradeDto(value, "ServerVersionHistoryResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ServerVersionHistoryResponseDto(
createdAt: mapDateTime(json, r'createdAt', r'')!,
id: mapValueOfType<String>(json, r'id')!,
version: mapValueOfType<String>(json, r'version')!,
);
}
return null;
}
static List<ServerVersionHistoryResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ServerVersionHistoryResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ServerVersionHistoryResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ServerVersionHistoryResponseDto> mapFromJson(dynamic json) {
final map = <String, ServerVersionHistoryResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerVersionHistoryResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ServerVersionHistoryResponseDto-objects as value to a dart map
static Map<String, List<ServerVersionHistoryResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ServerVersionHistoryResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ServerVersionHistoryResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'createdAt',
'id',
'version',
};
}

View File

@ -5088,6 +5088,30 @@
] ]
} }
}, },
"/server/version-history": {
"get": {
"operationId": "getVersionHistory",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/ServerVersionHistoryResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"tags": [
"Server"
]
}
},
"/sessions": { "/sessions": {
"delete": { "delete": {
"operationId": "deleteAllSessions", "operationId": "deleteAllSessions",
@ -11042,6 +11066,26 @@
], ],
"type": "object" "type": "object"
}, },
"ServerVersionHistoryResponseDto": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"id": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"createdAt",
"id",
"version"
],
"type": "object"
},
"ServerVersionResponseDto": { "ServerVersionResponseDto": {
"properties": { "properties": {
"major": { "major": {

View File

@ -1000,6 +1000,11 @@ export type ServerVersionResponseDto = {
minor: number; minor: number;
patch: number; patch: number;
}; };
export type ServerVersionHistoryResponseDto = {
createdAt: string;
id: string;
version: string;
};
export type SessionResponseDto = { export type SessionResponseDto = {
createdAt: string; createdAt: string;
current: boolean; current: boolean;
@ -2667,6 +2672,14 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) {
...opts ...opts
})); }));
} }
export function getVersionHistory(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerVersionHistoryResponseDto[];
}>("/server/version-history", {
...opts
}));
}
export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { export function deleteAllSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/sessions", { return oazapfts.ok(oazapfts.fetchText("/sessions", {
...opts, ...opts,

View File

@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:20240924@sha256:fff4358d435065a626c64a4c015cbfce6ee714b05fabe39aa0d83d8cff3951f2 AS dev FROM ghcr.io/immich-app/base-server-dev:20241001@sha256:bb10832c2567f5625df68bb790523e85a358031ddcb3d7ac98b669f62ed8de27 AS dev
RUN apt-get install --no-install-recommends -yqq tini RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
@ -41,7 +41,7 @@ RUN npm run build
# prod build # prod build
FROM ghcr.io/immich-app/base-server-prod:20240924@sha256:af3089fe48d7ff162594bd7edfffa56ba4e7014ad10ad69c4ebfd428e39b06ff FROM ghcr.io/immich-app/base-server-prod:20241001@sha256:a9a0745a486e9cbd73fa06b49168e985f8f2c1be0fca9fb0a8e06916246c7087
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \

View File

@ -10,6 +10,7 @@ import {
ServerStatsResponseDto, ServerStatsResponseDto,
ServerStorageResponseDto, ServerStorageResponseDto,
ServerThemeDto, ServerThemeDto,
ServerVersionHistoryResponseDto,
ServerVersionResponseDto, ServerVersionResponseDto,
} from 'src/dtos/server.dto'; } from 'src/dtos/server.dto';
import { Authenticated } from 'src/middleware/auth.guard'; import { Authenticated } from 'src/middleware/auth.guard';
@ -46,6 +47,11 @@ export class ServerController {
return this.versionService.getVersion(); return this.versionService.getVersion();
} }
@Get('version-history')
getVersionHistory(): Promise<ServerVersionHistoryResponseDto[]> {
return this.versionService.getVersionHistory();
}
@Get('features') @Get('features')
getServerFeatures(): Promise<ServerFeaturesDto> { getServerFeatures(): Promise<ServerFeaturesDto> {
return this.service.getFeatures(); return this.service.getFeatures();

View File

@ -68,6 +68,12 @@ export class ServerVersionResponseDto {
} }
} }
export class ServerVersionHistoryResponseDto {
id!: string;
createdAt!: Date;
version!: string;
}
export class UsageByUserDto { export class UsageByUserDto {
@ApiProperty({ type: 'string' }) @ApiProperty({ type: 'string' })
userId!: string; userId!: string;

View File

@ -25,6 +25,7 @@ import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { TagEntity } from 'src/entities/tag.entity'; import { TagEntity } from 'src/entities/tag.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
export const entities = [ export const entities = [
ActivityEntity, ActivityEntity,
@ -54,4 +55,5 @@ export const entities = [
UserMetadataEntity, UserMetadataEntity,
SessionEntity, SessionEntity,
LibraryEntity, LibraryEntity,
VersionHistoryEntity,
]; ];

View File

@ -0,0 +1,13 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('version_history')
export class VersionHistoryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@Column()
version!: string;
}

View File

@ -7,6 +7,9 @@ export interface EnvData {
skipMigrations: boolean; skipMigrations: boolean;
vectorExtension: VectorExtension; vectorExtension: VectorExtension;
}; };
storage: {
ignoreMountCheckErrors: boolean;
};
} }
export interface IConfigRepository { export interface IConfigRepository {

View File

@ -17,6 +17,7 @@ export enum DatabaseLock {
Migrations = 200, Migrations = 200,
SystemFileMounts = 300, SystemFileMounts = 300,
StorageTemplateMigration = 420, StorageTemplateMigration = 420,
VersionHistory = 500,
CLIPDimSize = 512, CLIPDimSize = 512,
LibraryWatch = 1337, LibraryWatch = 1337,
GetSystemConfig = 69, GetSystemConfig = 69,

View File

@ -0,0 +1,9 @@
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
export const IVersionHistoryRepository = 'IVersionHistoryRepository';
export interface IVersionHistoryRepository {
create(version: Omit<VersionHistoryEntity, 'id' | 'createdAt'>): Promise<VersionHistoryEntity>;
getAll(): Promise<VersionHistoryEntity[]>;
getLatest(): Promise<VersionHistoryEntity | null>;
}

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddVersionHistory1727797340951 implements MigrationInterface {
name = 'AddVersionHistory1727797340951'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "version_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "version" character varying NOT NULL, CONSTRAINT "PK_5db259cbb09ce82c0d13cfd1b23" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "version_history"`);
}
}

View File

@ -10,6 +10,9 @@ export class ConfigRepository implements IConfigRepository {
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true', skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: getVectorExtension(), vectorExtension: getVectorExtension(),
}, },
storage: {
ignoreMountCheckErrors: process.env.IMMICH_IGNORE_MOUNT_CHECK_ERRORS === 'true',
},
}; };
} }
} }

View File

@ -32,6 +32,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface'; import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { IViewRepository } from 'src/interfaces/view.interface'; import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
@ -67,6 +68,7 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { TagRepository } from 'src/repositories/tag.repository'; import { TagRepository } from 'src/repositories/tag.repository';
import { TrashRepository } from 'src/repositories/trash.repository'; import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository'; import { ViewRepository } from 'src/repositories/view-repository';
export const repositories = [ export const repositories = [
@ -104,5 +106,6 @@ export const repositories = [
{ provide: ITagRepository, useClass: TagRepository }, { provide: ITagRepository, useClass: TagRepository },
{ provide: ITrashRepository, useClass: TrashRepository }, { provide: ITrashRepository, useClass: TrashRepository },
{ provide: IUserRepository, useClass: UserRepository }, { provide: IUserRepository, useClass: UserRepository },
{ provide: IVersionHistoryRepository, useClass: VersionHistoryRepository },
{ provide: IViewRepository, useClass: ViewRepository }, { provide: IViewRepository, useClass: ViewRepository },
]; ];

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class VersionHistoryRepository implements IVersionHistoryRepository {
constructor(@InjectRepository(VersionHistoryEntity) private repository: Repository<VersionHistoryEntity>) {}
async getAll(): Promise<VersionHistoryEntity[]> {
return this.repository.find({ order: { createdAt: 'DESC' } });
}
async getLatest(): Promise<VersionHistoryEntity | null> {
const results = await this.repository.find({ order: { createdAt: 'DESC' }, take: 1 });
return results[0] || null;
}
create(version: Omit<VersionHistoryEntity, 'id' | 'createdAt'>): Promise<VersionHistoryEntity> {
return this.repository.save(version);
}
}

View File

@ -7,7 +7,7 @@ import {
} from 'src/interfaces/database.interface'; } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DatabaseService } from 'src/services/database.service'; import { DatabaseService } from 'src/services/database.service';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
@ -60,7 +60,9 @@ describe(DatabaseService.name, () => {
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
])('should work with $extensionName', ({ extension, extensionName }) => { ])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => { beforeEach(() => {
configMock.getEnv.mockReturnValue({ database: { skipMigrations: false, vectorExtension: extension } }); configMock.getEnv.mockReturnValue(
mockEnvData({ database: { skipMigrations: false, vectorExtension: extension } }),
);
}); });
it(`should start up successfully with ${extension}`, async () => { it(`should start up successfully with ${extension}`, async () => {
@ -244,12 +246,14 @@ describe(DatabaseService.name, () => {
}); });
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
configMock.getEnv.mockReturnValue({ configMock.getEnv.mockReturnValue(
mockEnvData({
database: { database: {
skipMigrations: true, skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS, vectorExtension: DatabaseExtension.VECTORS,
}, },
}); }),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
@ -257,12 +261,14 @@ describe(DatabaseService.name, () => {
}); });
it(`should throw error if pgvector extension could not be created`, async () => { it(`should throw error if pgvector extension could not be created`, async () => {
configMock.getEnv.mockReturnValue({ configMock.getEnv.mockReturnValue(
mockEnvData({
database: { database: {
skipMigrations: true, skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR, vectorExtension: DatabaseExtension.VECTOR,
}, },
}); }),
);
databaseMock.getExtensionVersion.mockResolvedValue({ databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null, installedVersion: null,
availableVersion: minVersionInRange, availableVersion: minVersionInRange,

View File

@ -140,9 +140,15 @@ export class LibraryService extends BaseService {
onAdd: (path) => { onAdd: (path) => {
const handler = async () => { const handler = async () => {
this.logger.debug(`File add event received for ${path} in library ${library.id}}`); this.logger.debug(`File add event received for ${path} in library ${library.id}}`);
if (matcher(path)) {
const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path);
if (asset) {
await this.syncAssets(library, [asset.id]);
}
if (matcher(path)) { if (matcher(path)) {
await this.syncFiles(library, [path]); await this.syncFiles(library, [path]);
} }
}
}; };
return handlePromiseError(handler(), this.logger); return handlePromiseError(handler(), this.logger);
}, },
@ -604,7 +610,7 @@ export class LibraryService extends BaseService {
this.logger.log(`Scanning library ${library.id} for removed assets`); this.logger.log(`Scanning library ${library.id} for removed assets`);
const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination, { libraryId: job.id }), this.assetRepository.getAll(pagination, { libraryId: job.id, withDeleted: true }),
); );
let assetCount = 0; let assetCount = 0;

View File

@ -1,9 +1,11 @@
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
@ -12,18 +14,20 @@ import { Mocked } from 'vitest';
describe(StorageService.name, () => { describe(StorageService.name, () => {
let sut: StorageService; let sut: StorageService;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>; let databaseMock: Mocked<IDatabaseRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => { beforeEach(() => {
configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock(); databaseMock = newDatabaseRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock); sut = new StorageService(configMock, databaseMock, storageMock, loggerMock, systemMock);
}); });
it('should work', () => { it('should work', () => {
@ -52,7 +56,7 @@ describe(StorageService.name, () => {
systemMock.get.mockResolvedValue({ mountFiles: true }); systemMock.get.mockResolvedValue({ mountFiles: true });
storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); await expect(sut.onBootstrap()).rejects.toThrow('Failed to read');
expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
expect(systemMock.set).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
@ -62,7 +66,21 @@ describe(StorageService.name, () => {
systemMock.get.mockResolvedValue({ mountFiles: true }); systemMock.get.mockResolvedValue({ mountFiles: true });
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); await expect(sut.onBootstrap()).rejects.toThrow('Failed to write');
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should startup if checks are disabled', async () => {
systemMock.get.mockResolvedValue({ mountFiles: true });
configMock.getEnv.mockReturnValue(
mockEnvData({
storage: { ignoreMountCheckErrors: true },
}),
);
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(systemMock.set).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled();
}); });

View File

@ -3,6 +3,7 @@ import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -10,9 +11,12 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ImmichStartupError } from 'src/utils/events'; import { ImmichStartupError } from 'src/utils/events';
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
@Injectable() @Injectable()
export class StorageService { export class StorageService {
constructor( constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@ -23,12 +27,15 @@ export class StorageService {
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap() { async onBootstrap() {
const envData = this.configRepository.getEnv();
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
const enabled = flags.mountFiles ?? false; const enabled = flags.mountFiles ?? false;
this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`); this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`);
try {
// check each folder exists and is writable // check each folder exists and is writable
for (const folder of Object.values(StorageFolder)) { for (const folder of Object.values(StorageFolder)) {
if (!enabled) { if (!enabled) {
@ -47,6 +54,14 @@ export class StorageService {
} }
this.logger.log('Successfully verified system mount folder checks'); this.logger.log('Successfully verified system mount folder checks');
} catch (error) {
if (envData.storage.ignoreMountCheckErrors) {
this.logger.error(error);
this.logger.warn('Ignoring mount folder errors');
} else {
throw error;
}
}
}); });
} }
@ -70,49 +85,45 @@ export class StorageService {
} }
private async verifyReadAccess(folder: StorageFolder) { private async verifyReadAccess(folder: StorageFolder) {
const { filePath } = this.getMountFilePaths(folder); const { internalPath, externalPath } = this.getMountFilePaths(folder);
try { try {
await this.storageRepository.readFile(filePath); await this.storageRepository.readFile(internalPath);
} catch (error) { } catch (error) {
this.logger.error(`Failed to read ${filePath}: ${error}`); this.logger.error(`Failed to read ${internalPath}: ${error}`);
this.logger.error( throw new ImmichStartupError(`Failed to read "${externalPath} - ${docsMessage}"`);
`The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (read from "<MEDIA_LOCATION>/${folder}")`);
} }
} }
private async createMountFile(folder: StorageFolder) { private async createMountFile(folder: StorageFolder) {
const { folderPath, filePath } = this.getMountFilePaths(folder); const { folderPath, internalPath, externalPath } = this.getMountFilePaths(folder);
try { try {
this.storageRepository.mkdirSync(folderPath); this.storageRepository.mkdirSync(folderPath);
await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`)); await this.storageRepository.createFile(internalPath, Buffer.from(`${Date.now()}`));
} catch (error) { } catch (error) {
this.logger.error(`Failed to create ${filePath}: ${error}`); if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
this.logger.error( this.logger.warn('Found existing mount file, skipping creation');
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, return;
); }
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`); this.logger.error(`Failed to create ${internalPath}: ${error}`);
throw new ImmichStartupError(`Failed to create "${externalPath} - ${docsMessage}"`);
} }
} }
private async verifyWriteAccess(folder: StorageFolder) { private async verifyWriteAccess(folder: StorageFolder) {
const { filePath } = this.getMountFilePaths(folder); const { internalPath, externalPath } = this.getMountFilePaths(folder);
try { try {
await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`)); await this.storageRepository.overwriteFile(internalPath, Buffer.from(`${Date.now()}`));
} catch (error) { } catch (error) {
this.logger.error(`Failed to write ${filePath}: ${error}`); this.logger.error(`Failed to write ${internalPath}: ${error}`);
this.logger.error( throw new ImmichStartupError(`Failed to write "${externalPath} - ${docsMessage}"`);
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
} }
} }
private getMountFilePaths(folder: StorageFolder) { private getMountFilePaths(folder: StorageFolder) {
const folderPath = StorageCore.getBaseFolder(folder); const folderPath = StorageCore.getBaseFolder(folder);
const filePath = join(folderPath, '.immich'); const internalPath = join(folderPath, '.immich');
const externalPath = `<UPLOAD_LOCATION>/${folder}/.immich`;
return { folderPath, filePath }; return { folderPath, internalPath, externalPath };
} }
} }

View File

@ -1,17 +1,21 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.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 { VersionService } from 'src/services/version.service';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const mockRelease = (version: string) => ({ const mockRelease = (version: string) => ({
@ -26,26 +30,47 @@ const mockRelease = (version: string) => ({
describe(VersionService.name, () => { describe(VersionService.name, () => {
let sut: VersionService; let sut: VersionService;
let databaseMock: Mocked<IDatabaseRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let serverMock: Mocked<IServerInfoRepository>; let serverMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let versionMock: Mocked<IVersionHistoryRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
databaseMock = newDatabaseRepositoryMock();
eventMock = newEventRepositoryMock(); eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
serverMock = newServerInfoRepositoryMock(); serverMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
versionMock = newVersionHistoryRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock); sut = new VersionService(databaseMock, eventMock, jobMock, serverMock, systemMock, versionMock, loggerMock);
}); });
it('should work', () => { it('should work', () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
describe('onBootstrap', () => {
it('should record a new version', async () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(versionMock.create).toHaveBeenCalledWith({ version: expect.any(String) });
});
it('should skip a duplicate version', async () => {
versionMock.getLatest.mockResolvedValue({
id: 'version-1',
createdAt: new Date(),
version: serverVersion.toString(),
});
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(versionMock.create).not.toHaveBeenCalled();
});
});
describe('getVersion', () => { describe('getVersion', () => {
it('should respond the server version', () => { it('should respond the server version', () => {
expect(sut.getVersion()).toEqual({ expect(sut.getVersion()).toEqual({
@ -56,6 +81,14 @@ describe(VersionService.name, () => {
}); });
}); });
describe('getVersionHistory', () => {
it('should respond the server version history', async () => {
const upgrade = { id: 'upgrade-1', createdAt: new Date(), version: '1.0.0' };
versionMock.getAll.mockResolvedValue([upgrade]);
await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]);
});
});
describe('handQueueVersionCheck', () => { describe('handQueueVersionCheck', () => {
it('should queue a version check job', async () => { it('should queue a version check job', async () => {
await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined();

View File

@ -6,11 +6,13 @@ import { OnEvent } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
@ -25,10 +27,12 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re
@Injectable() @Injectable()
export class VersionService extends BaseService { export class VersionService extends BaseService {
constructor( constructor(
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IVersionHistoryRepository) private versionRepository: IVersionHistoryRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ILoggerRepository) logger: ILoggerRepository,
) { ) {
super(systemMetadataRepository, logger); super(systemMetadataRepository, logger);
@ -38,12 +42,25 @@ export class VersionService extends BaseService {
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap(): Promise<void> { async onBootstrap(): Promise<void> {
await this.handleVersionCheck(); await this.handleVersionCheck();
await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => {
const latest = await this.versionRepository.getLatest();
const current = serverVersion.toString();
if (!latest || latest.version !== current) {
this.logger.log(`Version has changed, adding ${current} to history`);
await this.versionRepository.create({ version: current });
}
});
} }
getVersion() { getVersion() {
return ServerVersionResponseDto.fromSemVer(serverVersion); return ServerVersionResponseDto.fromSemVer(serverVersion);
} }
getVersionHistory() {
return this.versionRepository.getAll();
}
async handleQueueVersionCheck() { async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
} }

View File

@ -1,14 +1,21 @@
import { IConfigRepository } from 'src/interfaces/config.interface'; import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseExtension } from 'src/interfaces/database.interface'; import { DatabaseExtension } from 'src/interfaces/database.interface';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
export const newConfigRepositoryMock = (): Mocked<IConfigRepository> => { const envData: EnvData = {
return {
getEnv: vitest.fn().mockReturnValue({
database: { database: {
skipMigration: false, skipMigrations: false,
vectorExtension: DatabaseExtension.VECTORS, vectorExtension: DatabaseExtension.VECTORS,
}, },
}), storage: {
ignoreMountCheckErrors: false,
},
};
export const newConfigRepositoryMock = (): Mocked<IConfigRepository> => {
return {
getEnv: vitest.fn().mockReturnValue(envData),
}; };
}; };
export const mockEnvData = (config: Partial<EnvData>) => ({ ...envData, ...config });

View File

@ -0,0 +1,10 @@
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { Mocked, vitest } from 'vitest';
export const newVersionHistoryRepositoryMock = (): Mocked<IVersionHistoryRepository> => {
return {
getAll: vitest.fn().mockResolvedValue([]),
getLatest: vitest.fn(),
create: vitest.fn(),
};
};

50
web/package-lock.json generated
View File

@ -6129,9 +6129,9 @@
} }
}, },
"node_modules/prettier-plugin-svelte": { "node_modules/prettier-plugin-svelte": {
"version": "3.2.6", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.6.tgz", "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.7.tgz",
"integrity": "sha512-Y1XWLw7vXUQQZmgv1JAEiLcErqUniAF2wO7QJsw8BVMvpLET2dI5WpEIEJx1r11iHVdSMzQxivyfrH9On9t2IQ==", "integrity": "sha512-/Dswx/ea0lV34If1eDcG3nulQ63YNr5KPDfMsjbdtpSWOxKKJ7nAc2qlVuYwEvCr4raIuredNoR7K4JCkmTGaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@ -7085,14 +7085,14 @@
} }
}, },
"node_modules/svelte-check": { "node_modules/svelte-check": {
"version": "4.0.2", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.2.tgz", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.3.tgz",
"integrity": "sha512-w2yqcG9ELJe2RJCnAvB7v0OgkHhL3czzz/tVoxGFfO6y4mOrF6QHCDhXijeXzsU7LVKEwWS3Qd9tza4JBuDxqA==", "integrity": "sha512-V2eqOEuNrPi1jGf307opR1JZ+ITP6/7R8ALKSw4Uw3NWp6GfA+fe7tYtEvZc7QHCavYKBizCK4JFwYjbuPCeXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"chokidar": "^3.4.1", "chokidar": "^4.0.1",
"fdir": "^6.2.0", "fdir": "^6.2.0",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
"sade": "^1.7.4" "sade": "^1.7.4"
@ -7108,10 +7108,26 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/svelte-check/node_modules/chokidar": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-check/node_modules/fdir": { "node_modules/svelte-check/node_modules/fdir": {
"version": "6.3.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.3.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.0.tgz",
"integrity": "sha512-QOnuT+BOtivR77wYvCWHfGt9s4Pz1VIMbD463vegT5MLqNXy8rYFT/lPVEqf/bhYeT6qmqrNHhsX+rWwe3rOCQ==", "integrity": "sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@ -7138,6 +7154,20 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/svelte-check/node_modules/readdirp": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz",
"integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-eslint-parser": { "node_modules/svelte-eslint-parser": {
"version": "0.41.1", "version": "0.41.1",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.1.tgz", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.1.tgz",

View File

@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { type ServerAboutResponseDto } from '@immich/sdk'; import { type ServerAboutResponseDto, type ServerVersionHistoryResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let onClose: () => void; export let onClose: () => void;
export let info: ServerAboutResponseDto; export let info: ServerAboutResponseDto;
export let versions: ServerVersionHistoryResponseDto[];
</script> </script>
<Portal> <Portal>
<FullScreenModal title={$t('about')} {onClose}> <FullScreenModal title={$t('about')} {onClose}>
<div <div class="flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary">
class="immich-scrollbar max-h-[500px] overflow-y-auto flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary"
>
<div> <div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc" <label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
>Immich</label >Immich</label
@ -151,6 +151,35 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="col-span-full">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-history"
>{$t('version_history')}</label
>
<ul id="version-history" class="list-none">
{#each versions.slice(0, 5) as item (item.id)}
{@const createdAt = DateTime.fromISO(item.createdAt)}
<li>
<span
class="immich-form-label pb-2 text-xs"
id="version-history"
title={createdAt.toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS)}
>
{$t('version_history_item', {
values: {
version: item.version,
date: createdAt.toLocaleString({
month: 'short',
day: 'numeric',
year: 'numeric',
}),
},
})}
</span>
</li>
{/each}
</ul>
</div>
</div> </div>
</FullScreenModal> </FullScreenModal>
</Portal> </Portal>

View File

@ -4,7 +4,12 @@
import { requestServerInfo } from '$lib/utils/auth'; import { requestServerInfo } from '$lib/utils/auth';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; import {
getAboutInfo,
getVersionHistory,
type ServerAboutResponseDto,
type ServerVersionHistoryResponseDto,
} from '@immich/sdk';
const { serverVersion, connected } = websocketStore; const { serverVersion, connected } = websocketStore;
@ -12,16 +17,17 @@
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null; $: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
let aboutInfo: ServerAboutResponseDto; let info: ServerAboutResponseDto;
let versions: ServerVersionHistoryResponseDto[] = [];
onMount(async () => { onMount(async () => {
await requestServerInfo(); await requestServerInfo();
aboutInfo = await getAboutInfo(); [info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]);
}); });
</script> </script>
{#if isOpen} {#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} /> <ServerAboutModal onClose={() => (isOpen = false)} {info} {versions} />
{/if} {/if}
<div <div

View File

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { serverInfo } from '$lib/stores/server-info.store'; import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
@ -8,18 +7,14 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { getByteUnitString } from '../../../utils/byte-units'; import { getByteUnitString } from '../../../utils/byte-units';
import LoadingSpinner from '../loading-spinner.svelte'; import LoadingSpinner from '../loading-spinner.svelte';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
let usageClasses = ''; let usageClasses = '';
let isOpen = false;
$: hasQuota = $user?.quotaSizeInBytes !== null; $: hasQuota = $user?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0; $: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0; $: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
$: usedPercentage = Math.min(Math.round((usedBytes / availableBytes) * 100), 100); $: usedPercentage = Math.min(Math.round((usedBytes / availableBytes) * 100), 100);
let aboutInfo: ServerAboutResponseDto;
const onUpdate = () => { const onUpdate = () => {
usageClasses = getUsageClass(); usageClasses = getUsageClass();
}; };
@ -42,14 +37,9 @@
onMount(async () => { onMount(async () => {
await requestServerInfo(); await requestServerInfo();
aboutInfo = await getAboutInfo();
}); });
</script> </script>
{#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
{/if}
<div <div
class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm" class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm"
title={$t('storage_usage', { title={$t('storage_usage', {

View File

@ -1278,6 +1278,8 @@
"version": "Version", "version": "Version",
"version_announcement_closing": "Your friend, Alex", "version_announcement_closing": "Your friend, Alex",
"version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.", "version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.",
"version_history": "Version History",
"version_history_item": "Installed {version} on {date}",
"video": "Video", "video": "Video",
"video_hover_setting": "Play video thumbnail on hover", "video_hover_setting": "Play video thumbnail on hover",
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",