Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf

This commit is contained in:
Skyler Mäntysaari
2023-01-27 23:34:39 +02:00
committed by GitHub
163 changed files with 1693 additions and 1424 deletions

View File

@@ -78,6 +78,16 @@ jobs:
type=ref,event=tag
type=raw,value=release,enable=${{ github.event_name == 'release' }}
- name: Determine build cache output
id: cache-target
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
# Essentially just ignore the cache output (PR can't write to registry cache)
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
else
echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT
fi
- name: Build and push image
uses: docker/build-push-action@v3.3.0
with:
@@ -85,6 +95,6 @@ jobs:
platforms: linux/arm/v7,linux/amd64,linux/arm64
# Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.metadata.outputs.tags }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }}

View File

@@ -24,6 +24,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
with:
token: ${{ secrets.ORG_RELEASE_TOKEN }}
- name: Bump version
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"

31
.github/workflows/static_analysis.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Static Code Analysis
on:
workflow_dispatch:
pull_request:
push:
branches: [main]
jobs:
mobile-dart-analyze:
name: Run Dart Code Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.3.10'
- name: Install dependencies
run: dart pub get
working-directory: ./mobile
- name: Run dart analyze
run: dart analyze --fatal-infos
working-directory: ./mobile

View File

@@ -10,9 +10,6 @@ REDIS_HOSTNAME=immich-redis-test
# Upload File Config
UPLOAD_LOCATION=./upload
# JWT SECRET
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false

View File

@@ -30,16 +30,6 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# JWT SECRET
#
# This JWT_SECRET is used to sign the authentication keys for user login
# You should set it to a long randomly generated value
# You can use this command to generate one: openssl rand -base64 128
###################################################################################
JWT_SECRET=
###################################################################################
# Reverse Geocoding
#

View File

@@ -1,5 +1,5 @@
---
sidebar_position: 6
sidebar_position: 7
---
# FAQ
@@ -20,9 +20,9 @@ Immich doesn't have the mechanism to sync an existing directory with the server.
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
### What happens to existing files after I choose a new [Storage Template](/docs/features/storage-template.mdx)?
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/features/jobs.md) page.
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
### Why is object detection not very good?
@@ -42,7 +42,7 @@ The non-root user/group needs read/write access to the volume mounts, including
### How can I reset the admin password?
The admin password can be reset by running the [reset-admin-password](/docs/features/server-commands.md) command on the immich-server.
The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server.
### How can I **purge** data from Immich?

View File

@@ -0,0 +1,5 @@
{
"label": "Administration",
"position": 4
}

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -18,6 +18,6 @@ Several Immich functionalities are implemented as jobs, which run in the backgro
## Storage Migration
This job can be run after changing the [Storage Template](/docs/features/storage-template.mdx), in order to apply the change to the existing library.
This job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library.
![Storage Migration](./img/admin-jobs-template.png)

View File

@@ -14,19 +14,19 @@ To toggle the password login setting via the web, navigate to the "Administratio
### Server Command
There are two [Server Commands](/docs/features/server-commands.md) for password login:
There are two [Server Commands](/docs/administration/server-commands.md) for password login:
1. `enable-password-login`
2. `disable-password-login`
See [Server Commands](/docs/features/server-commands.md) for more details about how to run them.
See [Server Commands](/docs/administration/server-commands.md) for more details about how to run them.
## Password Reset
### Admin
To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/features/server-commands.md).
To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/administration/server-commands.md).
### User
Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/features/user-management.mdx#password-reset) for more information about how to do this.
Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/administration/user-management.mdx#password-reset) for more information about how to do this.

View File

@@ -1,4 +1,4 @@
{
"label": "Developer",
"position": 4
"position": 5
}

View File

