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

|

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

|

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