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
+13 -3
View File
@@ -78,6 +78,16 @@ jobs:
type=ref,event=tag type=ref,event=tag
type=raw,value=release,enable=${{ github.event_name == 'release' }} 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 - name: Build and push image
uses: docker/build-push-action@v3.3.0 uses: docker/build-push-action@v3.3.0
with: with:
@@ -85,6 +95,6 @@ jobs:
platforms: linux/arm/v7,linux/amd64,linux/arm64 platforms: linux/arm/v7,linux/amd64,linux/arm64
# Skip pushing when PR from a fork # Skip pushing when PR from a fork
push: ${{ !github.event.pull_request.head.repo.fork }} push: ${{ !github.event.pull_request.head.repo.fork }}
cache-from: type=gha cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
cache-to: type=gha,mode=max cache-to: ${{ steps.cache-target.outputs.cache-to }}
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
+2
View File
@@ -24,6 +24,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
with:
token: ${{ secrets.ORG_RELEASE_TOKEN }}
- name: Bump version - name: Bump version
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}" run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
+31
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
-3
View File
@@ -10,9 +10,6 @@ REDIS_HOSTNAME=immich-redis-test
# Upload File Config # Upload File Config
UPLOAD_LOCATION=./upload UPLOAD_LOCATION=./upload
# JWT SECRET
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
# MAPBOX # MAPBOX
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY ## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
ENABLE_MAPBOX=false ENABLE_MAPBOX=false
-10
View File
@@ -30,16 +30,6 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup 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 # Reverse Geocoding
# #
+4 -4
View File
@@ -1,5 +1,5 @@
--- ---
sidebar_position: 6 sidebar_position: 7
--- ---
# FAQ # 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. 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? ### 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? ### 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? ### How can I **purge** data from Immich?
+5
View File
@@ -0,0 +1,5 @@
{
"label": "Administration",
"position": 4
}

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -18,6 +18,6 @@ Several Immich functionalities are implemented as jobs, which run in the backgro
## Storage Migration ## 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) ![Storage Migration](./img/admin-jobs-template.png)
@@ -14,19 +14,19 @@ To toggle the password login setting via the web, navigate to the "Administratio
### Server Command ### 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` 1. `enable-password-login`
2. `disable-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 ## Password Reset
### Admin ### 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 ### 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.
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"label": "Developer", "label": "Developer",
"position": 4 "position": 5
} }
+1 -1
View File
@@ -24,7 +24,7 @@ All the services are packaged to run as with single Docker Compose command.
1. Clone the project repo. 1. Clone the project repo.
2. Run `cp docker/example.env docker/.env`. 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: 4. From the root directory, run:
```bash title="Start development server" ```bash title="Start development server"
+2 -2
View File
@@ -15,9 +15,9 @@ Users can change their own passwords.
![Change Password](./img/user-change-password.png) ![Change Password](./img/user-change-password.png)
:::tip Reset Password :::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 :::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)
::: :::
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"label": "Guides", "label": "Guides",
"position": 5 "position": 6
} }
-14
View File
@@ -63,15 +63,6 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba
LOG_LEVEL=simple 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 # Reverse Geocoding
#################################################################################### ####################################################################################
@@ -102,11 +93,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
- Populate custom database information if necessary. - Populate custom database information if necessary.
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. - 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 ### Step 3 - Start the containers
-5
View File
@@ -40,11 +40,6 @@ Install Immich using Portainer's Stack feature.
* Populate custom database information if necessary. * Populate custom database information if necessary.
* Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. * 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**". 11. Click on "**Deploy the stack**".
-1
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**" 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: 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` - `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 <img
+1 -1
View File
@@ -15,7 +15,7 @@ function HomepageHeader() {
<p>ON MOBILE DEVICE</p> <p>ON MOBILE DEVICE</p>
</div> </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 <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" 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" to="docs/overview/introduction"
+8 -2
View File
@@ -13,9 +13,15 @@
{ "source": "/docs/overview/technology-stack", "destination": "/docs/developer/architecture" }, { "source": "/docs/overview/technology-stack", "destination": "/docs/developer/architecture" },
{ "source": "/docs/usage/automatic-backup", "destination": "/docs/features/automatic-backup" }, { "source": "/docs/usage/automatic-backup", "destination": "/docs/features/automatic-backup" },
{ "source": "/docs/usage/bulk-upload", "destination": "/docs/features/bulk-upload" }, { "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/post-installation", "destination": "/docs/install/post-install" },
{ "source": "/docs/usage/update", "destination": "/docs/install/docker-compose#step-4---upgrading" }, { "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" }
] ]
} }
-7
View File
@@ -45,12 +45,6 @@ populate_upload_location() {
replace_env_value "UPLOAD_LOCATION" $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() { start_docker_compose() {
echo "Starting Immich's docker containers" echo "Starting Immich's docker containers"
@@ -92,5 +86,4 @@ create_immich_directory
download_docker_compose_file download_docker_compose_file
download_dot_env_file download_dot_env_file
populate_upload_location populate_upload_location
generate_jwt_secret
start_docker_compose start_docker_compose
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 65, "android.injected.version.code" => 66,
"android.injected.version.name" => "1.42.0", "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') 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')
@@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../test_utils/general_helper.dart'; import '../test_utils/general_helper.dart';
import '../test_utils/login_helper.dart';
void main() async { void main() async {
await ImmichTestHelper.initialize(); await ImmichTestHelper.initialize();
@@ -13,7 +12,7 @@ void main() async {
await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentials( await helper.loginHelper.enterCredentials(
email: " demo@immich.app" email: " demo@immich.app",
); );
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
@@ -21,7 +20,7 @@ void main() async {
expect(find.text("login_form_err_leading_whitespace".tr()), findsOneWidget); expect(find.text("login_form_err_leading_whitespace".tr()), findsOneWidget);
await helper.loginHelper.enterCredentials( await helper.loginHelper.enterCredentials(
email: "demo@immich.app " email: "demo@immich.app ",
); );
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
@@ -34,7 +33,7 @@ void main() async {
await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentials( await helper.loginHelper.enterCredentials(
email: "demo.immich.app" email: "demo.immich.app",
); );
await tester.pump(const Duration(milliseconds: 300)); await tester.pump(const Duration(milliseconds: 300));
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../test_utils/general_helper.dart'; import '../test_utils/general_helper.dart';
@@ -12,8 +10,9 @@ void main() async {
immichWidgetTest("Test correct credentials", (tester, helper) async { immichWidgetTest("Test correct credentials", (tester, helper) async {
await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper await helper.loginHelper.enterCredentialsOf(
.enterCredentialsOf(LoginCredentials.testInstance); LoginCredentials.testInstance,
);
await helper.loginHelper.pressLoginButton(); await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginSuccess(); await helper.loginHelper.assertLoginSuccess();
}); });
@@ -22,16 +21,19 @@ void main() async {
await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentialsOf( await helper.loginHelper.enterCredentialsOf(
LoginCredentials.testInstanceButWithWrongPassword); LoginCredentials.testInstanceButWithWrongPassword,
);
await helper.loginHelper.pressLoginButton(); await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginFailed(); 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.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentialsOf( await helper.loginHelper.enterCredentialsOf(
LoginCredentials.wrongInstanceUrl); LoginCredentials.wrongInstanceUrl,
);
await helper.loginHelper.pressLoginButton(); await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginFailed(); await helper.loginHelper.assertLoginFailed();
}); });
@@ -1,17 +1,14 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/main.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:immich_mobile/main.dart' as app; import 'package:immich_mobile/main.dart' as app;
import 'login_helper.dart'; import 'login_helper.dart';
class ImmichTestHelper { class ImmichTestHelper {
final WidgetTester tester; final WidgetTester tester;
ImmichTestHelper(this.tester); ImmichTestHelper(this.tester);
@@ -43,15 +40,19 @@ class ImmichTestHelper {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
} }
} }
@isTest @isTest
void immichWidgetTest(String description, Future<void> Function(WidgetTester, ImmichTestHelper) test) { void immichWidgetTest(
String description,
testWidgets(description, (widgetTester) async { Future<void> Function(WidgetTester, ImmichTestHelper) test,
await ImmichTestHelper.loadApp(widgetTester); ) {
await test(widgetTester, ImmichTestHelper(widgetTester)); testWidgets(
}, semanticsEnabled: false); description,
(widgetTester) async {
} await ImmichTestHelper.loadApp(widgetTester);
await test(widgetTester, ImmichTestHelper(widgetTester));
},
semanticsEnabled: false,
);
}
@@ -1,8 +1,6 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
class ImmichTestLoginHelper { class ImmichTestLoginHelper {
final WidgetTester tester; final WidgetTester tester;
@@ -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,
),
),
],
),
);
}
}
@@ -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(),
],
),
);
}
}
@@ -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),
);
}
),
);
}
}
@@ -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()
],
)
],
),
),
],
),
),
);
}
}
@@ -96,7 +96,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
if (isSuccess) { if (isSuccess) {
Navigator.pop(context); Navigator.pop(context);
ref.watch(assetSelectionProvider.notifier).disableMultiselection(); ref.watch(assetSelectionProvider.notifier).disableMultiselection();
ref.refresh(sharedAlbumDetailProvider(albumId)); ref.invalidate(sharedAlbumDetailProvider(albumId));
} else { } else {
Navigator.pop(context); Navigator.pop(context);
ImmichToast.show( ImmichToast.show(
@@ -62,7 +62,7 @@ class AlbumViewerPage extends HookConsumerWidget {
if (addAssetsResult != null && if (addAssetsResult != null &&
addAssetsResult.successfullyAdded > 0) { addAssetsResult.successfullyAdded > 0) {
ref.refresh(sharedAlbumDetailProvider(albumId)); ref.invalidate(sharedAlbumDetailProvider(albumId));
} }
ImmichLoadingOverlayController.appLoader.hide(); ImmichLoadingOverlayController.appLoader.hide();
@@ -88,7 +88,7 @@ class AlbumViewerPage extends HookConsumerWidget {
.addAdditionalUserToAlbum(sharedUserIds, albumId); .addAdditionalUserToAlbum(sharedUserIds, albumId);
if (isSuccess) { if (isSuccess) {
ref.refresh(sharedAlbumDetailProvider(albumId)); ref.invalidate(sharedAlbumDetailProvider(albumId));
} }
ImmichLoadingOverlayController.appLoader.hide(); ImmichLoadingOverlayController.appLoader.hide();
@@ -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/album_title_text_field.dart';
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget { 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -22,7 +22,7 @@ class LibraryPage extends HookConsumerWidget {
[], [],
); );
Widget _buildAppBar() { Widget buildAppBar() {
return const SliverAppBar( return const SliverAppBar(
centerTitle: true, centerTitle: true,
floating: true, floating: true,
@@ -40,7 +40,7 @@ class LibraryPage extends HookConsumerWidget {
); );
} }
Widget _buildCreateAlbumButton() { Widget buildCreateAlbumButton() {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false));
@@ -83,7 +83,7 @@ class LibraryPage extends HookConsumerWidget {
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
_buildAppBar(), buildAppBar(),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -99,7 +99,7 @@ class LibraryPage extends HookConsumerWidget {
child: Wrap( child: Wrap(
spacing: 12, spacing: 12,
children: [ children: [
_buildCreateAlbumButton(), buildCreateAlbumButton(),
for (var album in albums) for (var album in albums)
AlbumThumbnailCard( AlbumThumbnailCard(
album: album, album: album,
@@ -188,7 +188,7 @@ class ExifBottomSheet extends HookConsumerWidget {
), ),
), ),
subtitle: Text( 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} ",
), ),
), ),
], ],
@@ -11,6 +11,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
required this.onDownloadPressed, required this.onDownloadPressed,
required this.onSharePressed, required this.onSharePressed,
required this.onDeletePressed, required this.onDeletePressed,
required this.onAddToAlbumPressed,
required this.onToggleMotionVideo, required this.onToggleMotionVideo,
required this.isPlayingMotionVideo, required this.isPlayingMotionVideo,
}) : super(key: key); }) : super(key: key);
@@ -20,6 +21,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
final VoidCallback? onDownloadPressed; final VoidCallback? onDownloadPressed;
final VoidCallback onToggleMotionVideo; final VoidCallback onToggleMotionVideo;
final VoidCallback onDeletePressed; final VoidCallback onDeletePressed;
final VoidCallback onAddToAlbumPressed;
final Function onSharePressed; final Function onSharePressed;
final bool isPlayingMotionVideo; final bool isPlayingMotionVideo;
@@ -80,6 +82,18 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
color: Colors.grey[200], color: Colors.grey[200],
), ),
), ),
if (asset.isRemote)
IconButton(
iconSize: iconSize,
splashRadius: iconSize,
onPressed: () {
onAddToAlbumPressed();
},
icon: Icon(
Icons.add,
color: Colors.grey[200],
),
),
IconButton( IconButton(
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
@@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.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/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/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: TopControlAppBar( appBar: TopControlAppBar(
@@ -130,6 +148,7 @@ class GalleryViewerPage extends HookConsumerWidget {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value; isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}), }),
onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])), onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
), ),
body: SafeArea( body: SafeArea(
child: PageView.builder( child: PageView.builder(
@@ -8,7 +8,8 @@ class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache"); AssetCacheService() : super("asset_cache");
static Future<List<Map<String, dynamic>>> _computeSerialize( static Future<List<Map<String, dynamic>>> _computeSerialize(
List<Asset> assets) async { List<Asset> assets,
) async {
return assets.map((e) => e.toJson()).toList(); return assets.map((e) => e.toJson()).toList();
} }
@@ -42,8 +42,13 @@ class _AssetGroupsToRenderListComputeParameters {
final Map<String, List<Asset>> groups; final Map<String, List<Asset>> groups;
final int perRow; final int perRow;
_AssetGroupsToRenderListComputeParameters(this.monthFormat, this.dayFormat, _AssetGroupsToRenderListComputeParameters(
this.dayFormatYear, this.groups, this.perRow); this.monthFormat,
this.dayFormat,
this.dayFormatYear,
this.groups,
this.perRow,
);
} }
class RenderList { class RenderList {
@@ -52,7 +57,8 @@ class RenderList {
RenderList(this.elements); RenderList(this.elements);
static Future<RenderList> _processAssetGroupData( static Future<RenderList> _processAssetGroupData(
_AssetGroupsToRenderListComputeParameters data) async { _AssetGroupsToRenderListComputeParameters data,
) async {
final monthFormat = DateFormat(data.monthFormat); final monthFormat = DateFormat(data.monthFormat);
final dayFormatSameYear = DateFormat(data.dayFormat); final dayFormatSameYear = DateFormat(data.dayFormat);
final dayFormatOtherYear = DateFormat(data.dayFormatYear); final dayFormatOtherYear = DateFormat(data.dayFormatYear);
@@ -1,4 +1,3 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -1,12 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/modules/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ControlBottomAppBar extends ConsumerWidget { class ControlBottomAppBar extends ConsumerWidget {
@@ -16,11 +13,13 @@ class ControlBottomAppBar extends ConsumerWidget {
final void Function() onCreateNewAlbum; final void Function() onCreateNewAlbum;
final List<AlbumResponseDto> albums; final List<AlbumResponseDto> albums;
final List<AlbumResponseDto> sharedAlbums;
const ControlBottomAppBar({ const ControlBottomAppBar({
Key? key, Key? key,
required this.onShare, required this.onShare,
required this.onDelete, required this.onDelete,
required this.sharedAlbums,
required this.albums, required this.albums,
required this.onAddToAlbum, required this.onAddToAlbum,
required this.onCreateNewAlbum, 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( return DraggableScrollableSheet(
initialChildSize: 0.30, initialChildSize: 0.30,
minChildSize: 0.15, minChildSize: 0.15,
@@ -119,42 +64,53 @@ class ControlBottomAppBar extends ConsumerWidget {
BuildContext context, BuildContext context,
ScrollController scrollController, ScrollController scrollController,
) { ) {
return SingleChildScrollView( return Card(
controller: scrollController, elevation: 12.0,
child: Card( shape: const RoundedRectangleBorder(
elevation: 12.0, borderRadius: BorderRadius.only(
shape: const RoundedRectangleBorder( topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
margin: const EdgeInsets.all(0),
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(12), topLeft: Radius.circular(12),
topRight: Radius.circular(12), topRight: Radius.circular(12),
), ),
), ),
margin: const EdgeInsets.all(0), child: CustomScrollView(
child: Container( controller: scrollController,
decoration: const BoxDecoration( slivers: [
borderRadius: BorderRadius.only( SliverToBoxAdapter(
topLeft: Radius.circular(12), child: Column(
topRight: Radius.circular(12), children: <Widget>[
const SizedBox(height: 12),
const CustomDraggingHandle(),
const SizedBox(height: 12),
renderActionButtons(),
const Divider(
indent: 16,
endIndent: 16,
thickness: 1,
),
AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum),
],
),
), ),
), SliverPadding(
child: Column( padding: const EdgeInsets.symmetric(horizontal: 16),
children: <Widget>[ sliver: AddToAlbumSliverList(
const SizedBox(height: 12), albums: albums,
const CustomDraggingHandle(), sharedAlbums: sharedAlbums,
const SizedBox(height: 12), onAddToAlbum: onAddToAlbum,
renderActionButtons(),
const Divider(
indent: 16,
endIndent: 16,
thickness: 1,
), ),
AddToAlbumTitleRow( ),
onCreateNewAlbum: () => onCreateNewAlbum(), const SliverToBoxAdapter(
), child: SizedBox(height: 200),
renderAlbums(), )
const SizedBox(height: 200), ],
],
),
), ),
), ),
); );
@@ -185,9 +141,10 @@ class AddToAlbumTitleRow extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
).tr(), ).tr(),
TextButton( TextButton.icon(
onPressed: onCreateNewAlbum, onPressed: onCreateNewAlbum,
child: Text( icon: const Icon(Icons.add),
label: Text(
"control_bottom_app_bar_create_new_album", "control_bottom_app_bar_create_new_album",
style: TextStyle( style: TextStyle(
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/album/services/album.service.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.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 selection = useState(<Asset>{});
final albums = ref.watch(albumProvider); final albums = ref.watch(albumProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider); final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0); final tipOneOpacity = useState(0.0);
@@ -46,6 +48,7 @@ class HomePage extends HookConsumerWidget {
ref.read(websocketProvider.notifier).connect(); ref.read(websocketProvider.notifier).connect();
ref.read(assetProvider.notifier).getAllAsset(); ref.read(assetProvider.notifier).getAllAsset();
ref.read(albumProvider.notifier).getAllAlbums(); ref.read(albumProvider.notifier).getAllAlbums();
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
ref.watch(serverInfoProvider.notifier).getServerVersion(); ref.watch(serverInfoProvider.notifier).getServerVersion();
selectionEnabledHook.addListener(() { selectionEnabledHook.addListener(() {
@@ -147,6 +150,7 @@ class HomePage extends HookConsumerWidget {
if (result != null) { if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums(); ref.watch(albumProvider.notifier).getAllAlbums();
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id)); AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id));
@@ -220,6 +224,7 @@ class HomePage extends HookConsumerWidget {
onDelete: onDelete, onDelete: onDelete,
onAddToAlbum: onAddToAlbum, onAddToAlbum: onAddToAlbum,
albums: albums, albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum, onCreateNewAlbum: onCreateNewAlbum,
), ),
], ],
+1 -1
View File
@@ -235,7 +235,7 @@ class ServerEndpointInput extends StatelessWidget {
labelText: 'login_form_endpoint_url'.tr(), labelText: 'login_form_endpoint_url'.tr(),
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
hintText: 'login_form_endpoint_hint'.tr(), hintText: 'login_form_endpoint_hint'.tr(),
errorMaxLines: 4 errorMaxLines: 4,
), ),
validator: _validateInput, validator: _validateInput,
autovalidateMode: AutovalidateMode.always, autovalidateMode: AutovalidateMode.always,
@@ -24,6 +24,7 @@ class TilesPerRow extends HookConsumerWidget {
void sliderChangedEnd(double _) { void sliderChangedEnd(double _) {
ref.invalidate(assetProvider); ref.invalidate(assetProvider);
ref.watch(assetProvider.notifier).getAllAsset();
} }
useEffect( useEffect(
+25 -10
View File
@@ -60,7 +60,8 @@ class _$AppRouter extends RootStackRouter {
isZoomedFunction: args.isZoomedFunction, isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener, isZoomedListener: args.isZoomedListener,
loadPreview: args.loadPreview, loadPreview: args.loadPreview,
loadOriginal: args.loadOriginal)); loadOriginal: args.loadOriginal,
showExifSheet: args.showExifSheet));
}, },
VideoViewerRoute.name: (routeData) { VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>(); final args = routeData.argsAs<VideoViewerRouteArgs>();
@@ -87,7 +88,9 @@ class _$AppRouter extends RootStackRouter {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, routeData: routeData,
child: CreateAlbumPage( child: CreateAlbumPage(
key: args.key, isSharedAlbum: args.isSharedAlbum)); key: args.key,
isSharedAlbum: args.isSharedAlbum,
initialAssets: args.initialAssets));
}, },
AssetSelectionRoute.name: (routeData) { AssetSelectionRoute.name: (routeData) {
return CustomPage<AssetSelectionPageResult?>( return CustomPage<AssetSelectionPageResult?>(
@@ -307,7 +310,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
required void Function() isZoomedFunction, required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener, required ValueNotifier<bool> isZoomedListener,
required bool loadPreview, required bool loadPreview,
required bool loadOriginal}) required bool loadOriginal,
void Function()? showExifSheet})
: super(ImageViewerRoute.name, : super(ImageViewerRoute.name,
path: '/image-viewer-page', path: '/image-viewer-page',
args: ImageViewerRouteArgs( args: ImageViewerRouteArgs(
@@ -318,7 +322,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
isZoomedFunction: isZoomedFunction, isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
loadPreview: loadPreview, loadPreview: loadPreview,
loadOriginal: loadOriginal)); loadOriginal: loadOriginal,
showExifSheet: showExifSheet));
static const String name = 'ImageViewerRoute'; static const String name = 'ImageViewerRoute';
} }
@@ -332,7 +337,8 @@ class ImageViewerRouteArgs {
required this.isZoomedFunction, required this.isZoomedFunction,
required this.isZoomedListener, required this.isZoomedListener,
required this.loadPreview, required this.loadPreview,
required this.loadOriginal}); required this.loadOriginal,
this.showExifSheet});
final Key? key; final Key? key;
@@ -350,9 +356,11 @@ class ImageViewerRouteArgs {
final bool loadOriginal; final bool loadOriginal;
final void Function()? showExifSheet;
@override @override
String toString() { 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 /// generated route for
/// [CreateAlbumPage] /// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> { class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
CreateAlbumRoute({Key? key, required bool isSharedAlbum}) CreateAlbumRoute(
{Key? key, required bool isSharedAlbum, List<Asset>? initialAssets})
: super(CreateAlbumRoute.name, : super(CreateAlbumRoute.name,
path: '/create-album-page', path: '/create-album-page',
args: CreateAlbumRouteArgs(key: key, isSharedAlbum: isSharedAlbum)); args: CreateAlbumRouteArgs(
key: key,
isSharedAlbum: isSharedAlbum,
initialAssets: initialAssets));
static const String name = 'CreateAlbumRoute'; static const String name = 'CreateAlbumRoute';
} }
class CreateAlbumRouteArgs { class CreateAlbumRouteArgs {
const CreateAlbumRouteArgs({this.key, required this.isSharedAlbum}); const CreateAlbumRouteArgs(
{this.key, required this.isSharedAlbum, this.initialAssets});
final Key? key; final Key? key;
final bool isSharedAlbum; final bool isSharedAlbum;
final List<Asset>? initialAssets;
@override @override
String toString() { String toString() {
return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum}'; return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}';
} }
} }
@@ -30,8 +30,8 @@ class TabNavigationObserver extends AutoRouterObserver {
// Perform tasks on re-visit to SearchRoute // Perform tasks on re-visit to SearchRoute
if (route.name == 'SearchRoute') { if (route.name == 'SearchRoute') {
// Refresh Location State // Refresh Location State
ref.refresh(getCuratedLocationProvider); ref.invalidate(getCuratedLocationProvider);
ref.refresh(getCuratedObjectProvider); ref.invalidate(getCuratedObjectProvider);
} }
if (route.name == 'SharingRoute') { if (route.name == 'SharingRoute') {
@@ -83,6 +83,7 @@ class ImmichLogger {
} }
// Share file // Share file
// ignore: deprecated_member_use
await Share.shareFiles( await Share.shareFiles(
[filePath], [filePath],
subject: "Immich logs $dateTime", subject: "Immich logs $dateTime",
@@ -40,6 +40,7 @@ class ShareService {
} }
}); });
// ignore: deprecated_member_use
Share.shareFiles( Share.shareFiles(
await Future.wait(downloadedFilePaths), await Future.wait(downloadedFilePaths),
sharePositionOrigin: Rect.zero, sharePositionOrigin: Rect.zero,
+4 -2
View File
@@ -10,8 +10,10 @@ String getThumbnailUrl(
return _getThumbnailUrl(asset.id, type: type); return _getThumbnailUrl(asset.id, type: type);
} }
String getThumbnailCacheKey(final AssetResponseDto asset, String getThumbnailCacheKey(
{ThumbnailFormat type = ThumbnailFormat.WEBP}) { final AssetResponseDto asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
return _getThumbnailCacheKey(asset.id, type); return _getThumbnailCacheKey(asset.id, type);
} }
+6 -1
View File
@@ -31,6 +31,11 @@ ThemeData immichDarkTheme = ThemeData(
snackBarTheme: const SnackBarThemeData( snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'), contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
), ),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: immichDarkThemePrimaryColor,
),
),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
fontFamily: 'WorkSans', fontFamily: 'WorkSans',
@@ -59,7 +64,7 @@ ThemeData immichDarkTheme = ThemeData(
headline2: const TextStyle( headline2: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Color.fromARGB(255, 148, 151, 155), color: Color.fromARGB(255, 255, 255, 255),
), ),
headline3: TextStyle( headline3: TextStyle(
fontSize: 12, fontSize: 12,
+3 -1
View File
@@ -31,7 +31,9 @@ extension WithETag on AssetApi {
final responseBody = await _decodeBodyBytes(response); final responseBody = await _decodeBodyBytes(response);
final etag = response.headers[HttpHeaders.etagHeader]; final etag = response.headers[HttpHeaders.etagHeader];
final data = (await apiClient.deserializeAsync( final data = (await apiClient.deserializeAsync(
responseBody, 'List<AssetResponseDto>') as List) responseBody,
'List<AssetResponseDto>',
) as List)
.cast<AssetResponseDto>() .cast<AssetResponseDto>()
.toList(); .toList();
return Pair(data, etag); return Pair(data, etag);
-2
View File
@@ -102,8 +102,6 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *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 | *DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
-100
View File
@@ -9,109 +9,9 @@ All URIs are relative to */api*
Method | HTTP request | Description 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 | [**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** # **upsertDeviceInfo**
> DeviceInfoResponseDto upsertDeviceInfo(upsertDeviceInfoDto) > DeviceInfoResponseDto upsertDeviceInfo(upsertDeviceInfoDto)
+1 -1
View File
@@ -22,7 +22,7 @@ Name | Type | Description | Notes
**fNumber** | **num** | | [optional] **fNumber** | **num** | | [optional]
**focalLength** | **num** | | [optional] **focalLength** | **num** | | [optional]
**iso** | **num** | | [optional] **iso** | **num** | | [optional]
**exposureTime** | **num** | | [optional] **exposureTime** | **String** | | [optional]
**latitude** | **num** | | [optional] **latitude** | **num** | | [optional]
**longitude** | **num** | | [optional] **longitude** | **num** | | [optional]
**city** | **String** | | [optional] **city** | **String** | | [optional]
+1
View File
@@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**command** | [**JobCommand**](JobCommand.md) | | **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) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
-104
View File
@@ -16,110 +16,6 @@ class DeviceInfoApi {
final ApiClient apiClient; 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]. /// Note: This method returns the HTTP [Response].
+2 -4
View File
@@ -63,7 +63,7 @@ class ExifResponseDto {
num? iso; num? iso;
num? exposureTime; String? exposureTime;
num? latitude; num? latitude;
@@ -273,9 +273,7 @@ class ExifResponseDto {
iso: json[r'iso'] == null iso: json[r'iso'] == null
? null ? null
: num.parse(json[r'iso'].toString()), : num.parse(json[r'iso'].toString()),
exposureTime: json[r'exposureTime'] == null exposureTime: mapValueOfType<String>(json, r'exposureTime'),
? null
: num.parse(json[r'exposureTime'].toString()),
latitude: json[r'latitude'] == null latitude: json[r'latitude'] == null
? null ? null
: num.parse(json[r'latitude'].toString()), : num.parse(json[r'latitude'].toString()),
+11 -3
View File
@@ -14,25 +14,31 @@ class JobCommandDto {
/// Returns a new [JobCommandDto] instance. /// Returns a new [JobCommandDto] instance.
JobCommandDto({ JobCommandDto({
required this.command, required this.command,
required this.includeAllAssets,
}); });
JobCommand command; JobCommand command;
bool includeAllAssets;
@override @override
bool operator ==(Object other) => identical(this, other) || other is JobCommandDto && bool operator ==(Object other) => identical(this, other) || other is JobCommandDto &&
other.command == command; other.command == command &&
other.includeAllAssets == includeAllAssets;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(command.hashCode); (command.hashCode) +
(includeAllAssets.hashCode);
@override @override
String toString() => 'JobCommandDto[command=$command]'; String toString() => 'JobCommandDto[command=$command, includeAllAssets=$includeAllAssets]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'command'] = this.command; json[r'command'] = this.command;
json[r'includeAllAssets'] = this.includeAllAssets;
return json; return json;
} }
@@ -56,6 +62,7 @@ class JobCommandDto {
return JobCommandDto( return JobCommandDto(
command: JobCommand.fromJson(json[r'command'])!, command: JobCommand.fromJson(json[r'command'])!,
includeAllAssets: mapValueOfType<bool>(json, r'includeAllAssets')!,
); );
} }
return null; return null;
@@ -106,6 +113,7 @@ class JobCommandDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'command', 'command',
'includeAllAssets',
}; };
} }
-14
View File
@@ -17,20 +17,6 @@ void main() {
// final instance = DeviceInfoApi(); // final instance = DeviceInfoApi();
group('tests for 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 //Future<DeviceInfoResponseDto> upsertDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async
+1 -1
View File
@@ -86,7 +86,7 @@ void main() {
// TODO // TODO
}); });
// num exposureTime // String exposureTime
test('to test the property `exposureTime`', () async { test('to test the property `exposureTime`', () async {
// TODO // TODO
}); });
+5
View File
@@ -21,6 +21,11 @@ void main() {
// TODO // TODO
}); });
// bool includeAllAssets
test('to test the property `includeAllAssets`', () async {
// TODO
});
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.42.0+65 version: 1.43.0+66
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@@ -29,6 +29,8 @@ export interface IAssetRepository {
livePhotoAssetEntity?: AssetEntity, livePhotoAssetEntity?: AssetEntity,
): Promise<AssetEntity>; ): Promise<AssetEntity>;
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>; update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
getAll(): Promise<AssetEntity[]>;
getAllVideos(): Promise<AssetEntity[]>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>; getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>; getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>; getById(assetId: string): Promise<AssetEntity>;
@@ -61,6 +63,22 @@ export class AssetRepository implements IAssetRepository {
@Inject(ITagRepository) private _tagRepository: ITagRepository, @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[]> { async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
return await this.assetRepository return await this.assetRepository
.createQueryBuilder('asset') .createQueryBuilder('asset')
@@ -19,7 +19,7 @@ import {
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express'; 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 { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto'; import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
@@ -80,7 +80,7 @@ export class AssetController {
}) })
async uploadFile( async uploadFile(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] }, @UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
@Body(ValidationPipe) createAssetDto: CreateAssetDto, @Body(ValidationPipe) createAssetDto: CreateAssetDto,
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> { ): Promise<AssetFileUploadResponseDto> {
@@ -123,6 +123,8 @@ describe('AssetService', () => {
assetRepositoryMock = { assetRepositoryMock = {
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
getAll: jest.fn(),
getAllVideos: jest.fn(),
getAllByUserId: jest.fn(), getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(), getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(), getAssetCountByTimeBucket: jest.fn(),
@@ -55,6 +55,7 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain'; import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto'; import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
import { AssetSearchDto } from './dto/asset-search.dto'; import { AssetSearchDto } from './dto/asset-search.dto';
import { ImmichFile } from '../../config/asset-upload.config';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -82,16 +83,16 @@ export class AssetService {
authUser: AuthUserDto, authUser: AuthUserDto,
createAssetDto: CreateAssetDto, createAssetDto: CreateAssetDto,
res: Res, res: Res,
originalAssetData: Express.Multer.File, originalAssetData: ImmichFile,
livePhotoAssetData?: Express.Multer.File, livePhotoAssetData?: ImmichFile,
) { ) {
const checksum = await this.calculateChecksum(originalAssetData.path); const checksum = originalAssetData.checksum;
const isLivePhoto = livePhotoAssetData !== undefined; const isLivePhoto = livePhotoAssetData !== undefined;
let livePhotoAssetEntity: AssetEntity | undefined; let livePhotoAssetEntity: AssetEntity | undefined;
try { try {
if (isLivePhoto) { if (isLivePhoto) {
const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path); const livePhotoChecksum = livePhotoAssetData.checksum;
livePhotoAssetEntity = await this.createUserAsset( livePhotoAssetEntity = await this.createUserAsset(
authUser, authUser,
createAssetDto, createAssetDto,
@@ -19,8 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
async handleConnection(client: Socket) { async handleConnection(client: Socket) {
try { try {
this.logger.log(`New websocket connection: ${client.id}`); this.logger.log(`New websocket connection: ${client.id}`);
const user = await this.authService.validate(client.request.headers);
const user = await this.authService.validateSocket(client);
if (user) { if (user) {
client.join(user.id); client.join(user.id);
} else { } else {
@@ -28,7 +27,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
client.disconnect(); client.disconnect();
} }
} catch (e) { } catch (e) {
// Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection'); client.emit('error', 'unauthorized');
client.disconnect();
} }
} }
} }
@@ -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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
@@ -13,24 +13,6 @@ import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/dev
export class DeviceInfoController { export class DeviceInfoController {
constructor(private readonly deviceInfoService: DeviceInfoService) {} 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() @Put()
public async upsertDeviceInfo( public async upsertDeviceInfo(
@GetAuthUser() user: AuthUserDto, @GetAuthUser() user: AuthUserDto,
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty } from 'class-validator'; import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator';
export class JobCommandDto { export class JobCommandDto {
@IsNotEmpty() @IsNotEmpty()
@@ -9,4 +9,8 @@ export class JobCommandDto {
enumName: 'JobCommand', enumName: 'JobCommand',
}) })
command!: string; command!: string;
@IsOptional()
@IsBoolean()
includeAllAssets!: boolean;
} }
@@ -21,12 +21,12 @@ export class JobController {
@Put('/:jobId') @Put('/:jobId')
async sendJobCommand( async sendJobCommand(
@Param(ValidationPipe) params: GetJobDto, @Param(ValidationPipe) params: GetJobDto,
@Body(ValidationPipe) body: JobCommandDto, @Body(ValidationPipe) dto: JobCommandDto,
): Promise<number> { ): Promise<number> {
if (body.command === 'start') { if (dto.command === 'start') {
return await this.jobService.start(params.jobId); 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 await this.jobService.stop(params.jobId);
} }
return 0; return 0;
@@ -5,7 +5,7 @@ import { IAssetRepository } from '../asset/asset-repository';
import { AssetType } from '@app/infra'; import { AssetType } from '@app/infra';
import { JobId } from './dto/get-job.dto'; import { JobId } from './dto/get-job.dto';
import { MACHINE_LEARNING_ENABLED } from '@app/common'; import { MACHINE_LEARNING_ENABLED } from '@app/common';
import { getFileNameWithoutExtension } from '../../utils/file-name.util';
const jobIds = Object.values(JobId) as JobId[]; const jobIds = Object.values(JobId) as JobId[];
@Injectable() @Injectable()
@@ -19,8 +19,8 @@ export class JobService {
} }
} }
start(jobId: JobId): Promise<number> { start(jobId: JobId, includeAllAssets: boolean): Promise<number> {
return this.run(this.asQueueName(jobId)); return this.run(this.asQueueName(jobId), includeAllAssets);
} }
async stop(jobId: JobId): Promise<number> { async stop(jobId: JobId): Promise<number> {
@@ -36,7 +36,7 @@ export class JobService {
return response; 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); const isActive = await this.jobRepository.isActive(name);
if (isActive) { if (isActive) {
throw new BadRequestException(`Job is already running`); throw new BadRequestException(`Job is already running`);
@@ -44,7 +44,9 @@ export class JobService {
switch (name) { switch (name) {
case QueueName.VIDEO_CONVERSION: { 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) { for (const asset of assets) {
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); 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.'); 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) { for (const asset of assets) {
await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } }); await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } });
await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } }); await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } });
@@ -70,19 +75,37 @@ export class JobService {
} }
case QueueName.METADATA_EXTRACTION: { 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) { for (const asset of assets) {
if (asset.type === AssetType.VIDEO) { 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 { } 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; return assets.length;
} }
case QueueName.THUMBNAIL_GENERATION: { 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) { for (const asset of assets) {
await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
} }
+4 -4
View File
@@ -1,7 +1,6 @@
import { immichAppConfig } from '@app/common/config'; import { immichAppConfig } from '@app/common/config';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module'; 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 { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module'; import { ServerInfoModule } from './api-v1/server-info/server-info.module';
@@ -23,6 +22,9 @@ import {
SystemConfigController, SystemConfigController,
UserController, UserController,
} from './controllers'; } 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({ @Module({
imports: [ imports: [
@@ -34,8 +36,6 @@ import {
AssetModule, AssetModule,
ImmichJwtModule,
DeviceInfoModule, DeviceInfoModule,
ServerInfoModule, ServerInfoModule,
@@ -64,7 +64,7 @@ import {
SystemConfigController, SystemConfigController,
UserController, UserController,
], ],
providers: [], providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
// TODO: check if consumer is needed or remove // TODO: check if consumer is needed or remove
@@ -1,10 +1,10 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants'; import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; 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 { Request } from 'express';
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer'; import { diskStorage, StorageEngine } from 'multer';
import { extname, join } from 'path'; import { extname, join } from 'path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { AuthUserDto } from '../decorators/auth-user.decorator'; import { AuthUserDto } from '../decorators/auth-user.decorator';
@@ -12,14 +12,40 @@ import { patchFormData } from '../utils/path-form-data.util';
const logger = new Logger('AssetUploadConfig'); const logger = new Logger('AssetUploadConfig');
export interface ImmichFile extends Express.Multer.File {
/** sha1 hash of file */
checksum: Buffer;
}
export const assetUploadOption: MulterOptions = { export const assetUploadOption: MulterOptions = {
fileFilter, fileFilter,
storage: diskStorage({ storage: customStorage(),
destination,
filename,
}),
}; };
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 }; export const multerUtils = { fileFilter, filename, destination };
function fileFilter(req: Request, file: any, cb: any) { function fileFilter(req: Request, file: any, cb: any) {
@@ -1,7 +1,7 @@
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware'; import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-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 { interface AuthenticatedOptions {
admin?: boolean; admin?: boolean;
+3
View File
@@ -4,5 +4,8 @@ declare global {
namespace Express { namespace Express {
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
interface User extends AuthUserDto {} interface User extends AuthUserDto {}
export interface Request {
user: AuthUserDto;
}
} }
} }
-3
View File
@@ -40,9 +40,6 @@ async function bootstrap() {
.addBearerAuth({ .addBearerAuth({
type: 'http', type: 'http',
scheme: 'Bearer', scheme: 'Bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header', in: 'header',
}) })
.addServer('/api') .addServer('/api')
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy'; 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'; import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
@Injectable() @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]) {}
@@ -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;
}
}
@@ -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 {}
@@ -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);
}
}
@@ -1,9 +1,8 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm'; import { IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType, ExifEntity, UserEntity } from '@app/infra'; import { UserEntity } from '@app/infra';
import { ConfigService } from '@nestjs/config';
import { userUtils } from '@app/common'; import { userUtils } from '@app/common';
import { IJobRepository, JobName } from '@app/domain'; import { IJobRepository, JobName } from '@app/domain';
@@ -13,93 +12,8 @@ export class ScheduleTasksService {
@InjectRepository(UserEntity) @InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>, private userRepository: Repository<UserEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
@Inject(IJobRepository) private jobRepository: IJobRepository, @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) @Cron(CronExpression.EVERY_DAY_AT_11PM)
async deleteUserAndRelatedAssets() { async deleteUserAndRelatedAssets() {
const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
@@ -0,0 +1,5 @@
import { basename, extname } from 'node:path';
export function getFileNameWithoutExtension(path: string): string {
return basename(path, extname(path));
}
+2 -2
View File
@@ -5,10 +5,10 @@ import { clearDb, getAuthUser, authCustom } from './test-utils';
import { InfraModule } from '@app/infra'; import { InfraModule } from '@app/infra';
import { AlbumModule } from '../src/api-v1/album/album.module'; import { AlbumModule } from '../src/api-v1/album/album.module';
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto'; 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 { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { AuthService, DomainModule, UserService } from '@app/domain'; import { AuthService, DomainModule, UserService } from '@app/domain';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppModule } from '../src/app.module';
function _createAlbum(app: INestApplication, data: CreateAlbumDto) { function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
return request(app.getHttpServer()).post('/album').send(data); return request(app.getHttpServer()).post('/album').send(data);
@@ -21,7 +21,7 @@ describe('Album', () => {
describe('without auth', () => { describe('without auth', () => {
beforeAll(async () => { beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule, ImmichJwtModule], imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
}).compile(); }).compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
+1
View File
@@ -1,5 +1,6 @@
{ {
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": ["js", "json", "ts"],
"modulePaths": ["<rootDir>", "<rootDir>../../../"],
"rootDir": ".", "rootDir": ".",
"testEnvironment": "node", "testEnvironment": "node",
"testRegex": ".e2e-spec.ts$", "testRegex": ".e2e-spec.ts$",

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