@@ -24,7 +24,7 @@ All the services are packaged to run as with single Docker Compose command.
1. Clone the project repo.
2. Run `cp docker/example.env docker/.env`.
3. Edit `docker/.env` to provide values for the required variables `UPLOAD_LOCATION` and `JWT_SECRET`.
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
4. From the root directory, run:
```bash title="Start development server"

View File

@@ -15,9 +15,9 @@ Users can change their own passwords.
![Change Password](./img/user-change-password.png)
:::tip Reset Password
The admin can reset a password through the [User Management](/docs/features/user-management.mdx) screen.
The admin can reset a password through the [User Management](/docs/administration/user-management.mdx) screen.
:::
:::tip Reset Admin Password
The admin password can be reset using a [Server Command](/docs/features/server-commands.md)
The admin password can be reset using a [Server Command](/docs/administration/server-commands.md)
:::

View File

@@ -1,4 +1,4 @@
{
"label": "Guides",
"position": 5
"position": 6
}

View File

@@ -63,15 +63,6 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba
LOG_LEVEL=simple
###################################################################################
# JWT SECRET
###################################################################################
# This JWT_SECRET is used to sign the authentication keys for user login
# You should set it to a long randomly generated value
# You can use this command to generate one: openssl rand -base64 128
JWT_SECRET=
###################################################################################
# Reverse Geocoding
####################################################################################
@@ -102,11 +93,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
- Populate custom database information if necessary.
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
- Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key:
```bash title="Command to generate secure JWT_SECRET key"
openssl rand -base64 128
```
### Step 3 - Start the containers

View File

@@ -40,11 +40,6 @@ Install Immich using Portainer's Stack feature.
* Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
* Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key:
```bash title="Generate secure JWT_SECRET key"
openssl rand -base64 128
```
11. Click on "**Deploy the stack**".

View File

@@ -55,7 +55,6 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Past the entire contents of the [Immich example.env](https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env) file into the Unraid editor, then **before saving** edit the following:
- `JWT_SECRET`: Generate a unique secret and paste the value here > Can be generated by either typing `openssl rand -base64 128` in your terminal or copying from [uuidgenerator](https://www.uuidgenerator.net/version1)
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
<img

View File

@@ -15,7 +15,7 @@ function HomepageHeader() {
<p>ON MOBILE DEVICE</p>
</div>
<div className="flex place-items-center place-content-center mt-9 mb-16 gap-4 ">
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 mb-16 gap-4 ">
<Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold"
to="docs/overview/introduction"

View File

@@ -13,9 +13,15 @@
{ "source": "/docs/overview/technology-stack", "destination": "/docs/developer/architecture" },
{ "source": "/docs/usage/automatic-backup", "destination": "/docs/features/automatic-backup" },
{ "source": "/docs/usage/bulk-upload", "destination": "/docs/features/bulk-upload" },
{ "source": "/docs/usage/oauth", "destination": "/docs/features/oauth" },
{ "source": "/docs/usage/oauth", "destination": "/docs/administration/oauth" },
{ "source": "/docs/usage/post-installation", "destination": "/docs/install/post-install" },
{ "source": "/docs/usage/update", "destination": "/docs/install/docker-compose#step-4---upgrading" },
{ "source": "/docs/usage/server-commands", "destination": "/docs/features/server-commands" }
{ "source": "/docs/usage/server-commands", "destination": "/docs/administration/server-commands" },
{ "source": "/docs/features/jobs", "destination": "/docs/administration/jobs" },
{ "source": "/docs/features/oauth", "destination": "/docs/administration/oauth" },
{ "source": "/docs/features/password-login", "destination": "/docs/administration/password-login" },
{ "source": "/docs/features/server-commands", "destination": "/docs/administration/server-commands" },
{ "source": "/docs/features/storage-template", "destination": "/docs/administration/storage-template" },
{ "source": "/docs/features/user-management", "destination": "/docs/administration/user-management" }
]
}

View File

@@ -45,12 +45,6 @@ populate_upload_location() {
replace_env_value "UPLOAD_LOCATION" $upload_location
}
generate_jwt_secret() {
echo "Generating JWT_SECRET value..."
jwt_secret=$(openssl rand -base64 128)
replace_env_value "JWT_SECRET" $jwt_secret
}
start_docker_compose() {
echo "Starting Immich's docker containers"
@@ -92,5 +86,4 @@ create_immich_directory
download_docker_compose_file
download_dot_env_file
populate_upload_location
generate_jwt_secret
start_docker_compose

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 65,
"android.injected.version.name" => "1.42.0",
"android.injected.version.code" => 66,
"android.injected.version.name" => "1.43.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import '../test_utils/general_helper.dart';
import '../test_utils/login_helper.dart';
void main() async {
await ImmichTestHelper.initialize();
@@ -13,7 +12,7 @@ void main() async {
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentials(
email: " demo@immich.app"
email: " demo@immich.app",
);
await tester.pump(const Duration(milliseconds: 300));
@@ -21,7 +20,7 @@ void main() async {
expect(find.text("login_form_err_leading_whitespace".tr()), findsOneWidget);
await helper.loginHelper.enterCredentials(
email: "demo@immich.app "
email: "demo@immich.app ",
);
await tester.pump(const Duration(milliseconds: 300));
@@ -34,7 +33,7 @@ void main() async {
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentials(
email: "demo.immich.app"
email: "demo.immich.app",
);
await tester.pump(const Duration(milliseconds: 300));

View File

@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../test_utils/general_helper.dart';
@@ -12,8 +10,9 @@ void main() async {
immichWidgetTest("Test correct credentials", (tester, helper) async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper
.enterCredentialsOf(LoginCredentials.testInstance);
await helper.loginHelper.enterCredentialsOf(
LoginCredentials.testInstance,
);
await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginSuccess();
});
@@ -22,16 +21,19 @@ void main() async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentialsOf(
LoginCredentials.testInstanceButWithWrongPassword);
LoginCredentials.testInstanceButWithWrongPassword,
);
await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginFailed();
});
immichWidgetTest("Test login with wrong server URL", (tester, helper) async {
immichWidgetTest("Test login with wrong server URL",
(tester, helper) async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentialsOf(
LoginCredentials.wrongInstanceUrl);
LoginCredentials.wrongInstanceUrl,
);
await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginFailed();
});

View File

@@ -1,17 +1,14 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/main.dart';
import 'package:integration_test/integration_test.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
import 'package:immich_mobile/main.dart' as app;
import 'login_helper.dart';
class ImmichTestHelper {
final WidgetTester tester;
ImmichTestHelper(this.tester);
@@ -43,15 +40,19 @@ class ImmichTestHelper {
await tester.pumpAndSettle();
await EasyLocalization.ensureInitialized();
}
}
@isTest
void immichWidgetTest(String description, Future<void> Function(WidgetTester, ImmichTestHelper) test) {
testWidgets(description, (widgetTester) async {
await ImmichTestHelper.loadApp(widgetTester);
await test(widgetTester, ImmichTestHelper(widgetTester));
}, semanticsEnabled: false);
}
void immichWidgetTest(
String description,
Future<void> Function(WidgetTester, ImmichTestHelper) test,
) {
testWidgets(
description,
(widgetTester) async {
await ImmichTestHelper.loadApp(widgetTester);
await test(widgetTester, ImmichTestHelper(widgetTester));
},
semanticsEnabled: false,
);
}

View File

@@ -1,8 +1,6 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
class ImmichTestLoginHelper {
final WidgetTester tester;

View File

@@ -0,0 +1,129 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart';
class AddToAlbumBottomSheet extends HookConsumerWidget {
/// The asset to add to an album
final List<Asset> assets;
const AddToAlbumBottomSheet({
Key? key,
required this.assets,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider);
final albumService = ref.watch(albumServiceProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect(
() {
// Fetch album updates, e.g., cover image
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return null;
},
[],
);
void addToAlbum(AlbumResponseDto album) async {
final result = await albumService.addAdditionalAssetToAlbum(
assets,
album.id,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: 'Already in ${album.albumName}',
);
} else {
ImmichToast.show(
context: context,
msg: 'Added to ${album.albumName}',
);
}
}
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
Navigator.pop(context);
}
return Card(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
),
),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Align(
alignment: Alignment.center,
child: CustomDraggingHandle(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Add to album',
style: Theme.of(context).textTheme.headline2,
),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Create new album'),
onPressed: () {
ref.watch(assetSelectionProvider.notifier).removeAll();
ref.watch(assetSelectionProvider.notifier).addNewAssets(assets);
AutoRouter.of(context).push(
CreateAlbumRoute(
isSharedAlbum: false,
initialAssets: assets,
),
);
},
),
],
),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: AddToAlbumSliverList(
albums: albums,
sharedAlbums: sharedAlbums,
onAddToAlbum: addToAlbum,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,134 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart';
class AddToAlbumList extends HookConsumerWidget {
/// The asset to add to an album
final List<Asset> assets;
const AddToAlbumList({
Key? key,
required this.assets,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider);
final albumService = ref.watch(albumServiceProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect(
() {
// Fetch album updates, e.g., cover image
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
return null;
},
[],
);
void addToAlbum(AlbumResponseDto album) async {
final result = await albumService.addAdditionalAssetToAlbum(
assets,
album.id,
);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: 'Already in ${album.albumName}',
);
} else {
ImmichToast.show(
context: context,
msg: 'Added to ${album.albumName}',
);
}
}
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
Navigator.pop(context);
}
return Card(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
),
),
child: ListView(
padding: const EdgeInsets.all(18.0),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Align(
alignment: Alignment.center,
child: CustomDraggingHandle(),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Add to album',
style: Theme.of(context).textTheme.headline2,
),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('New album'),
onPressed: () {
ref.watch(assetSelectionProvider.notifier).removeAll();
ref.watch(assetSelectionProvider.notifier).addNewAssets(assets);
AutoRouter.of(context).push(
CreateAlbumRoute(
isSharedAlbum: false,
initialAssets: assets,
),
);
},
),
],
),
],
),
if (sharedAlbums.isNotEmpty)
ExpansionTile(
title: const Text('Shared'),
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
leading: const Icon(Icons.group),
children: sharedAlbums.map((album) =>
AlbumThumbnailListTile(
album: album,
onTap: () => addToAlbum(album),
),
).toList(),
),
const SizedBox(height: 12),
... albums.map((album) =>
AlbumThumbnailListTile(
album: album,
onTap: () => addToAlbum(album),
),
).toList(),
],
),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
import 'package:openapi/api.dart';
class AddToAlbumSliverList extends HookConsumerWidget {
/// The asset to add to an album
final List<AlbumResponseDto> albums;
final List<AlbumResponseDto> sharedAlbums;
final void Function(AlbumResponseDto) onAddToAlbum;
const AddToAlbumSliverList({
Key? key,
required this.onAddToAlbum,
required this.albums,
required this.sharedAlbums,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: albums.length + (sharedAlbums.isEmpty ? 0 : 1),
(context, index) {
// Build shared expander
if (index == 0 && sharedAlbums.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
title: const Text('Shared'),
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
leading: const Icon(Icons.group),
children: sharedAlbums.map((album) =>
AlbumThumbnailListTile(
album: album,
onTap: () => onAddToAlbum(album),
),
).toList(),
),
);
}
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = albums[offset];
return AlbumThumbnailListTile(
album: album,
onTap: () => onAddToAlbum(album),
);
}
),
);
}
}

View File

@@ -0,0 +1,115 @@
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:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class AlbumThumbnailListTile extends StatelessWidget {
const AlbumThumbnailListTile({
Key? key,
required this.album,
this.onTap,
}) : super(key: key);
final AlbumResponseDto album;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var cardSize = 68.0;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
buildEmptyThumbnail() {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: SizedBox(
height: cardSize,
width: cardSize,
child: const Center(
child: Icon(Icons.no_photography),
),
),
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap ?? () {
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
},
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.albumThumbnailAssetId == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.albumName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
album.assetCount == 1
? 'album_thumbnail_card_item'
: 'album_thumbnail_card_items',
style: const TextStyle(
fontSize: 12,
),
).tr(args: ['${album.assetCount}']),
if (album.shared)
const Text(
'album_thumbnail_card_shared',
style: TextStyle(
fontSize: 12,
),
).tr()
],
)
],
),
),
],
),
),
);
}
}

View File

@@ -96,7 +96,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
if (isSuccess) {
Navigator.pop(context);
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
ref.refresh(sharedAlbumDetailProvider(albumId));
ref.invalidate(sharedAlbumDetailProvider(albumId));
} else {
Navigator.pop(context);
ImmichToast.show(

View File

@@ -62,7 +62,7 @@ class AlbumViewerPage extends HookConsumerWidget {
if (addAssetsResult != null &&
addAssetsResult.successfullyAdded > 0) {
ref.refresh(sharedAlbumDetailProvider(albumId));
ref.invalidate(sharedAlbumDetailProvider(albumId));
}
ImmichLoadingOverlayController.appLoader.hide();
@@ -88,7 +88,7 @@ class AlbumViewerPage extends HookConsumerWidget {
.addAdditionalUserToAlbum(sharedUserIds, albumId);
if (isSuccess) {
ref.refresh(sharedAlbumDetailProvider(albumId));
ref.invalidate(sharedAlbumDetailProvider(albumId));
}
ImmichLoadingOverlayController.appLoader.hide();

View File

@@ -11,12 +11,18 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
// ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget {
bool isSharedAlbum;
final bool isSharedAlbum;
final List<Asset>? initialAssets;
CreateAlbumPage({Key? key, required this.isSharedAlbum}) : super(key: key);
const CreateAlbumPage({
Key? key,
required this.isSharedAlbum,
this.initialAssets,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {

View File

@@ -22,7 +22,7 @@ class LibraryPage extends HookConsumerWidget {
[],
);
Widget _buildAppBar() {
Widget buildAppBar() {
return const SliverAppBar(
centerTitle: true,
floating: true,
@@ -40,7 +40,7 @@ class LibraryPage extends HookConsumerWidget {
);
}
Widget _buildCreateAlbumButton() {
Widget buildCreateAlbumButton() {
return GestureDetector(
onTap: () {
AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false));
@@ -83,7 +83,7 @@ class LibraryPage extends HookConsumerWidget {
return Scaffold(
body: CustomScrollView(
slivers: [
_buildAppBar(),
buildAppBar(),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(12.0),
@@ -99,7 +99,7 @@ class LibraryPage extends HookConsumerWidget {
child: Wrap(
spacing: 12,
children: [
_buildCreateAlbumButton(),
buildCreateAlbumButton(),
for (var album in albums)
AlbumThumbnailCard(
album: album,

View File

@@ -188,7 +188,7 @@ class ExifBottomSheet extends HookConsumerWidget {
),
),
subtitle: Text(
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
],

View File

@@ -11,6 +11,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
required this.onDownloadPressed,
required this.onSharePressed,
required this.onDeletePressed,
required this.onAddToAlbumPressed,
required this.onToggleMotionVideo,
required this.isPlayingMotionVideo,
}) : super(key: key);
@@ -20,6 +21,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo;
final VoidCallback onDeletePressed;
final VoidCallback onAddToAlbumPressed;
final Function onSharePressed;
final bool isPlayingMotionVideo;
@@ -80,6 +82,18 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
color: Colors.grey[200],
),
),
if (asset.isRemote)
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onAddToAlbumPressed();
},
icon: Icon(
Icons.add,
color: Colors.grey[200],
),
),
IconButton(
iconSize: iconSize,
splashRadius: iconSize,

View File

@@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_list.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
@@ -105,6 +107,22 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
void addToAlbum(Asset addToAlbumAsset) {
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
context: context,
builder: (BuildContext _) {
return AddToAlbumBottomSheet(
assets: [addToAlbumAsset],
);
},
);
}
return Scaffold(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
@@ -130,6 +148,7 @@ class GalleryViewerPage extends HookConsumerWidget {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}),
onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
),
body: SafeArea(
child: PageView.builder(

View File

@@ -8,7 +8,8 @@ class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache");
static Future<List<Map<String, dynamic>>> _computeSerialize(
List<Asset> assets) async {
List<Asset> assets,
) async {
return assets.map((e) => e.toJson()).toList();
}

View File

@@ -42,8 +42,13 @@ class _AssetGroupsToRenderListComputeParameters {
final Map<String, List<Asset>> groups;
final int perRow;
_AssetGroupsToRenderListComputeParameters(this.monthFormat, this.dayFormat,
this.dayFormatYear, this.groups, this.perRow);
_AssetGroupsToRenderListComputeParameters(
this.monthFormat,
this.dayFormat,
this.dayFormatYear,
this.groups,
this.perRow,
);
}
class RenderList {
@@ -52,7 +57,8 @@ class RenderList {
RenderList(this.elements);
static Future<RenderList> _processAssetGroupData(
_AssetGroupsToRenderListComputeParameters data) async {
_AssetGroupsToRenderListComputeParameters data,
) async {
final monthFormat = DateFormat(data.monthFormat);
final dayFormatSameYear = DateFormat(data.dayFormat);
final dayFormatOtherYear = DateFormat(data.dayFormatYear);

View File

@@ -1,4 +1,3 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

View File

@@ -1,12 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class ControlBottomAppBar extends ConsumerWidget {
@@ -16,11 +13,13 @@ class ControlBottomAppBar extends ConsumerWidget {
final void Function() onCreateNewAlbum;
final List<AlbumResponseDto> albums;
final List<AlbumResponseDto> sharedAlbums;
const ControlBottomAppBar({
Key? key,
required this.onShare,
required this.onDelete,
required this.sharedAlbums,
required this.albums,
required this.onAddToAlbum,
required this.onCreateNewAlbum,
@@ -56,60 +55,6 @@ class ControlBottomAppBar extends ConsumerWidget {
);
}
Widget renderAlbums() {
Widget renderAlbum(AlbumResponseDto album) {
final box = Hive.box(userInfoBox);
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GestureDetector(
onTap: () => onAddToAlbum(album),
child: Container(
width: 112,
padding: const EdgeInsets.all(6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
width: 100,
height: 100,
fit: BoxFit.cover,
imageUrl: getAlbumThumbnailUrl(album),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
cacheKey: getAlbumThumbNailCacheKey(album),
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
album.albumName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12.0,
),
),
),
],
),
),
),
);
}
return SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemBuilder: (buildContext, i) => renderAlbum(albums[i]),
itemCount: albums.length,
),
);
}
return DraggableScrollableSheet(
initialChildSize: 0.30,
minChildSize: 0.15,
@@ -119,42 +64,53 @@ class ControlBottomAppBar extends ConsumerWidget {
BuildContext context,
ScrollController scrollController,
) {
return SingleChildScrollView(
controller: scrollController,
child: Card(
elevation: 12.0,
shape: const RoundedRectangleBorder(
return Card(
elevation: 12.0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
margin: const EdgeInsets.all(0),
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
margin: const EdgeInsets.all(0),
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Column(
children: <Widget>[
const SizedBox(height: 12),
const CustomDraggingHandle(),
const SizedBox(height: 12),
renderActionButtons(),
const Divider(
indent: 16,
endIndent: 16,
thickness: 1,
),
AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum),
],
),
),
),
child: Column(
children: <Widget>[
const SizedBox(height: 12),
const CustomDraggingHandle(),
const SizedBox(height: 12),
renderActionButtons(),
const Divider(
indent: 16,
endIndent: 16,
thickness: 1,
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: AddToAlbumSliverList(
albums: albums,
sharedAlbums: sharedAlbums,
onAddToAlbum: onAddToAlbum,
),
AddToAlbumTitleRow(
onCreateNewAlbum: () => onCreateNewAlbum(),
),
renderAlbums(),
const SizedBox(height: 200),
],
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 200),
)
],
),
),
);
@@ -185,9 +141,10 @@ class AddToAlbumTitleRow extends StatelessWidget {
fontWeight: FontWeight.bold,
),
).tr(),
TextButton(
TextButton.icon(
onPressed: onCreateNewAlbum,
child: Text(
icon: const Icon(Icons.add),
label: Text(
"control_bottom_app_bar_create_new_album",
style: TextStyle(
color: Theme.of(context).primaryColor,

View File

@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
@@ -37,6 +38,7 @@ class HomePage extends HookConsumerWidget {
final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0);
@@ -46,6 +48,7 @@ class HomePage extends HookConsumerWidget {
ref.read(websocketProvider.notifier).connect();
ref.read(assetProvider.notifier).getAllAsset();
ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.watch(serverInfoProvider.notifier).getServerVersion();
selectionEnabledHook.addListener(() {
@@ -147,6 +150,7 @@ class HomePage extends HookConsumerWidget {
if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false;
AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id));
@@ -220,6 +224,7 @@ class HomePage extends HookConsumerWidget {
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum,
),
],

View File

@@ -235,7 +235,7 @@ class ServerEndpointInput extends StatelessWidget {
labelText: 'login_form_endpoint_url'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_endpoint_hint'.tr(),
errorMaxLines: 4
errorMaxLines: 4,
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,

View File

@@ -24,6 +24,7 @@ class TilesPerRow extends HookConsumerWidget {
void sliderChangedEnd(double _) {
ref.invalidate(assetProvider);
ref.watch(assetProvider.notifier).getAllAsset();
}
useEffect(

View File

@@ -60,7 +60,8 @@ class _$AppRouter extends RootStackRouter {
isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener,
loadPreview: args.loadPreview,
loadOriginal: args.loadOriginal));
loadOriginal: args.loadOriginal,
showExifSheet: args.showExifSheet));
},
VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -87,7 +88,9 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>(
routeData: routeData,
child: CreateAlbumPage(
key: args.key, isSharedAlbum: args.isSharedAlbum));
key: args.key,
isSharedAlbum: args.isSharedAlbum,
initialAssets: args.initialAssets));
},
AssetSelectionRoute.name: (routeData) {
return CustomPage<AssetSelectionPageResult?>(
@@ -307,7 +310,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener,
required bool loadPreview,
required bool loadOriginal})
required bool loadOriginal,
void Function()? showExifSheet})
: super(ImageViewerRoute.name,
path: '/image-viewer-page',
args: ImageViewerRouteArgs(
@@ -318,7 +322,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
loadPreview: loadPreview,
loadOriginal: loadOriginal));
loadOriginal: loadOriginal,
showExifSheet: showExifSheet));
static const String name = 'ImageViewerRoute';
}
@@ -332,7 +337,8 @@ class ImageViewerRouteArgs {
required this.isZoomedFunction,
required this.isZoomedListener,
required this.loadPreview,
required this.loadOriginal});
required this.loadOriginal,
this.showExifSheet});
final Key? key;
@@ -350,9 +356,11 @@ class ImageViewerRouteArgs {
final bool loadOriginal;
final void Function()? showExifSheet;
@override
String toString() {
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal}';
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}';
}
}
@@ -432,24 +440,31 @@ class SearchResultRouteArgs {
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
CreateAlbumRoute({Key? key, required bool isSharedAlbum})
CreateAlbumRoute(
{Key? key, required bool isSharedAlbum, List<Asset>? initialAssets})
: super(CreateAlbumRoute.name,
path: '/create-album-page',
args: CreateAlbumRouteArgs(key: key, isSharedAlbum: isSharedAlbum));
args: CreateAlbumRouteArgs(
key: key,
isSharedAlbum: isSharedAlbum,
initialAssets: initialAssets));
static const String name = 'CreateAlbumRoute';
}
class CreateAlbumRouteArgs {
const CreateAlbumRouteArgs({this.key, required this.isSharedAlbum});
const CreateAlbumRouteArgs(
{this.key, required this.isSharedAlbum, this.initialAssets});
final Key? key;
final bool isSharedAlbum;
final List<Asset>? initialAssets;
@override
String toString() {
return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum}';
return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}';
}
}

View File

@@ -30,8 +30,8 @@ class TabNavigationObserver extends AutoRouterObserver {
// Perform tasks on re-visit to SearchRoute
if (route.name == 'SearchRoute') {
// Refresh Location State
ref.refresh(getCuratedLocationProvider);
ref.refresh(getCuratedObjectProvider);
ref.invalidate(getCuratedLocationProvider);
ref.invalidate(getCuratedObjectProvider);
}
if (route.name == 'SharingRoute') {

View File

@@ -83,6 +83,7 @@ class ImmichLogger {
}
// Share file
// ignore: deprecated_member_use
await Share.shareFiles(
[filePath],
subject: "Immich logs $dateTime",

View File

@@ -40,6 +40,7 @@ class ShareService {
}
});
// ignore: deprecated_member_use
Share.shareFiles(
await Future.wait(downloadedFilePaths),
sharePositionOrigin: Rect.zero,

View File

@@ -10,8 +10,10 @@ String getThumbnailUrl(
return _getThumbnailUrl(asset.id, type: type);
}
String getThumbnailCacheKey(final AssetResponseDto asset,
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
String getThumbnailCacheKey(
final AssetResponseDto asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
return _getThumbnailCacheKey(asset.id, type);
}

View File

@@ -31,6 +31,11 @@ ThemeData immichDarkTheme = ThemeData(
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: immichDarkThemePrimaryColor,
),
),
appBarTheme: AppBarTheme(
titleTextStyle: TextStyle(
fontFamily: 'WorkSans',
@@ -59,7 +64,7 @@ ThemeData immichDarkTheme = ThemeData(
headline2: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color.fromARGB(255, 148, 151, 155),
color: Color.fromARGB(255, 255, 255, 255),
),
headline3: TextStyle(
fontSize: 12,

View File

@@ -31,7 +31,9 @@ extension WithETag on AssetApi {
final responseBody = await _decodeBodyBytes(response);
final etag = response.headers[HttpHeaders.etagHeader];
final data = (await apiClient.deserializeAsync(
responseBody, 'List<AssetResponseDto>') as List)
responseBody,
'List<AssetResponseDto>',
) as List)
.cast<AssetResponseDto>()
.toList();
return Pair(data, etag);

View File

@@ -102,8 +102,6 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info |
*DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info |
*DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |

View File

@@ -9,109 +9,9 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**createDeviceInfo**](DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info |
[**updateDeviceInfo**](DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info |
[**upsertDeviceInfo**](DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
# **createDeviceInfo**
> DeviceInfoResponseDto createDeviceInfo(upsertDeviceInfoDto)
@deprecated
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = DeviceInfoApi();
final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto |
try {
final result = api_instance.createDeviceInfo(upsertDeviceInfoDto);
print(result);
} catch (e) {
print('Exception when calling DeviceInfoApi->createDeviceInfo: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| |
### Return type
[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateDeviceInfo**
> DeviceInfoResponseDto updateDeviceInfo(upsertDeviceInfoDto)
@deprecated
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = DeviceInfoApi();
final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto |
try {
final result = api_instance.updateDeviceInfo(upsertDeviceInfoDto);
print(result);
} catch (e) {
print('Exception when calling DeviceInfoApi->updateDeviceInfo: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| |
### Return type
[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **upsertDeviceInfo**
> DeviceInfoResponseDto upsertDeviceInfo(upsertDeviceInfoDto)

View File

@@ -22,7 +22,7 @@ Name | Type | Description | Notes
**fNumber** | **num** | | [optional]
**focalLength** | **num** | | [optional]
**iso** | **num** | | [optional]
**exposureTime** | **num** | | [optional]
**exposureTime** | **String** | | [optional]
**latitude** | **num** | | [optional]
**longitude** | **num** | | [optional]
**city** | **String** | | [optional]

View File

@@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**command** | [**JobCommand**](JobCommand.md) | |
**includeAllAssets** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -16,110 +16,6 @@ class DeviceInfoApi {
final ApiClient apiClient;
/// @deprecated
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
Future<Response> createDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
// ignore: prefer_const_declarations
final path = r'/device-info';
// ignore: prefer_final_locals
Object? postBody = upsertDeviceInfoDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// @deprecated
///
/// Parameters:
///
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
Future<DeviceInfoResponseDto?> createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
final response = await createDeviceInfoWithHttpInfo(upsertDeviceInfoDto,);
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto;
}
return null;
}
/// @deprecated
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
Future<Response> updateDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
// ignore: prefer_const_declarations
final path = r'/device-info';
// ignore: prefer_final_locals
Object? postBody = upsertDeviceInfoDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PATCH',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// @deprecated
///
/// Parameters:
///
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
Future<DeviceInfoResponseDto?> updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
final response = await updateDeviceInfoWithHttpInfo(upsertDeviceInfoDto,);
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto;
}
return null;
}
///
///
/// Note: This method returns the HTTP [Response].

View File

@@ -63,7 +63,7 @@ class ExifResponseDto {
num? iso;
num? exposureTime;
String? exposureTime;
num? latitude;
@@ -273,9 +273,7 @@ class ExifResponseDto {
iso: json[r'iso'] == null
? null
: num.parse(json[r'iso'].toString()),
exposureTime: json[r'exposureTime'] == null
? null
: num.parse(json[r'exposureTime'].toString()),
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
latitude: json[r'latitude'] == null
? null
: num.parse(json[r'latitude'].toString()),

View File

@@ -14,25 +14,31 @@ class JobCommandDto {
/// Returns a new [JobCommandDto] instance.
JobCommandDto({
required this.command,
required this.includeAllAssets,
});
JobCommand command;
bool includeAllAssets;
@override
bool operator ==(Object other) => identical(this, other) || other is JobCommandDto &&
other.command == command;
other.command == command &&
other.includeAllAssets == includeAllAssets;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(command.hashCode);
(command.hashCode) +
(includeAllAssets.hashCode);
@override
String toString() => 'JobCommandDto[command=$command]';
String toString() => 'JobCommandDto[command=$command, includeAllAssets=$includeAllAssets]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'command'] = this.command;
json[r'includeAllAssets'] = this.includeAllAssets;
return json;
}
@@ -56,6 +62,7 @@ class JobCommandDto {
return JobCommandDto(
command: JobCommand.fromJson(json[r'command'])!,
includeAllAssets: mapValueOfType<bool>(json, r'includeAllAssets')!,
);
}
return null;
@@ -106,6 +113,7 @@ class JobCommandDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'command',
'includeAllAssets',
};
}

View File

@@ -17,20 +17,6 @@ void main() {
// final instance = DeviceInfoApi();
group('tests for DeviceInfoApi', () {
// @deprecated
//
//Future<DeviceInfoResponseDto> createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async
test('test createDeviceInfo', () async {
// TODO
});
// @deprecated
//
//Future<DeviceInfoResponseDto> updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async
test('test updateDeviceInfo', () async {
// TODO
});
//
//
//Future<DeviceInfoResponseDto> upsertDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async

View File

@@ -86,7 +86,7 @@ void main() {
// TODO
});
// num exposureTime
// String exposureTime
test('to test the property `exposureTime`', () async {
// TODO
});

View File

@@ -21,6 +21,11 @@ void main() {
// TODO
});
// bool includeAllAssets
test('to test the property `includeAllAssets`', () async {
// TODO
});
});

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.42.0+65
version: 1.43.0+66
environment:
sdk: ">=2.17.0 <3.0.0"

View File

@@ -29,6 +29,8 @@ export interface IAssetRepository {
livePhotoAssetEntity?: AssetEntity,
): Promise<AssetEntity>;
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
getAll(): Promise<AssetEntity[]>;
getAllVideos(): Promise<AssetEntity[]>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>;
@@ -61,6 +63,22 @@ export class AssetRepository implements IAssetRepository {
@Inject(ITagRepository) private _tagRepository: ITagRepository,
) {}
async getAllVideos(): Promise<AssetEntity[]> {
return await this.assetRepository.find({
where: { type: AssetType.VIDEO },
});
}
async getAll(): Promise<AssetEntity[]> {
return await this.assetRepository.find({
where: { isVisible: true },
relations: {
exifInfo: true,
smartInfo: true,
},
});
}
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
return await this.assetRepository
.createQueryBuilder('asset')

View File

@@ -19,7 +19,7 @@ import {
import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { assetUploadOption } from '../../config/asset-upload.config';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
@@ -80,7 +80,7 @@ export class AssetController {
})
async uploadFile(
@GetAuthUser() authUser: AuthUserDto,
@UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] },
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> {

View File

@@ -123,6 +123,8 @@ describe('AssetService', () => {
assetRepositoryMock = {
create: jest.fn(),
update: jest.fn(),
getAll: jest.fn(),
getAllVideos: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),

View File

@@ -55,6 +55,7 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { ImmichFile } from '../../config/asset-upload.config';
const fileInfo = promisify(stat);
@@ -82,16 +83,16 @@ export class AssetService {
authUser: AuthUserDto,
createAssetDto: CreateAssetDto,
res: Res,
originalAssetData: Express.Multer.File,
livePhotoAssetData?: Express.Multer.File,
originalAssetData: ImmichFile,
livePhotoAssetData?: ImmichFile,
) {
const checksum = await this.calculateChecksum(originalAssetData.path);
const checksum = originalAssetData.checksum;
const isLivePhoto = livePhotoAssetData !== undefined;
let livePhotoAssetEntity: AssetEntity | undefined;
try {
if (isLivePhoto) {
const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path);
const livePhotoChecksum = livePhotoAssetData.checksum;
livePhotoAssetEntity = await this.createUserAsset(
authUser,
createAssetDto,

View File

@@ -19,8 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
async handleConnection(client: Socket) {
try {
this.logger.log(`New websocket connection: ${client.id}`);
const user = await this.authService.validateSocket(client);
const user = await this.authService.validate(client.request.headers);
if (user) {
client.join(user.id);
} else {
@@ -28,7 +27,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
client.disconnect();
}
} catch (e) {
// Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection');
client.emit('error', 'unauthorized');
client.disconnect();
}
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Patch, Post, Put, ValidationPipe } from '@nestjs/common';
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
@@ -13,24 +13,6 @@ import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/dev
export class DeviceInfoController {
constructor(private readonly deviceInfoService: DeviceInfoService) {}
/** @deprecated */
@Post()
public async createDeviceInfo(
@GetAuthUser() user: AuthUserDto,
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
): Promise<DeviceInfoResponseDto> {
return this.upsertDeviceInfo(user, dto);
}
/** @deprecated */
@Patch()
public async updateDeviceInfo(
@GetAuthUser() user: AuthUserDto,
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
): Promise<DeviceInfoResponseDto> {
return this.upsertDeviceInfo(user, dto);
}
@Put()
public async upsertDeviceInfo(
@GetAuthUser() user: AuthUserDto,

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty } from 'class-validator';
import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator';
export class JobCommandDto {
@IsNotEmpty()
@@ -9,4 +9,8 @@ export class JobCommandDto {
enumName: 'JobCommand',
})
command!: string;
@IsOptional()
@IsBoolean()
includeAllAssets!: boolean;
}

View File

@@ -21,12 +21,12 @@ export class JobController {
@Put('/:jobId')
async sendJobCommand(
@Param(ValidationPipe) params: GetJobDto,
@Body(ValidationPipe) body: JobCommandDto,
@Body(ValidationPipe) dto: JobCommandDto,
): Promise<number> {
if (body.command === 'start') {
return await this.jobService.start(params.jobId);
if (dto.command === 'start') {
return await this.jobService.start(params.jobId, dto.includeAllAssets);
}
if (body.command === 'stop') {
if (dto.command === 'stop') {
return await this.jobService.stop(params.jobId);
}
return 0;

View File

@@ -5,7 +5,7 @@ import { IAssetRepository } from '../asset/asset-repository';
import { AssetType } from '@app/infra';
import { JobId } from './dto/get-job.dto';
import { MACHINE_LEARNING_ENABLED } from '@app/common';
import { getFileNameWithoutExtension } from '../../utils/file-name.util';
const jobIds = Object.values(JobId) as JobId[];
@Injectable()
@@ -19,8 +19,8 @@ export class JobService {
}
}
start(jobId: JobId): Promise<number> {
return this.run(this.asQueueName(jobId));
start(jobId: JobId, includeAllAssets: boolean): Promise<number> {
return this.run(this.asQueueName(jobId), includeAllAssets);
}
async stop(jobId: JobId): Promise<number> {
@@ -36,7 +36,7 @@ export class JobService {
return response;
}
private async run(name: QueueName): Promise<number> {
private async run(name: QueueName, includeAllAssets: boolean): Promise<number> {
const isActive = await this.jobRepository.isActive(name);
if (isActive) {
throw new BadRequestException(`Job is already running`);
@@ -44,7 +44,9 @@ export class JobService {
switch (name) {
case QueueName.VIDEO_CONVERSION: {
const assets = await this._assetRepository.getAssetWithNoEncodedVideo();
const assets = includeAllAssets
? await this._assetRepository.getAllVideos()
: await this._assetRepository.getAssetWithNoEncodedVideo();
for (const asset of assets) {
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
}
@@ -61,7 +63,10 @@ export class JobService {
throw new BadRequestException('Machine learning is not enabled.');
}
const assets = await this._assetRepository.getAssetWithNoSmartInfo();
const assets = includeAllAssets
? await this._assetRepository.getAll()
: await this._assetRepository.getAssetWithNoSmartInfo();
for (const asset of assets) {
await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } });
await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } });
@@ -70,19 +75,37 @@ export class JobService {
}
case QueueName.METADATA_EXTRACTION: {
const assets = await this._assetRepository.getAssetWithNoEXIF();
const assets = includeAllAssets
? await this._assetRepository.getAll()
: await this._assetRepository.getAssetWithNoEXIF();
for (const asset of assets) {
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } });
await this.jobRepository.add({
name: JobName.EXTRACT_VIDEO_METADATA,
data: {
asset,
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
},
});
} else {
await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } });
await this.jobRepository.add({
name: JobName.EXIF_EXTRACTION,
data: {
asset,
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
},
});
}
}
return assets.length;
}
case QueueName.THUMBNAIL_GENERATION: {
const assets = await this._assetRepository.getAssetWithNoThumbnail();
const assets = includeAllAssets
? await this._assetRepository.getAll()
: await this._assetRepository.getAssetWithNoThumbnail();
for (const asset of assets) {
await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
}

View File

@@ -1,7 +1,6 @@
import { immichAppConfig } from '@app/common/config';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
@@ -23,6 +22,9 @@ import {
SystemConfigController,
UserController,
} from './controllers';
import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy';
import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy';
import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy';
@Module({
imports: [
@@ -34,8 +36,6 @@ import {
AssetModule,
ImmichJwtModule,
DeviceInfoModule,
ServerInfoModule,
@@ -64,7 +64,7 @@ import {
SystemConfigController,
UserController,
],
providers: [],
providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
})
export class AppModule implements NestModule {
// TODO: check if consumer is needed or remove

View File

@@ -1,10 +1,10 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { randomUUID } from 'crypto';
import { createHash, randomUUID } from 'crypto';
import { Request } from 'express';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { diskStorage, StorageEngine } from 'multer';
import { extname, join } from 'path';
import sanitize from 'sanitize-filename';
import { AuthUserDto } from '../decorators/auth-user.decorator';
@@ -12,14 +12,40 @@ import { patchFormData } from '../utils/path-form-data.util';
const logger = new Logger('AssetUploadConfig');
export interface ImmichFile extends Express.Multer.File {
/** sha1 hash of file */
checksum: Buffer;
}
export const assetUploadOption: MulterOptions = {
fileFilter,
storage: diskStorage({
destination,
filename,
}),
storage: customStorage(),
};
export function customStorage(): StorageEngine {
const storage = diskStorage({ destination, filename });
return {
_handleFile(req, file, callback) {
const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk));
storage._handleFile(req, file, (error, response) => {
if (error) {
hash.destroy();
callback(error);
} else {
callback(null, { ...response, checksum: hash.digest() } as ImmichFile);
}
});
},
_removeFile(req, file, callback) {
storage._removeFile(req, file, callback);
},
};
}
export const multerUtils = { fileFilter, filename, destination };
function fileFilter(req: Request, file: any, cb: any) {

View File

@@ -1,7 +1,7 @@
import { UseGuards } from '@nestjs/common';
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard';
import { AuthGuard } from '../modules/immich-auth/guards/auth.guard';
interface AuthenticatedOptions {
admin?: boolean;

View File

@@ -4,5 +4,8 @@ declare global {
namespace Express {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface User extends AuthUserDto {}
export interface Request {
user: AuthUserDto;
}
}
}

View File

@@ -40,9 +40,6 @@ async function bootstrap() {
.addBearerAuth({
type: 'http',
scheme: 'Bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
})
.addServer('/api')

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
import { JWT_STRATEGY } from '../strategies/jwt.strategy';
import { AUTH_COOKIE_STRATEGY } from '../strategies/user-auth.strategy';
import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
@Injectable()
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {}
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {}

View File

@@ -0,0 +1,24 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { AuthService, AuthUserDto, UserService } from '@app/domain';
import { Strategy } from 'passport-custom';
import { Request } from 'express';
export const AUTH_COOKIE_STRATEGY = 'auth-cookie';
@Injectable()
export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) {
constructor(private userService: UserService, private authService: AuthService) {
super();
}
async validate(request: Request): Promise<AuthUserDto> {
const authUser = await this.authService.validate(request.headers);
if (!authUser) {
throw new UnauthorizedException('Incorrect token provided');
}
return authUser;
}
}

View File

@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { APIKeyStrategy } from './strategies/api-key.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PublicShareStrategy } from './strategies/public-share.strategy';
@Module({
providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy],
})
export class ImmichJwtModule {}

View File

@@ -1,24 +0,0 @@
import { AuthService, AuthUserDto, JwtPayloadDto, jwtSecret } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
export const JWT_STRATEGY = 'jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
constructor(private authService: AuthService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req) => authService.extractJwtFromCookie(req.cookies),
(req) => authService.extractJwtFromHeader(req.headers),
]),
ignoreExpiration: false,
secretOrKey: jwtSecret,
} as StrategyOptions);
}
async validate(payload: JwtPayloadDto): Promise<AuthUserDto> {
return this.authService.validatePayload(payload);
}
}

View File

@@ -1,9 +1,8 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType, ExifEntity, UserEntity } from '@app/infra';
import { ConfigService } from '@nestjs/config';
import { UserEntity } from '@app/infra';
import { userUtils } from '@app/common';
import { IJobRepository, JobName } from '@app/domain';
@@ -13,93 +12,8 @@ export class ScheduleTasksService {
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@Inject(IJobRepository) private jobRepository: IJobRepository,
private configService: ConfigService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async webpConversion() {
const assets = await this.assetRepository.find({
where: {
webpPath: '',
},
});
if (assets.length == 0) {
Logger.log('All assets has webp file - aborting task', 'CronjobWebpGenerator');
return;
}
for (const asset of assets) {
await this.jobRepository.add({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
}
}
@Cron(CronExpression.EVERY_DAY_AT_1AM)
async videoConversion() {
const assets = await this.assetRepository.find({
where: {
type: AssetType.VIDEO,
mimeType: 'video/quicktime',
encodedVideoPath: '',
},
order: {
createdAt: 'DESC',
},
});
for (const asset of assets) {
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
}
}
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async reverseGeocoding() {
const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true';
if (isGeocodingEnabled) {
const exifInfo = await this.exifRepository.find({
where: {
city: IsNull(),
longitude: Not(IsNull()),
latitude: Not(IsNull()),
},
});
for (const exif of exifInfo) {
await this.jobRepository.add({
name: JobName.REVERSE_GEOCODING,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data: { exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! },
});
}
}
}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async extractExif() {
const exifAssets = await this.assetRepository
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.exifInfo', 'ei')
.where('ei."assetId" IS NULL')
.getMany();
for (const asset of exifAssets) {
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } });
} else {
await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } });
}
}
}
@Cron(CronExpression.EVERY_DAY_AT_11PM)
async deleteUserAndRelatedAssets() {
const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });

View File

@@ -0,0 +1,5 @@
import { basename, extname } from 'node:path';
export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path));
}

View File

@@ -5,10 +5,10 @@ import { clearDb, getAuthUser, authCustom } from './test-utils';
import { InfraModule } from '@app/infra';
import { AlbumModule } from '../src/api-v1/album/album.module';
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { AuthService, DomainModule, UserService } from '@app/domain';
import { DataSource } from 'typeorm';
import { AppModule } from '../src/app.module';
function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
return request(app.getHttpServer()).post('/album').send(data);
@@ -21,7 +21,7 @@ describe('Album', () => {
describe('without auth', () => {
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule, ImmichJwtModule],
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
}).compile();
app = moduleFixture.createNestApplication();

View File

@@ -1,5 +1,6 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"modulePaths": ["<rootDir>", "<rootDir>../../../"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",

Some files were not shown because too many files have changed in this diff Show More