Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf
16
.github/workflows/docker.yml
vendored
@@ -78,6 +78,16 @@ jobs:
|
||||
type=ref,event=tag
|
||||
type=raw,value=release,enable=${{ github.event_name == 'release' }}
|
||||
|
||||
- name: Determine build cache output
|
||||
id: cache-target
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
# Essentially just ignore the cache output (PR can't write to registry cache)
|
||||
echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v3.3.0
|
||||
with:
|
||||
@@ -85,6 +95,6 @@ jobs:
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
# Skip pushing when PR from a fork
|
||||
push: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}}
|
||||
cache-to: ${{ steps.cache-target.outputs.cache-to }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
|
||||
2
.github/workflows/prepare-release.yml
vendored
@@ -24,6 +24,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
token: ${{ secrets.ORG_RELEASE_TOKEN }}
|
||||
|
||||
- name: Bump version
|
||||
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
|
||||
|
||||
31
.github/workflows/static_analysis.yml
vendored
Normal file
@@ -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
|
||||
|
||||
@@ -10,9 +10,6 @@ REDIS_HOSTNAME=immich-redis-test
|
||||
# Upload File Config
|
||||
UPLOAD_LOCATION=./upload
|
||||
|
||||
# JWT SECRET
|
||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||
|
||||
# MAPBOX
|
||||
## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
||||
ENABLE_MAPBOX=false
|
||||
|
||||
@@ -30,16 +30,6 @@ REDIS_HOSTNAME=immich_redis
|
||||
|
||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||
|
||||
###################################################################################
|
||||
# JWT SECRET
|
||||
#
|
||||
# This JWT_SECRET is used to sign the authentication keys for user login
|
||||
# You should set it to a long randomly generated value
|
||||
# You can use this command to generate one: openssl rand -base64 128
|
||||
###################################################################################
|
||||
|
||||
JWT_SECRET=
|
||||
|
||||
###################################################################################
|
||||
# Reverse Geocoding
|
||||
#
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# FAQ
|
||||
@@ -20,9 +20,9 @@ Immich doesn't have the mechanism to sync an existing directory with the server.
|
||||
|
||||
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
|
||||
|
||||
### What happens to existing files after I choose a new [Storage Template](/docs/features/storage-template.mdx)?
|
||||
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
|
||||
|
||||
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/features/jobs.md) page.
|
||||
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
|
||||
|
||||
### Why is object detection not very good?
|
||||
|
||||
@@ -42,7 +42,7 @@ The non-root user/group needs read/write access to the volume mounts, including
|
||||
|
||||
### How can I reset the admin password?
|
||||
|
||||
The admin password can be reset by running the [reset-admin-password](/docs/features/server-commands.md) command on the immich-server.
|
||||
The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server.
|
||||
|
||||
### How can I **purge** data from Immich?
|
||||
|
||||
|
||||
5
docs/docs/administration/_category_.json
Normal 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
|
||||
|
||||
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.
|
||||
|
||||

|
||||
@@ -14,19 +14,19 @@ To toggle the password login setting via the web, navigate to the "Administratio
|
||||
|
||||
### Server Command
|
||||
|
||||
There are two [Server Commands](/docs/features/server-commands.md) for password login:
|
||||
There are two [Server Commands](/docs/administration/server-commands.md) for password login:
|
||||
|
||||
1. `enable-password-login`
|
||||
2. `disable-password-login`
|
||||
|
||||
See [Server Commands](/docs/features/server-commands.md) for more details about how to run them.
|
||||
See [Server Commands](/docs/administration/server-commands.md) for more details about how to run them.
|
||||
|
||||
## Password Reset
|
||||
|
||||
### Admin
|
||||
|
||||
To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/features/server-commands.md).
|
||||
To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/administration/server-commands.md).
|
||||
|
||||
### User
|
||||
|
||||
Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/features/user-management.mdx#password-reset) for more information about how to do this.
|
||||
Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/administration/user-management.mdx#password-reset) for more information about how to do this.
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"label": "Developer",
|
||||
"position": 4
|
||||
"position": 5
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ All the services are packaged to run as with single Docker Compose command.
|
||||
|
||||
1. Clone the project repo.
|
||||
2. Run `cp docker/example.env docker/.env`.
|
||||
3. Edit `docker/.env` to provide values for the required variables `UPLOAD_LOCATION` and `JWT_SECRET`.
|
||||
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
|
||||
4. From the root directory, run:
|
||||
|
||||
```bash title="Start development server"
|
||||
|
||||
@@ -15,9 +15,9 @@ Users can change their own passwords.
|
||||

|
||||
|
||||
:::tip Reset Password
|
||||
The admin can reset a password through the [User Management](/docs/features/user-management.mdx) screen.
|
||||
The admin can reset a password through the [User Management](/docs/administration/user-management.mdx) screen.
|
||||
:::
|
||||
|
||||
:::tip Reset Admin Password
|
||||
The admin password can be reset using a [Server Command](/docs/features/server-commands.md)
|
||||
The admin password can be reset using a [Server Command](/docs/administration/server-commands.md)
|
||||
:::
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"label": "Guides",
|
||||
"position": 5
|
||||
"position": 6
|
||||
}
|
||||
|
||||
@@ -63,15 +63,6 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba
|
||||
|
||||
LOG_LEVEL=simple
|
||||
|
||||
###################################################################################
|
||||
# JWT SECRET
|
||||
###################################################################################
|
||||
|
||||
# This JWT_SECRET is used to sign the authentication keys for user login
|
||||
# You should set it to a long randomly generated value
|
||||
# You can use this command to generate one: openssl rand -base64 128
|
||||
JWT_SECRET=
|
||||
|
||||
###################################################################################
|
||||
# Reverse Geocoding
|
||||
####################################################################################
|
||||
@@ -102,11 +93,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
|
||||
|
||||
- Populate custom database information if necessary.
|
||||
- Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
|
||||
- Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key:
|
||||
|
||||
```bash title="Command to generate secure JWT_SECRET key"
|
||||
openssl rand -base64 128
|
||||
```
|
||||
|
||||
### Step 3 - Start the containers
|
||||
|
||||
|
||||
@@ -40,11 +40,6 @@ Install Immich using Portainer's Stack feature.
|
||||
|
||||
* Populate custom database information if necessary.
|
||||
* Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets.
|
||||
* Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key:
|
||||
|
||||
```bash title="Generate secure JWT_SECRET key"
|
||||
openssl rand -base64 128
|
||||
```
|
||||
|
||||
11. Click on "**Deploy the stack**".
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
|
||||
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
|
||||
7. Past the entire contents of the [Immich example.env](https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env) file into the Unraid editor, then **before saving** edit the following:
|
||||
|
||||
- `JWT_SECRET`: Generate a unique secret and paste the value here > Can be generated by either typing `openssl rand -base64 128` in your terminal or copying from [uuidgenerator](https://www.uuidgenerator.net/version1)
|
||||
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
|
||||
|
||||
<img
|
||||
|
||||
@@ -15,7 +15,7 @@ function HomepageHeader() {
|
||||
<p>ON MOBILE DEVICE</p>
|
||||
</div>
|
||||
|
||||
<div className="flex place-items-center place-content-center mt-9 mb-16 gap-4 ">
|
||||
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 mb-16 gap-4 ">
|
||||
<Link
|
||||
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold"
|
||||
to="docs/overview/introduction"
|
||||
|
||||
@@ -13,9 +13,15 @@
|
||||
{ "source": "/docs/overview/technology-stack", "destination": "/docs/developer/architecture" },
|
||||
{ "source": "/docs/usage/automatic-backup", "destination": "/docs/features/automatic-backup" },
|
||||
{ "source": "/docs/usage/bulk-upload", "destination": "/docs/features/bulk-upload" },
|
||||
{ "source": "/docs/usage/oauth", "destination": "/docs/features/oauth" },
|
||||
{ "source": "/docs/usage/oauth", "destination": "/docs/administration/oauth" },
|
||||
{ "source": "/docs/usage/post-installation", "destination": "/docs/install/post-install" },
|
||||
{ "source": "/docs/usage/update", "destination": "/docs/install/docker-compose#step-4---upgrading" },
|
||||
{ "source": "/docs/usage/server-commands", "destination": "/docs/features/server-commands" }
|
||||
{ "source": "/docs/usage/server-commands", "destination": "/docs/administration/server-commands" },
|
||||
{ "source": "/docs/features/jobs", "destination": "/docs/administration/jobs" },
|
||||
{ "source": "/docs/features/oauth", "destination": "/docs/administration/oauth" },
|
||||
{ "source": "/docs/features/password-login", "destination": "/docs/administration/password-login" },
|
||||
{ "source": "/docs/features/server-commands", "destination": "/docs/administration/server-commands" },
|
||||
{ "source": "/docs/features/storage-template", "destination": "/docs/administration/storage-template" },
|
||||
{ "source": "/docs/features/user-management", "destination": "/docs/administration/user-management" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,12 +45,6 @@ populate_upload_location() {
|
||||
replace_env_value "UPLOAD_LOCATION" $upload_location
|
||||
}
|
||||
|
||||
generate_jwt_secret() {
|
||||
echo "Generating JWT_SECRET value..."
|
||||
jwt_secret=$(openssl rand -base64 128)
|
||||
replace_env_value "JWT_SECRET" $jwt_secret
|
||||
}
|
||||
|
||||
start_docker_compose() {
|
||||
echo "Starting Immich's docker containers"
|
||||
|
||||
@@ -92,5 +86,4 @@ create_immich_directory
|
||||
download_docker_compose_file
|
||||
download_dot_env_file
|
||||
populate_upload_location
|
||||
generate_jwt_secret
|
||||
start_docker_compose
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 65,
|
||||
"android.injected.version.name" => "1.42.0",
|
||||
"android.injected.version.code" => 66,
|
||||
"android.injected.version.name" => "1.43.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../test_utils/general_helper.dart';
|
||||
import '../test_utils/login_helper.dart';
|
||||
|
||||
void main() async {
|
||||
await ImmichTestHelper.initialize();
|
||||
@@ -13,7 +12,7 @@ void main() async {
|
||||
await helper.loginHelper.acknowledgeNewServerVersion();
|
||||
|
||||
await helper.loginHelper.enterCredentials(
|
||||
email: " demo@immich.app"
|
||||
email: " demo@immich.app",
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
@@ -21,7 +20,7 @@ void main() async {
|
||||
expect(find.text("login_form_err_leading_whitespace".tr()), findsOneWidget);
|
||||
|
||||
await helper.loginHelper.enterCredentials(
|
||||
email: "demo@immich.app "
|
||||
email: "demo@immich.app ",
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
@@ -34,7 +33,7 @@ void main() async {
|
||||
await helper.loginHelper.acknowledgeNewServerVersion();
|
||||
|
||||
await helper.loginHelper.enterCredentials(
|
||||
email: "demo.immich.app"
|
||||
email: "demo.immich.app",
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../test_utils/general_helper.dart';
|
||||
@@ -12,8 +10,9 @@ void main() async {
|
||||
immichWidgetTest("Test correct credentials", (tester, helper) async {
|
||||
await helper.loginHelper.waitForLoginScreen();
|
||||
await helper.loginHelper.acknowledgeNewServerVersion();
|
||||
await helper.loginHelper
|
||||
.enterCredentialsOf(LoginCredentials.testInstance);
|
||||
await helper.loginHelper.enterCredentialsOf(
|
||||
LoginCredentials.testInstance,
|
||||
);
|
||||
await helper.loginHelper.pressLoginButton();
|
||||
await helper.loginHelper.assertLoginSuccess();
|
||||
});
|
||||
@@ -22,16 +21,19 @@ void main() async {
|
||||
await helper.loginHelper.waitForLoginScreen();
|
||||
await helper.loginHelper.acknowledgeNewServerVersion();
|
||||
await helper.loginHelper.enterCredentialsOf(
|
||||
LoginCredentials.testInstanceButWithWrongPassword);
|
||||
LoginCredentials.testInstanceButWithWrongPassword,
|
||||
);
|
||||
await helper.loginHelper.pressLoginButton();
|
||||
await helper.loginHelper.assertLoginFailed();
|
||||
});
|
||||
|
||||
immichWidgetTest("Test login with wrong server URL", (tester, helper) async {
|
||||
immichWidgetTest("Test login with wrong server URL",
|
||||
(tester, helper) async {
|
||||
await helper.loginHelper.waitForLoginScreen();
|
||||
await helper.loginHelper.acknowledgeNewServerVersion();
|
||||
await helper.loginHelper.enterCredentialsOf(
|
||||
LoginCredentials.wrongInstanceUrl);
|
||||
LoginCredentials.wrongInstanceUrl,
|
||||
);
|
||||
await helper.loginHelper.pressLoginButton();
|
||||
await helper.loginHelper.assertLoginFailed();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/main.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:immich_mobile/main.dart' as app;
|
||||
|
||||
import 'login_helper.dart';
|
||||
|
||||
class ImmichTestHelper {
|
||||
|
||||
final WidgetTester tester;
|
||||
|
||||
ImmichTestHelper(this.tester);
|
||||
@@ -43,15 +40,19 @@ class ImmichTestHelper {
|
||||
await tester.pumpAndSettle();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@isTest
|
||||
void immichWidgetTest(String description, Future<void> Function(WidgetTester, ImmichTestHelper) test) {
|
||||
|
||||
testWidgets(description, (widgetTester) async {
|
||||
await ImmichTestHelper.loadApp(widgetTester);
|
||||
await test(widgetTester, ImmichTestHelper(widgetTester));
|
||||
}, semanticsEnabled: false);
|
||||
|
||||
}
|
||||
void immichWidgetTest(
|
||||
String description,
|
||||
Future<void> Function(WidgetTester, ImmichTestHelper) test,
|
||||
) {
|
||||
testWidgets(
|
||||
description,
|
||||
(widgetTester) async {
|
||||
await ImmichTestHelper.loadApp(widgetTester);
|
||||
await test(widgetTester, ImmichTestHelper(widgetTester));
|
||||
},
|
||||
semanticsEnabled: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
|
||||
class ImmichTestLoginHelper {
|
||||
final WidgetTester tester;
|
||||
|
||||
129
mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
|
||||
/// The asset to add to an album
|
||||
final List<Asset> assets;
|
||||
|
||||
const AddToAlbumBottomSheet({
|
||||
Key? key,
|
||||
required this.assets,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// Fetch album updates, e.g., cover image
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void addToAlbum(AlbumResponseDto album) async {
|
||||
final result = await albumService.addAdditionalAssetToAlbum(
|
||||
assets,
|
||||
album.id,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
if (result.alreadyInAlbum.isNotEmpty) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Already in ${album.albumName}',
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Added to ${album.albumName}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
|
||||
return Card(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(15),
|
||||
topRight: Radius.circular(15),
|
||||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomDraggingHandle(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Add to album',
|
||||
style: Theme.of(context).textTheme.headline2,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create new album'),
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets(assets);
|
||||
AutoRouter.of(context).push(
|
||||
CreateAlbumRoute(
|
||||
isSharedAlbum: false,
|
||||
initialAssets: assets,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: AddToAlbumSliverList(
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onAddToAlbum: addToAlbum,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
mobile/lib/modules/album/ui/add_to_album_list.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AddToAlbumList extends HookConsumerWidget {
|
||||
|
||||
/// The asset to add to an album
|
||||
final List<Asset> assets;
|
||||
|
||||
const AddToAlbumList({
|
||||
Key? key,
|
||||
required this.assets,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// Fetch album updates, e.g., cover image
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void addToAlbum(AlbumResponseDto album) async {
|
||||
final result = await albumService.addAdditionalAssetToAlbum(
|
||||
assets,
|
||||
album.id,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
if (result.alreadyInAlbum.isNotEmpty) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Already in ${album.albumName}',
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Added to ${album.albumName}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
return Card(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(15),
|
||||
topRight: Radius.circular(15),
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomDraggingHandle(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Add to album',
|
||||
style: Theme.of(context).textTheme.headline2,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New album'),
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets(assets);
|
||||
AutoRouter.of(context).push(
|
||||
CreateAlbumRoute(
|
||||
isSharedAlbum: false,
|
||||
initialAssets: assets,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (sharedAlbums.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: const Text('Shared'),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
leading: const Icon(Icons.group),
|
||||
children: sharedAlbums.map((album) =>
|
||||
AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => addToAlbum(album),
|
||||
),
|
||||
).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
... albums.map((album) =>
|
||||
AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => addToAlbum(album),
|
||||
),
|
||||
).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
mobile/lib/modules/album/ui/add_to_album_sliverlist.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AddToAlbumSliverList extends HookConsumerWidget {
|
||||
|
||||
/// The asset to add to an album
|
||||
final List<AlbumResponseDto> albums;
|
||||
final List<AlbumResponseDto> sharedAlbums;
|
||||
final void Function(AlbumResponseDto) onAddToAlbum;
|
||||
|
||||
const AddToAlbumSliverList({
|
||||
Key? key,
|
||||
required this.onAddToAlbum,
|
||||
required this.albums,
|
||||
required this.sharedAlbums,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: albums.length + (sharedAlbums.isEmpty ? 0 : 1),
|
||||
(context, index) {
|
||||
// Build shared expander
|
||||
if (index == 0 && sharedAlbums.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ExpansionTile(
|
||||
title: const Text('Shared'),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
leading: const Icon(Icons.group),
|
||||
children: sharedAlbums.map((album) =>
|
||||
AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => onAddToAlbum(album),
|
||||
),
|
||||
).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build albums list
|
||||
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
|
||||
final album = albums[offset];
|
||||
return AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => onAddToAlbum(album),
|
||||
);
|
||||
}
|
||||
),
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
115
mobile/lib/modules/album/ui/album_thumbnail_listtile.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AlbumThumbnailListTile extends StatelessWidget {
|
||||
const AlbumThumbnailListTile({
|
||||
Key? key,
|
||||
required this.album,
|
||||
this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
final AlbumResponseDto album;
|
||||
final void Function()? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var cardSize = 68.0;
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
buildEmptyThumbnail() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
|
||||
),
|
||||
child: SizedBox(
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
child: const Center(
|
||||
child: Icon(Icons.no_photography),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() {
|
||||
return CachedNetworkImage(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageUrl: getAlbumThumbnailUrl(
|
||||
album,
|
||||
type: ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap ?? () {
|
||||
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: album.albumThumbnailAssetId == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
album.albumName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
: 'album_thumbnail_card_items',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(args: ['${album.assetCount}']),
|
||||
if (album.shared)
|
||||
const Text(
|
||||
'album_thumbnail_card_shared',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr()
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
if (isSuccess) {
|
||||
Navigator.pop(context);
|
||||
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
ImmichToast.show(
|
||||
|
||||
@@ -62,7 +62,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
if (addAssetsResult != null &&
|
||||
addAssetsResult.successfullyAdded > 0) {
|
||||
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
@@ -88,7 +88,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
.addAdditionalUserToAlbum(sharedUserIds, albumId);
|
||||
|
||||
if (isSuccess) {
|
||||
ref.refresh(sharedAlbumDetailProvider(albumId));
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
}
|
||||
|
||||
ImmichLoadingOverlayController.appLoader.hide();
|
||||
|
||||
@@ -11,12 +11,18 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart
|
||||
import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class CreateAlbumPage extends HookConsumerWidget {
|
||||
bool isSharedAlbum;
|
||||
final bool isSharedAlbum;
|
||||
final List<Asset>? initialAssets;
|
||||
|
||||
CreateAlbumPage({Key? key, required this.isSharedAlbum}) : super(key: key);
|
||||
const CreateAlbumPage({
|
||||
Key? key,
|
||||
required this.isSharedAlbum,
|
||||
this.initialAssets,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
@@ -22,7 +22,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
Widget _buildAppBar() {
|
||||
Widget buildAppBar() {
|
||||
return const SliverAppBar(
|
||||
centerTitle: true,
|
||||
floating: true,
|
||||
@@ -40,7 +40,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateAlbumButton() {
|
||||
Widget buildCreateAlbumButton() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false));
|
||||
@@ -83,7 +83,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(),
|
||||
buildAppBar(),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
@@ -99,7 +99,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
children: [
|
||||
_buildCreateAlbumButton(),
|
||||
buildCreateAlbumButton(),
|
||||
for (var album in albums)
|
||||
AlbumThumbnailCard(
|
||||
album: album,
|
||||
|
||||
@@ -188,7 +188,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
"ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
|
||||
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
required this.onDownloadPressed,
|
||||
required this.onSharePressed,
|
||||
required this.onDeletePressed,
|
||||
required this.onAddToAlbumPressed,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingMotionVideo,
|
||||
}) : super(key: key);
|
||||
@@ -20,6 +21,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onDeletePressed;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final Function onSharePressed;
|
||||
final bool isPlayingMotionVideo;
|
||||
|
||||
@@ -80,6 +82,18 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
),
|
||||
if (asset.isRemote)
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
onPressed: () {
|
||||
onAddToAlbumPressed();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.add,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
iconSize: iconSize,
|
||||
splashRadius: iconSize,
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_list.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
@@ -105,6 +107,22 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void addToAlbum(Asset addToAlbumAsset) {
|
||||
showModalBottomSheet(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
barrierColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return AddToAlbumBottomSheet(
|
||||
assets: [addToAlbumAsset],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
@@ -130,6 +148,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
||||
}),
|
||||
onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])),
|
||||
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: PageView.builder(
|
||||
|
||||
@@ -8,7 +8,8 @@ class AssetCacheService extends JsonCache<List<Asset>> {
|
||||
AssetCacheService() : super("asset_cache");
|
||||
|
||||
static Future<List<Map<String, dynamic>>> _computeSerialize(
|
||||
List<Asset> assets) async {
|
||||
List<Asset> assets,
|
||||
) async {
|
||||
return assets.map((e) => e.toJson()).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,13 @@ class _AssetGroupsToRenderListComputeParameters {
|
||||
final Map<String, List<Asset>> groups;
|
||||
final int perRow;
|
||||
|
||||
_AssetGroupsToRenderListComputeParameters(this.monthFormat, this.dayFormat,
|
||||
this.dayFormatYear, this.groups, this.perRow);
|
||||
_AssetGroupsToRenderListComputeParameters(
|
||||
this.monthFormat,
|
||||
this.dayFormat,
|
||||
this.dayFormatYear,
|
||||
this.groups,
|
||||
this.perRow,
|
||||
);
|
||||
}
|
||||
|
||||
class RenderList {
|
||||
@@ -52,7 +57,8 @@ class RenderList {
|
||||
RenderList(this.elements);
|
||||
|
||||
static Future<RenderList> _processAssetGroupData(
|
||||
_AssetGroupsToRenderListComputeParameters data) async {
|
||||
_AssetGroupsToRenderListComputeParameters data,
|
||||
) async {
|
||||
final monthFormat = DateFormat(data.monthFormat);
|
||||
final dayFormatSameYear = DateFormat(data.dayFormat);
|
||||
final dayFormatOtherYear = DateFormat(data.dayFormatYear);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.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:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ControlBottomAppBar extends ConsumerWidget {
|
||||
@@ -16,11 +13,13 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
final void Function() onCreateNewAlbum;
|
||||
|
||||
final List<AlbumResponseDto> albums;
|
||||
final List<AlbumResponseDto> sharedAlbums;
|
||||
|
||||
const ControlBottomAppBar({
|
||||
Key? key,
|
||||
required this.onShare,
|
||||
required this.onDelete,
|
||||
required this.sharedAlbums,
|
||||
required this.albums,
|
||||
required this.onAddToAlbum,
|
||||
required this.onCreateNewAlbum,
|
||||
@@ -56,60 +55,6 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget renderAlbums() {
|
||||
Widget renderAlbum(AlbumResponseDto album) {
|
||||
final box = Hive.box(userInfoBox);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: GestureDetector(
|
||||
onTap: () => onAddToAlbum(album),
|
||||
child: Container(
|
||||
width: 112,
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
width: 100,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: getAlbumThumbnailUrl(album),
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
cacheKey: getAlbumThumbNailCacheKey(album),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
album.albumName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (buildContext, i) => renderAlbum(albums[i]),
|
||||
itemCount: albums.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.30,
|
||||
minChildSize: 0.15,
|
||||
@@ -119,42 +64,53 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Card(
|
||||
elevation: 12.0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
return Card(
|
||||
elevation: 12.0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.all(0),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.all(0),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 12),
|
||||
const CustomDraggingHandle(),
|
||||
const SizedBox(height: 12),
|
||||
renderActionButtons(),
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
thickness: 1,
|
||||
),
|
||||
AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 12),
|
||||
const CustomDraggingHandle(),
|
||||
const SizedBox(height: 12),
|
||||
renderActionButtons(),
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
thickness: 1,
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: AddToAlbumSliverList(
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onAddToAlbum: onAddToAlbum,
|
||||
),
|
||||
AddToAlbumTitleRow(
|
||||
onCreateNewAlbum: () => onCreateNewAlbum(),
|
||||
),
|
||||
renderAlbums(),
|
||||
const SizedBox(height: 200),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 200),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -185,9 +141,10 @@ class AddToAlbumTitleRow extends StatelessWidget {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
TextButton(
|
||||
TextButton.icon(
|
||||
onPressed: onCreateNewAlbum,
|
||||
child: Text(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(
|
||||
"control_bottom_app_bar_create_new_album",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
@@ -37,6 +38,7 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
final selection = useState(<Asset>{});
|
||||
final albums = ref.watch(albumProvider);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
|
||||
final tipOneOpacity = useState(0.0);
|
||||
@@ -46,6 +48,7 @@ class HomePage extends HookConsumerWidget {
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
ref.read(assetProvider.notifier).getAllAsset();
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
|
||||
selectionEnabledHook.addListener(() {
|
||||
@@ -147,6 +150,7 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
if (result != null) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
selectionEnabledHook.value = false;
|
||||
|
||||
AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id));
|
||||
@@ -220,6 +224,7 @@ class HomePage extends HookConsumerWidget {
|
||||
onDelete: onDelete,
|
||||
onAddToAlbum: onAddToAlbum,
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onCreateNewAlbum: onCreateNewAlbum,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -235,7 +235,7 @@ class ServerEndpointInput extends StatelessWidget {
|
||||
labelText: 'login_form_endpoint_url'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_endpoint_hint'.tr(),
|
||||
errorMaxLines: 4
|
||||
errorMaxLines: 4,
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
|
||||
@@ -24,6 +24,7 @@ class TilesPerRow extends HookConsumerWidget {
|
||||
|
||||
void sliderChangedEnd(double _) {
|
||||
ref.invalidate(assetProvider);
|
||||
ref.watch(assetProvider.notifier).getAllAsset();
|
||||
}
|
||||
|
||||
useEffect(
|
||||
|
||||
@@ -60,7 +60,8 @@ class _$AppRouter extends RootStackRouter {
|
||||
isZoomedFunction: args.isZoomedFunction,
|
||||
isZoomedListener: args.isZoomedListener,
|
||||
loadPreview: args.loadPreview,
|
||||
loadOriginal: args.loadOriginal));
|
||||
loadOriginal: args.loadOriginal,
|
||||
showExifSheet: args.showExifSheet));
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
@@ -87,7 +88,9 @@ class _$AppRouter extends RootStackRouter {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: CreateAlbumPage(
|
||||
key: args.key, isSharedAlbum: args.isSharedAlbum));
|
||||
key: args.key,
|
||||
isSharedAlbum: args.isSharedAlbum,
|
||||
initialAssets: args.initialAssets));
|
||||
},
|
||||
AssetSelectionRoute.name: (routeData) {
|
||||
return CustomPage<AssetSelectionPageResult?>(
|
||||
@@ -307,7 +310,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
required void Function() isZoomedFunction,
|
||||
required ValueNotifier<bool> isZoomedListener,
|
||||
required bool loadPreview,
|
||||
required bool loadOriginal})
|
||||
required bool loadOriginal,
|
||||
void Function()? showExifSheet})
|
||||
: super(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page',
|
||||
args: ImageViewerRouteArgs(
|
||||
@@ -318,7 +322,8 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
loadPreview: loadPreview,
|
||||
loadOriginal: loadOriginal));
|
||||
loadOriginal: loadOriginal,
|
||||
showExifSheet: showExifSheet));
|
||||
|
||||
static const String name = 'ImageViewerRoute';
|
||||
}
|
||||
@@ -332,7 +337,8 @@ class ImageViewerRouteArgs {
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.loadPreview,
|
||||
required this.loadOriginal});
|
||||
required this.loadOriginal,
|
||||
this.showExifSheet});
|
||||
|
||||
final Key? key;
|
||||
|
||||
@@ -350,9 +356,11 @@ class ImageViewerRouteArgs {
|
||||
|
||||
final bool loadOriginal;
|
||||
|
||||
final void Function()? showExifSheet;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal}';
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,24 +440,31 @@ class SearchResultRouteArgs {
|
||||
/// generated route for
|
||||
/// [CreateAlbumPage]
|
||||
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
|
||||
CreateAlbumRoute({Key? key, required bool isSharedAlbum})
|
||||
CreateAlbumRoute(
|
||||
{Key? key, required bool isSharedAlbum, List<Asset>? initialAssets})
|
||||
: super(CreateAlbumRoute.name,
|
||||
path: '/create-album-page',
|
||||
args: CreateAlbumRouteArgs(key: key, isSharedAlbum: isSharedAlbum));
|
||||
args: CreateAlbumRouteArgs(
|
||||
key: key,
|
||||
isSharedAlbum: isSharedAlbum,
|
||||
initialAssets: initialAssets));
|
||||
|
||||
static const String name = 'CreateAlbumRoute';
|
||||
}
|
||||
|
||||
class CreateAlbumRouteArgs {
|
||||
const CreateAlbumRouteArgs({this.key, required this.isSharedAlbum});
|
||||
const CreateAlbumRouteArgs(
|
||||
{this.key, required this.isSharedAlbum, this.initialAssets});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final bool isSharedAlbum;
|
||||
|
||||
final List<Asset>? initialAssets;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum}';
|
||||
return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
// Perform tasks on re-visit to SearchRoute
|
||||
if (route.name == 'SearchRoute') {
|
||||
// Refresh Location State
|
||||
ref.refresh(getCuratedLocationProvider);
|
||||
ref.refresh(getCuratedObjectProvider);
|
||||
ref.invalidate(getCuratedLocationProvider);
|
||||
ref.invalidate(getCuratedObjectProvider);
|
||||
}
|
||||
|
||||
if (route.name == 'SharingRoute') {
|
||||
|
||||
@@ -83,6 +83,7 @@ class ImmichLogger {
|
||||
}
|
||||
|
||||
// Share file
|
||||
// ignore: deprecated_member_use
|
||||
await Share.shareFiles(
|
||||
[filePath],
|
||||
subject: "Immich logs $dateTime",
|
||||
|
||||
@@ -40,6 +40,7 @@ class ShareService {
|
||||
}
|
||||
});
|
||||
|
||||
// ignore: deprecated_member_use
|
||||
Share.shareFiles(
|
||||
await Future.wait(downloadedFilePaths),
|
||||
sharePositionOrigin: Rect.zero,
|
||||
|
||||
@@ -10,8 +10,10 @@ String getThumbnailUrl(
|
||||
return _getThumbnailUrl(asset.id, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKey(final AssetResponseDto asset,
|
||||
{ThumbnailFormat type = ThumbnailFormat.WEBP}) {
|
||||
String getThumbnailCacheKey(
|
||||
final AssetResponseDto asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailCacheKey(asset.id, type);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ ThemeData immichDarkTheme = ThemeData(
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: immichDarkThemePrimaryColor,
|
||||
),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
titleTextStyle: TextStyle(
|
||||
fontFamily: 'WorkSans',
|
||||
@@ -59,7 +64,7 @@ ThemeData immichDarkTheme = ThemeData(
|
||||
headline2: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color.fromARGB(255, 148, 151, 155),
|
||||
color: Color.fromARGB(255, 255, 255, 255),
|
||||
),
|
||||
headline3: TextStyle(
|
||||
fontSize: 12,
|
||||
|
||||
@@ -31,7 +31,9 @@ extension WithETag on AssetApi {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
final etag = response.headers[HttpHeaders.etagHeader];
|
||||
final data = (await apiClient.deserializeAsync(
|
||||
responseBody, 'List<AssetResponseDto>') as List)
|
||||
responseBody,
|
||||
'List<AssetResponseDto>',
|
||||
) as List)
|
||||
.cast<AssetResponseDto>()
|
||||
.toList();
|
||||
return Pair(data, etag);
|
||||
|
||||
2
mobile/openapi/README.md
generated
@@ -102,8 +102,6 @@ Class | Method | HTTP request | Description
|
||||
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
|
||||
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
|
||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
||||
*DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info |
|
||||
*DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info |
|
||||
*DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
|
||||
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
|
||||
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} |
|
||||
|
||||
100
mobile/openapi/doc/DeviceInfoApi.md
generated
@@ -9,109 +9,9 @@ All URIs are relative to */api*
|
||||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**createDeviceInfo**](DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info |
|
||||
[**updateDeviceInfo**](DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info |
|
||||
[**upsertDeviceInfo**](DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info |
|
||||
|
||||
|
||||
# **createDeviceInfo**
|
||||
> DeviceInfoResponseDto createDeviceInfo(upsertDeviceInfoDto)
|
||||
|
||||
|
||||
|
||||
@deprecated
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = DeviceInfoApi();
|
||||
final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.createDeviceInfo(upsertDeviceInfoDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling DeviceInfoApi->createDeviceInfo: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: application/json
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **updateDeviceInfo**
|
||||
> DeviceInfoResponseDto updateDeviceInfo(upsertDeviceInfoDto)
|
||||
|
||||
|
||||
|
||||
@deprecated
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = DeviceInfoApi();
|
||||
final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.updateDeviceInfo(upsertDeviceInfoDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling DeviceInfoApi->updateDeviceInfo: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: application/json
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **upsertDeviceInfo**
|
||||
> DeviceInfoResponseDto upsertDeviceInfo(upsertDeviceInfoDto)
|
||||
|
||||
|
||||
2
mobile/openapi/doc/ExifResponseDto.md
generated
@@ -22,7 +22,7 @@ Name | Type | Description | Notes
|
||||
**fNumber** | **num** | | [optional]
|
||||
**focalLength** | **num** | | [optional]
|
||||
**iso** | **num** | | [optional]
|
||||
**exposureTime** | **num** | | [optional]
|
||||
**exposureTime** | **String** | | [optional]
|
||||
**latitude** | **num** | | [optional]
|
||||
**longitude** | **num** | | [optional]
|
||||
**city** | **String** | | [optional]
|
||||
|
||||
1
mobile/openapi/doc/JobCommandDto.md
generated
@@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**command** | [**JobCommand**](JobCommand.md) | |
|
||||
**includeAllAssets** | **bool** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
104
mobile/openapi/lib/api/device_info_api.dart
generated
@@ -16,110 +16,6 @@ class DeviceInfoApi {
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// @deprecated
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
|
||||
Future<Response> createDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/device-info';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = upsertDeviceInfoDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// @deprecated
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
|
||||
Future<DeviceInfoResponseDto?> createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
|
||||
final response = await createDeviceInfoWithHttpInfo(upsertDeviceInfoDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// @deprecated
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
|
||||
Future<Response> updateDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/device-info';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = upsertDeviceInfoDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'PATCH',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// @deprecated
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required):
|
||||
Future<DeviceInfoResponseDto?> updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async {
|
||||
final response = await updateDeviceInfoWithHttpInfo(upsertDeviceInfoDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
///
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
|
||||
6
mobile/openapi/lib/model/exif_response_dto.dart
generated
@@ -63,7 +63,7 @@ class ExifResponseDto {
|
||||
|
||||
num? iso;
|
||||
|
||||
num? exposureTime;
|
||||
String? exposureTime;
|
||||
|
||||
num? latitude;
|
||||
|
||||
@@ -273,9 +273,7 @@ class ExifResponseDto {
|
||||
iso: json[r'iso'] == null
|
||||
? null
|
||||
: num.parse(json[r'iso'].toString()),
|
||||
exposureTime: json[r'exposureTime'] == null
|
||||
? null
|
||||
: num.parse(json[r'exposureTime'].toString()),
|
||||
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
|
||||
latitude: json[r'latitude'] == null
|
||||
? null
|
||||
: num.parse(json[r'latitude'].toString()),
|
||||
|
||||
14
mobile/openapi/lib/model/job_command_dto.dart
generated
@@ -14,25 +14,31 @@ class JobCommandDto {
|
||||
/// Returns a new [JobCommandDto] instance.
|
||||
JobCommandDto({
|
||||
required this.command,
|
||||
required this.includeAllAssets,
|
||||
});
|
||||
|
||||
JobCommand command;
|
||||
|
||||
bool includeAllAssets;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is JobCommandDto &&
|
||||
other.command == command;
|
||||
other.command == command &&
|
||||
other.includeAllAssets == includeAllAssets;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(command.hashCode);
|
||||
(command.hashCode) +
|
||||
(includeAllAssets.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'JobCommandDto[command=$command]';
|
||||
String toString() => 'JobCommandDto[command=$command, includeAllAssets=$includeAllAssets]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'command'] = this.command;
|
||||
json[r'includeAllAssets'] = this.includeAllAssets;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -56,6 +62,7 @@ class JobCommandDto {
|
||||
|
||||
return JobCommandDto(
|
||||
command: JobCommand.fromJson(json[r'command'])!,
|
||||
includeAllAssets: mapValueOfType<bool>(json, r'includeAllAssets')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -106,6 +113,7 @@ class JobCommandDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'command',
|
||||
'includeAllAssets',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
14
mobile/openapi/test/device_info_api_test.dart
generated
@@ -17,20 +17,6 @@ void main() {
|
||||
// final instance = DeviceInfoApi();
|
||||
|
||||
group('tests for DeviceInfoApi', () {
|
||||
// @deprecated
|
||||
//
|
||||
//Future<DeviceInfoResponseDto> createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async
|
||||
test('test createDeviceInfo', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// @deprecated
|
||||
//
|
||||
//Future<DeviceInfoResponseDto> updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async
|
||||
test('test updateDeviceInfo', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//
|
||||
//
|
||||
//Future<DeviceInfoResponseDto> upsertDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async
|
||||
|
||||
2
mobile/openapi/test/exif_response_dto_test.dart
generated
@@ -86,7 +86,7 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// num exposureTime
|
||||
// String exposureTime
|
||||
test('to test the property `exposureTime`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
5
mobile/openapi/test/job_command_dto_test.dart
generated
@@ -21,6 +21,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool includeAllAssets
|
||||
test('to test the property `includeAllAssets`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.42.0+65
|
||||
version: 1.43.0+66
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface IAssetRepository {
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity>;
|
||||
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
|
||||
getAll(): Promise<AssetEntity[]>;
|
||||
getAllVideos(): Promise<AssetEntity[]>;
|
||||
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
|
||||
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
|
||||
getById(assetId: string): Promise<AssetEntity>;
|
||||
@@ -61,6 +63,22 @@ export class AssetRepository implements IAssetRepository {
|
||||
@Inject(ITagRepository) private _tagRepository: ITagRepository,
|
||||
) {}
|
||||
|
||||
async getAllVideos(): Promise<AssetEntity[]> {
|
||||
return await this.assetRepository.find({
|
||||
where: { type: AssetType.VIDEO },
|
||||
});
|
||||
}
|
||||
|
||||
async getAll(): Promise<AssetEntity[]> {
|
||||
return await this.assetRepository.find({
|
||||
where: { isVisible: true },
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
smartInfo: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
|
||||
return await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { assetUploadOption } from '../../config/asset-upload.config';
|
||||
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
import { Response as Res } from 'express';
|
||||
@@ -80,7 +80,7 @@ export class AssetController {
|
||||
})
|
||||
async uploadFile(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] },
|
||||
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
|
||||
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
|
||||
@@ -123,6 +123,8 @@ describe('AssetService', () => {
|
||||
assetRepositoryMock = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
getAllVideos: jest.fn(),
|
||||
getAllByUserId: jest.fn(),
|
||||
getAllByDeviceId: jest.fn(),
|
||||
getAssetCountByTimeBucket: jest.fn(),
|
||||
|
||||
@@ -55,6 +55,7 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
|
||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { ImmichFile } from '../../config/asset-upload.config';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -82,16 +83,16 @@ export class AssetService {
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
res: Res,
|
||||
originalAssetData: Express.Multer.File,
|
||||
livePhotoAssetData?: Express.Multer.File,
|
||||
originalAssetData: ImmichFile,
|
||||
livePhotoAssetData?: ImmichFile,
|
||||
) {
|
||||
const checksum = await this.calculateChecksum(originalAssetData.path);
|
||||
const checksum = originalAssetData.checksum;
|
||||
const isLivePhoto = livePhotoAssetData !== undefined;
|
||||
let livePhotoAssetEntity: AssetEntity | undefined;
|
||||
|
||||
try {
|
||||
if (isLivePhoto) {
|
||||
const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path);
|
||||
const livePhotoChecksum = livePhotoAssetData.checksum;
|
||||
livePhotoAssetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
|
||||
@@ -19,8 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`New websocket connection: ${client.id}`);
|
||||
|
||||
const user = await this.authService.validateSocket(client);
|
||||
const user = await this.authService.validate(client.request.headers);
|
||||
if (user) {
|
||||
client.join(user.id);
|
||||
} else {
|
||||
@@ -28,7 +27,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
||||
client.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
// Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection');
|
||||
client.emit('error', 'unauthorized');
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Patch, Post, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
@@ -13,24 +13,6 @@ import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/dev
|
||||
export class DeviceInfoController {
|
||||
constructor(private readonly deviceInfoService: DeviceInfoService) {}
|
||||
|
||||
/** @deprecated */
|
||||
@Post()
|
||||
public async createDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
|
||||
): Promise<DeviceInfoResponseDto> {
|
||||
return this.upsertDeviceInfo(user, dto);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
@Patch()
|
||||
public async updateDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
|
||||
): Promise<DeviceInfoResponseDto> {
|
||||
return this.upsertDeviceInfo(user, dto);
|
||||
}
|
||||
|
||||
@Put()
|
||||
public async upsertDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty } from 'class-validator';
|
||||
import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class JobCommandDto {
|
||||
@IsNotEmpty()
|
||||
@@ -9,4 +9,8 @@ export class JobCommandDto {
|
||||
enumName: 'JobCommand',
|
||||
})
|
||||
command!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeAllAssets!: boolean;
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ export class JobController {
|
||||
@Put('/:jobId')
|
||||
async sendJobCommand(
|
||||
@Param(ValidationPipe) params: GetJobDto,
|
||||
@Body(ValidationPipe) body: JobCommandDto,
|
||||
@Body(ValidationPipe) dto: JobCommandDto,
|
||||
): Promise<number> {
|
||||
if (body.command === 'start') {
|
||||
return await this.jobService.start(params.jobId);
|
||||
if (dto.command === 'start') {
|
||||
return await this.jobService.start(params.jobId, dto.includeAllAssets);
|
||||
}
|
||||
if (body.command === 'stop') {
|
||||
if (dto.command === 'stop') {
|
||||
return await this.jobService.stop(params.jobId);
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IAssetRepository } from '../asset/asset-repository';
|
||||
import { AssetType } from '@app/infra';
|
||||
import { JobId } from './dto/get-job.dto';
|
||||
import { MACHINE_LEARNING_ENABLED } from '@app/common';
|
||||
|
||||
import { getFileNameWithoutExtension } from '../../utils/file-name.util';
|
||||
const jobIds = Object.values(JobId) as JobId[];
|
||||
|
||||
@Injectable()
|
||||
@@ -19,8 +19,8 @@ export class JobService {
|
||||
}
|
||||
}
|
||||
|
||||
start(jobId: JobId): Promise<number> {
|
||||
return this.run(this.asQueueName(jobId));
|
||||
start(jobId: JobId, includeAllAssets: boolean): Promise<number> {
|
||||
return this.run(this.asQueueName(jobId), includeAllAssets);
|
||||
}
|
||||
|
||||
async stop(jobId: JobId): Promise<number> {
|
||||
@@ -36,7 +36,7 @@ export class JobService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private async run(name: QueueName): Promise<number> {
|
||||
private async run(name: QueueName, includeAllAssets: boolean): Promise<number> {
|
||||
const isActive = await this.jobRepository.isActive(name);
|
||||
if (isActive) {
|
||||
throw new BadRequestException(`Job is already running`);
|
||||
@@ -44,7 +44,9 @@ export class JobService {
|
||||
|
||||
switch (name) {
|
||||
case QueueName.VIDEO_CONVERSION: {
|
||||
const assets = await this._assetRepository.getAssetWithNoEncodedVideo();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAllVideos()
|
||||
: await this._assetRepository.getAssetWithNoEncodedVideo();
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
|
||||
}
|
||||
@@ -61,7 +63,10 @@ export class JobService {
|
||||
throw new BadRequestException('Machine learning is not enabled.');
|
||||
}
|
||||
|
||||
const assets = await this._assetRepository.getAssetWithNoSmartInfo();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAll()
|
||||
: await this._assetRepository.getAssetWithNoSmartInfo();
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } });
|
||||
await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } });
|
||||
@@ -70,19 +75,37 @@ export class JobService {
|
||||
}
|
||||
|
||||
case QueueName.METADATA_EXTRACTION: {
|
||||
const assets = await this._assetRepository.getAssetWithNoEXIF();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAll()
|
||||
: await this._assetRepository.getAssetWithNoEXIF();
|
||||
|
||||
for (const asset of assets) {
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } });
|
||||
await this.jobRepository.add({
|
||||
name: JobName.EXTRACT_VIDEO_METADATA,
|
||||
data: {
|
||||
asset,
|
||||
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } });
|
||||
await this.jobRepository.add({
|
||||
name: JobName.EXIF_EXTRACTION,
|
||||
data: {
|
||||
asset,
|
||||
fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return assets.length;
|
||||
}
|
||||
|
||||
case QueueName.THUMBNAIL_GENERATION: {
|
||||
const assets = await this._assetRepository.getAssetWithNoThumbnail();
|
||||
const assets = includeAllAssets
|
||||
? await this._assetRepository.getAll()
|
||||
: await this._assetRepository.getAssetWithNoThumbnail();
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { immichAppConfig } from '@app/common/config';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
@@ -23,6 +22,9 @@ import {
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
} from './controllers';
|
||||
import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy';
|
||||
import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy';
|
||||
import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,8 +36,6 @@ import {
|
||||
|
||||
AssetModule,
|
||||
|
||||
ImmichJwtModule,
|
||||
|
||||
DeviceInfoModule,
|
||||
|
||||
ServerInfoModule,
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
],
|
||||
providers: [],
|
||||
providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
// TODO: check if consumer is needed or remove
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
|
||||
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { Request } from 'express';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { diskStorage } from 'multer';
|
||||
import { diskStorage, StorageEngine } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { AuthUserDto } from '../decorators/auth-user.decorator';
|
||||
@@ -12,14 +12,40 @@ import { patchFormData } from '../utils/path-form-data.util';
|
||||
|
||||
const logger = new Logger('AssetUploadConfig');
|
||||
|
||||
export interface ImmichFile extends Express.Multer.File {
|
||||
/** sha1 hash of file */
|
||||
checksum: Buffer;
|
||||
}
|
||||
|
||||
export const assetUploadOption: MulterOptions = {
|
||||
fileFilter,
|
||||
storage: diskStorage({
|
||||
destination,
|
||||
filename,
|
||||
}),
|
||||
storage: customStorage(),
|
||||
};
|
||||
|
||||
export function customStorage(): StorageEngine {
|
||||
const storage = diskStorage({ destination, filename });
|
||||
|
||||
return {
|
||||
_handleFile(req, file, callback) {
|
||||
const hash = createHash('sha1');
|
||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
||||
|
||||
storage._handleFile(req, file, (error, response) => {
|
||||
if (error) {
|
||||
hash.destroy();
|
||||
callback(error);
|
||||
} else {
|
||||
callback(null, { ...response, checksum: hash.digest() } as ImmichFile);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_removeFile(req, file, callback) {
|
||||
storage._removeFile(req, file, callback);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const multerUtils = { fileFilter, filename, destination };
|
||||
|
||||
function fileFilter(req: Request, file: any, cb: any) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
|
||||
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
|
||||
import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard';
|
||||
import { AuthGuard } from '../modules/immich-auth/guards/auth.guard';
|
||||
|
||||
interface AuthenticatedOptions {
|
||||
admin?: boolean;
|
||||
|
||||
3
server/apps/immich/src/global.d.ts
vendored
@@ -4,5 +4,8 @@ declare global {
|
||||
namespace Express {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface User extends AuthUserDto {}
|
||||
export interface Request {
|
||||
user: AuthUserDto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,6 @@ async function bootstrap() {
|
||||
.addBearerAuth({
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
bearerFormat: 'JWT',
|
||||
name: 'JWT',
|
||||
description: 'Enter JWT token',
|
||||
in: 'header',
|
||||
})
|
||||
.addServer('/api')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
|
||||
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
|
||||
import { JWT_STRATEGY } from '../strategies/jwt.strategy';
|
||||
import { AUTH_COOKIE_STRATEGY } from '../strategies/user-auth.strategy';
|
||||
import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {}
|
||||
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {}
|
||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { AssetEntity, AssetType, ExifEntity, UserEntity } from '@app/infra';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UserEntity } from '@app/infra';
|
||||
import { userUtils } from '@app/common';
|
||||
import { IJobRepository, JobName } from '@app/domain';
|
||||
|
||||
@@ -13,93 +12,8 @@ export class ScheduleTasksService {
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
|
||||
@InjectRepository(ExifEntity)
|
||||
private exifRepository: Repository<ExifEntity>,
|
||||
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async webpConversion() {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: {
|
||||
webpPath: '',
|
||||
},
|
||||
});
|
||||
|
||||
if (assets.length == 0) {
|
||||
Logger.log('All assets has webp file - aborting task', 'CronjobWebpGenerator');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } });
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_1AM)
|
||||
async videoConversion() {
|
||||
const assets = await this.assetRepository.find({
|
||||
where: {
|
||||
type: AssetType.VIDEO,
|
||||
mimeType: 'video/quicktime',
|
||||
encodedVideoPath: '',
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } });
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||
async reverseGeocoding() {
|
||||
const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true';
|
||||
|
||||
if (isGeocodingEnabled) {
|
||||
const exifInfo = await this.exifRepository.find({
|
||||
where: {
|
||||
city: IsNull(),
|
||||
longitude: Not(IsNull()),
|
||||
latitude: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
for (const exif of exifInfo) {
|
||||
await this.jobRepository.add({
|
||||
name: JobName.REVERSE_GEOCODING,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
data: { exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||
async extractExif() {
|
||||
const exifAssets = await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.leftJoinAndSelect('asset.exifInfo', 'ei')
|
||||
.where('ei."assetId" IS NULL')
|
||||
.getMany();
|
||||
|
||||
for (const asset of exifAssets) {
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } });
|
||||
} else {
|
||||
await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_11PM)
|
||||
async deleteUserAndRelatedAssets() {
|
||||
const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
|
||||
|
||||
5
server/apps/immich/src/utils/file-name.util.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { basename, extname } from 'node:path';
|
||||
|
||||
export function getFileNameWithoutExtension(path: string): string {
|
||||
return basename(path, extname(path));
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import { clearDb, getAuthUser, authCustom } from './test-utils';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { AlbumModule } from '../src/api-v1/album/album.module';
|
||||
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||
import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { AuthService, DomainModule, UserService } from '@app/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
|
||||
return request(app.getHttpServer()).post('/album').send(data);
|
||||
@@ -21,7 +21,7 @@ describe('Album', () => {
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule, ImmichJwtModule],
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"modulePaths": ["<rootDir>", "<rootDir>../../../"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
|
||||