mirror of
https://github.com/immich-app/immich.git
synced 2025-12-10 01:10:21 +03:00
Compare commits
55 Commits
v1.28.2_40
...
v1.30.0_46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b9e37461 | ||
|
|
0d3a2fe844 | ||
|
|
848781aef5 | ||
|
|
28bf497a0b | ||
|
|
8ede738396 | ||
|
|
40c2b6a563 | ||
|
|
3581cf7305 | ||
|
|
c33775b944 | ||
|
|
b0cd2522e0 | ||
|
|
c3979f6e31 | ||
|
|
103df4d9f3 | ||
|
|
040e02cfc5 | ||
|
|
f377b64065 | ||
|
|
e5459b68ff | ||
|
|
fc194021a4 | ||
|
|
39f8ca3bf1 | ||
|
|
7a807f7216 | ||
|
|
bedfb51b1c | ||
|
|
b2afb95c19 | ||
|
|
10239161fd | ||
|
|
242f10952d | ||
|
|
e997bd371b | ||
|
|
400167f4ef | ||
|
|
572f6d833d | ||
|
|
2e06be5155 | ||
|
|
62121470a8 | ||
|
|
e3ccc3ee6b | ||
|
|
ece94f6bdc | ||
|
|
03fc0703c0 | ||
|
|
0d13b25f56 | ||
|
|
75c2067836 | ||
|
|
824da6a07b | ||
|
|
2c2ea24dc4 | ||
|
|
47b73a5b64 | ||
|
|
6b3f8e548d | ||
|
|
0ea483f901 | ||
|
|
97aed8ef23 | ||
|
|
0ee3fe9157 | ||
|
|
434770155f | ||
|
|
7e8bf94543 | ||
|
|
8d8944705c | ||
|
|
7c9c1a5169 | ||
|
|
1a6c16d8ea | ||
|
|
ccf792f9d3 | ||
|
|
789bc8563c | ||
|
|
99a50f70dd | ||
|
|
9bef411056 | ||
|
|
e79e92c60f | ||
|
|
858ad43d3b | ||
|
|
5761765ea7 | ||
|
|
6abc733763 | ||
|
|
4271e24e59 | ||
|
|
9e4ed2214b | ||
|
|
011332e509 | ||
|
|
5403ef4d84 |
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
74
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '20 13 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
28
README.md
28
README.md
@@ -23,12 +23,29 @@
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
## Demo
|
||||
|
||||
You can access the web demo at https://demo.immich.app
|
||||
|
||||
For the mobile app, you can use https://demo.immich.app/api for the `Server Endpoint URL`
|
||||
|
||||
|
||||
```
|
||||
The credential
|
||||
email: demo@immich.app
|
||||
password: demo
|
||||
```
|
||||
|
||||
```
|
||||
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
```
|
||||
|
||||
## Content
|
||||
- [Features](#features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Installation](#installation)
|
||||
- [Update](#update)
|
||||
- [Mobile App](#-mobile-app)
|
||||
- [Mobile App](#mobile-app)
|
||||
- [Development](#development)
|
||||
- [Support](#support)
|
||||
- [Known Issues](#known-issues)
|
||||
@@ -146,7 +163,6 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
||||
* Populate custom database information if necessary.
|
||||
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
|
||||
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
|
||||
* [Optional] Populate Mapbox value to use reverse geocoding.
|
||||
|
||||
### Step 3 - Start the containers
|
||||
|
||||
@@ -185,9 +201,9 @@ docker-compose pull && docker-compose up -d
|
||||
|
||||
| F-Droid | Google Play | iOS |
|
||||
| - | - | - |
|
||||
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <img src="design/google-play-qr-code.png" width="200" title="Google Play Store"> <p/> | <p align="left"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <p/> |
|
||||
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
|
||||
|
||||
> *The App version might be lagging behind the latest release due to the review process.*
|
||||
> *The Play/App Store version might be lagging behind the latest release due to the review process.*
|
||||
|
||||
|
||||
<br/>
|
||||
@@ -236,7 +252,7 @@ Cheers! 🎉
|
||||
|
||||
## TensorFlow Build Issue
|
||||
|
||||
*This is a known issue for incorrect Promox setup*
|
||||
*This is a known issue for incorrect Proxmox setup*
|
||||
|
||||
TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX and AVX2 instruction set. If you encounter the error `illegal instruction core dump` when running the docker-compose command above, check for your CPU flags with the command and make sure you see `AVX` and `AVX2`:
|
||||
|
||||
@@ -244,7 +260,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
|
||||
more /proc/cpuinfo | grep flags
|
||||
```
|
||||
|
||||
If you are running virtualization in Promox, the VM doesn't have the flag enabled.
|
||||
If you are running virtualization in Proxmox, the VM doesn't have the flag enabled.
|
||||
|
||||
You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.
|
||||
|
||||
|
||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to `alex.tran1502@gmail.com`
|
||||
@@ -10,9 +10,6 @@ DB_DATABASE_NAME=immich
|
||||
# Optional Database settings:
|
||||
# DB_PORT=5432
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################################
|
||||
# Redis
|
||||
###################################################################################
|
||||
@@ -25,18 +22,17 @@ REDIS_HOSTNAME=immich_redis
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_SOCKET=
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################################
|
||||
# Upload File Config
|
||||
###################################################################################
|
||||
|
||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||
|
||||
###################################################################################
|
||||
# Log message level - [simple|verbose]
|
||||
###################################################################################
|
||||
|
||||
|
||||
LOG_LEVEL=simple
|
||||
|
||||
###################################################################################
|
||||
# JWT SECRET
|
||||
@@ -44,17 +40,21 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba
|
||||
|
||||
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
|
||||
|
||||
|
||||
|
||||
|
||||
###################################################################################
|
||||
# MAPBOX
|
||||
# Reverse Geocoding
|
||||
####################################################################################
|
||||
|
||||
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY
|
||||
ENABLE_MAPBOX=false
|
||||
MAPBOX_KEY=
|
||||
# DISABLE_REVERSE_GEOCODING=false
|
||||
|
||||
# Reverse geocoding is done locally which has a small impact on memory usage
|
||||
# This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
|
||||
# This ranges from 0-3 with 3 being the most precise
|
||||
# 3 - Cities > 500 population: ~200MB RAM
|
||||
# 2 - Cities > 1000 population: ~150MB RAM
|
||||
# 1 - Cities > 5000 population: ~80MB RAM
|
||||
# 0 - Cities > 15000 population: ~40MB RAM
|
||||
|
||||
# REVERSE_GEOCODING_PRECISION=3
|
||||
|
||||
####################################################################################
|
||||
# WEB - Optional
|
||||
@@ -63,4 +63,4 @@ MAPBOX_KEY=
|
||||
# Custom message on the login page, should be written in HTML form.
|
||||
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
|
||||
|
||||
PUBLIC_LOGIN_PAGE_MESSAGE=
|
||||
PUBLIC_LOGIN_PAGE_MESSAGE=
|
||||
|
||||
@@ -19,4 +19,4 @@ ENABLE_MAPBOX=false
|
||||
|
||||
# WEB
|
||||
MAPBOX_KEY=
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
|
||||
@@ -102,8 +102,7 @@ services:
|
||||
context: ../nginx
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
|
||||
@@ -72,8 +72,7 @@ services:
|
||||
container_name: immich_proxy
|
||||
image: altran1502/immich-proxy:staging
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2284:443
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
container_name: immich_proxy
|
||||
image: altran1502/immich-proxy:release
|
||||
ports:
|
||||
- 2283:80
|
||||
- 2283:8080
|
||||
logging:
|
||||
driver: none
|
||||
depends_on:
|
||||
|
||||
19
install.sh
19
install.sh
@@ -2,10 +2,19 @@ echo "Starting Immich installation..."
|
||||
|
||||
ip_address=$(hostname -I | awk '{print $1}')
|
||||
|
||||
release_version=$(curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" |
|
||||
grep '"tag_name":' |
|
||||
sed -E 's/.*"([^"]+)".*/\1/')
|
||||
RED='\033[0;31m'
|
||||
GREEN='\032[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
get_release_version() {
|
||||
curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" | # Get latest release from GitHub api
|
||||
grep '"tag_name":' | # Get tag line
|
||||
sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value
|
||||
}
|
||||
|
||||
create_immich_directory() {
|
||||
echo "Creating Immich directory..."
|
||||
mkdir -p ./immich-app/immich-data
|
||||
@@ -13,12 +22,12 @@ create_immich_directory() {
|
||||
|
||||
download_docker_compose_file() {
|
||||
echo "Downloading docker-compose.yml..."
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
|
||||
}
|
||||
|
||||
download_dot_env_file() {
|
||||
echo "Downloading .env file..."
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
|
||||
curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
|
||||
}
|
||||
|
||||
populate_upload_location() {
|
||||
@@ -41,9 +50,9 @@ populate_upload_location() {
|
||||
start_docker_compose() {
|
||||
echo "Starting Immich's docker containers"
|
||||
|
||||
if docker compose &> /dev/null; then
|
||||
if docker compose &>/dev/null; then
|
||||
docker_bin="docker compose"
|
||||
elif docker-compose &> /dev/null; then
|
||||
elif docker-compose &>/dev/null; then
|
||||
docker_bin="docker-compose"
|
||||
else
|
||||
echo 'Cannot find `docker compose` or `docker-compose`.'
|
||||
@@ -64,7 +73,7 @@ show_friendly_message() {
|
||||
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
|
||||
echo "The backup (or upload) location is $upload_location"
|
||||
echo "---------------------------------------------------"
|
||||
echo "If you want to confgure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
||||
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
||||
|
||||
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,
|
||||
|
||||
|
||||
2227
machine-learning/package-lock.json
generated
2227
machine-learning/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@
|
||||
"@nestjs/core": "^8.0.0",
|
||||
"@nestjs/mapped-types": "^1.0.1",
|
||||
"@nestjs/platform-express": "^8.0.0",
|
||||
"@nestjs/typeorm": "^8.0.3",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.2",
|
||||
"@tensorflow-models/mobilenet": "^2.1.0",
|
||||
"@tensorflow/tfjs": "^3.19.0",
|
||||
@@ -34,11 +33,9 @@
|
||||
"@tensorflow/tfjs-node": "^3.19.0",
|
||||
"@tensorflow/tfjs-node-gpu": "^3.19.0",
|
||||
"@trpc/server": "^9.20.3",
|
||||
"pg": "^8.7.3",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"typeorm": "^0.2.45"
|
||||
"rxjs": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^8.2.4",
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageClassifierModule } from './image-classifier/image-classifier.module';
|
||||
import { databaseConfig } from './config/database.config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ObjectDetectionModule } from './object-detection/object-detection.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
ImageClassifierModule,
|
||||
ObjectDetectionModule,
|
||||
],
|
||||
imports: [ImageClassifierModule, ObjectDetectionModule],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
export const databaseConfig: TypeOrmModuleOptions = {
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOSTNAME || 'immich_postgres',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE_NAME,
|
||||
synchronize: false,
|
||||
};
|
||||
@@ -14,7 +14,12 @@ class MainActivity: FlutterActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startService(Intent(getBaseContext(), AppClearedService::class.java));
|
||||
try {
|
||||
startService(Intent(getBaseContext(), AppClearedService::class.java));
|
||||
} catch (e: Exception) {
|
||||
// startService must not be called when app is in background (crashes app)
|
||||
// there is nothing we can do
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 40,
|
||||
"android.injected.version.name" => "1.28.2",
|
||||
"android.injected.version.code" => 46,
|
||||
"android.injected.version.name" => "1.30.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')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed oversize play button on video
|
||||
* Fixed app crashing when swipe between assets
|
||||
@@ -0,0 +1,2 @@
|
||||
* Fixed Android BackgroundServiceStartNotAllowedException
|
||||
* Restore old cache mechanism
|
||||
@@ -0,0 +1 @@
|
||||
* Update deprecated API that causes notification not dismissing after background upload progress finished.
|
||||
@@ -0,0 +1 @@
|
||||
* Fixed app crashes when there is no object detection result.
|
||||
@@ -0,0 +1 @@
|
||||
* Correctly display time based on timezone
|
||||
@@ -0,0 +1 @@
|
||||
* Added improvement for timeline view
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.311329">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="99.857291">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.070842">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="40.236485">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -165,5 +165,10 @@
|
||||
"version_announcement_overlay_text_1": "Hi friend, there is a new release of",
|
||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
|
||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||
"experimental_settings_title": "Experimental",
|
||||
"experimental_settings_subtitle": "Use at your own risk!",
|
||||
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
||||
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
||||
"settings_require_restart": "Please restart Immich to apply this setting"
|
||||
}
|
||||
@@ -360,11 +360,11 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 60;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -495,11 +495,11 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 60;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -522,11 +522,11 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
CURRENT_PROJECT_VERSION = 60;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.27.0</string>
|
||||
<string>1.29.6</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>52</string>
|
||||
<string>60</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.28.2"
|
||||
version_number: "1.30.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.604146">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="30.057077">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.321654">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.438506">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.655368">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="62.328417">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="62.633232">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -15,11 +14,9 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
const AlbumThumbnailCard({
|
||||
Key? key,
|
||||
required this.album,
|
||||
required this.cacheService,
|
||||
}) : super(key: key);
|
||||
|
||||
final AlbumResponseDto album;
|
||||
final CacheService cacheService;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -39,7 +36,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
|
||||
memCacheHeight: max(400, cardSize.toInt() * 3),
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -17,13 +14,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
const AlbumViewerThumbnail({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.cacheManager,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -126,7 +121,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: cacheManager,
|
||||
cacheKey: asset.id,
|
||||
width: 300,
|
||||
height: 300,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -24,7 +22,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
var newAssetsForAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
||||
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
|
||||
@@ -114,7 +111,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
cacheKey: asset.id,
|
||||
width: 150,
|
||||
height: 150,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -16,7 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
@@ -26,7 +23,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
cacheKey: asset.id,
|
||||
width: 500,
|
||||
height: 500,
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
@@ -192,7 +191,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final bool showStorageIndicator =
|
||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
if (albumInfo.assets.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
@@ -207,7 +205,6 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return AlbumViewerThumbnail(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
asset: albumInfo.assets[index],
|
||||
assetList: albumInfo.assets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
|
||||
class LibraryPage extends HookConsumerWidget {
|
||||
const LibraryPage({Key? key}) : super(key: key);
|
||||
@@ -14,7 +13,6 @@ class LibraryPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -104,7 +102,6 @@ class LibraryPage extends HookConsumerWidget {
|
||||
_buildCreateAlbumButton(),
|
||||
for (var album in albums)
|
||||
AlbumThumbnailCard(
|
||||
cacheService: cacheService,
|
||||
album: album,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -21,7 +20,6 @@ class SharingPage extends HookConsumerWidget {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
|
||||
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -47,8 +45,6 @@ class SharingPage extends HookConsumerWidget {
|
||||
height: 60,
|
||||
memCacheHeight: 200,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager:
|
||||
cacheService.getCache(CacheType.sharedAlbumThumbnail),
|
||||
imageUrl: getAlbumThumbnailUrl(album),
|
||||
cacheKey: album.albumThumbnailAssetId,
|
||||
httpHeaders: {
|
||||
|
||||
@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
|
||||
if (assetDetail.exifInfo?.dateTimeOriginal != null)
|
||||
Text(
|
||||
DateFormat('date_format'.tr()).format(
|
||||
assetDetail.exifInfo!.dateTimeOriginal!,
|
||||
assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
||||
@@ -12,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
bool _zoomedIn = false;
|
||||
|
||||
static const int swipeThreshold = 100;
|
||||
late CachedNetworkImageProvider fullProvider;
|
||||
late CachedNetworkImageProvider previewProvider;
|
||||
late CachedNetworkImageProvider thumbnailProvider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -56,21 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
widget.isZoomedFunction();
|
||||
}
|
||||
|
||||
void _fireStartLoadingEvent() {
|
||||
widget.onLoadingStart();
|
||||
}
|
||||
|
||||
void _fireFinishedLoadingEvent() {
|
||||
widget.onLoadingCompleted();
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(
|
||||
String url, String cacheKey, BaseCacheManager? cacheManager) {
|
||||
String url,
|
||||
String cacheKey,
|
||||
) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"Authorization": widget.authToken},
|
||||
cacheKey: cacheKey,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (newStatus != _RemoteImageStatus.full) {
|
||||
_fireStartLoadingEvent();
|
||||
} else {
|
||||
_fireFinishedLoadingEvent();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = newStatus;
|
||||
_imageProvider = provider;
|
||||
@@ -104,10 +92,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
}
|
||||
|
||||
void _loadImages() {
|
||||
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
|
||||
thumbnailProvider = _authorizedImageProvider(
|
||||
widget.thumbnailUrl,
|
||||
widget.cacheKey,
|
||||
widget.thumbnailCacheManager,
|
||||
);
|
||||
_imageProvider = thumbnailProvider;
|
||||
|
||||
@@ -121,10 +108,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
);
|
||||
|
||||
if (widget.previewUrl != null) {
|
||||
CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
|
||||
previewProvider = _authorizedImageProvider(
|
||||
widget.previewUrl!,
|
||||
"${widget.cacheKey}_previewStage",
|
||||
widget.previewCacheManager,
|
||||
);
|
||||
previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
@@ -133,10 +119,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
);
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
|
||||
fullProvider = _authorizedImageProvider(
|
||||
widget.imageUrl,
|
||||
"${widget.cacheKey}_fullStage",
|
||||
widget.fullCacheManager,
|
||||
);
|
||||
fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
@@ -147,8 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_loadImages();
|
||||
super.initState();
|
||||
_loadImages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() async {
|
||||
super.dispose();
|
||||
|
||||
if (_status == _RemoteImageStatus.full) {
|
||||
await fullProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.preview) {
|
||||
await previewProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.thumbnail) {
|
||||
await thumbnailProvider.evict();
|
||||
}
|
||||
|
||||
await _imageProvider.evict();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,11 +163,6 @@ class RemotePhotoView extends StatefulWidget {
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
this.previewUrl,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
this.thumbnailCacheManager,
|
||||
this.previewCacheManager,
|
||||
this.fullCacheManager,
|
||||
required this.cacheKey,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -175,11 +170,6 @@ class RemotePhotoView extends StatefulWidget {
|
||||
final String imageUrl;
|
||||
final String authToken;
|
||||
final String? previewUrl;
|
||||
final Function onLoadingCompleted;
|
||||
final Function onLoadingStart;
|
||||
final BaseCacheManager? thumbnailCacheManager;
|
||||
final BaseCacheManager? previewCacheManager;
|
||||
final BaseCacheManager? fullCacheManager;
|
||||
final String cacheKey;
|
||||
|
||||
final void Function() onSwipeDown;
|
||||
|
||||
@@ -24,11 +24,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
double iconSize = 18.0;
|
||||
|
||||
return AppBar(
|
||||
// iconTheme: IconThemeData(color: Colors.grey[100]),
|
||||
// actionsIconTheme: IconThemeData(color: Colors.grey[100]),
|
||||
foregroundColor: Colors.grey[100],
|
||||
toolbarHeight: 60,
|
||||
backgroundColor: Colors.black,
|
||||
backgroundColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
|
||||
@@ -121,8 +121,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onLoadingCompleted: () => {},
|
||||
onLoadingStart: () => {},
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
threeStageLoading: threeStageLoading.value,
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -19,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
final String authToken;
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
final void Function() isZoomedFunction;
|
||||
final void Function() onLoadingCompleted;
|
||||
final void Function() onLoadingStart;
|
||||
final bool threeStageLoading;
|
||||
|
||||
ImageViewerPage({
|
||||
@@ -30,8 +27,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
required this.threeStageLoading,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -41,7 +36,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail =
|
||||
@@ -85,14 +79,6 @@ class ImageViewerPage extends HookConsumerWidget {
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: () => showInfo(),
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart,
|
||||
thumbnailCacheManager:
|
||||
cacheService.getCache(CacheType.thumbnail),
|
||||
previewCacheManager:
|
||||
cacheService.getCache(CacheType.imageViewerPreview),
|
||||
fullCacheManager:
|
||||
cacheService.getCache(CacheType.imageViewerFull),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
|
||||
_createChewieController() {
|
||||
chewieController = ChewieController(
|
||||
showOptions: true,
|
||||
showControlsOnInitialize: true,
|
||||
showControlsOnInitialize: false,
|
||||
videoPlayerController: videoPlayerController,
|
||||
autoPlay: true,
|
||||
autoInitialize: true,
|
||||
|
||||
@@ -26,7 +26,7 @@ class AvailableAlbum {
|
||||
|
||||
String get name => albumEntity.name;
|
||||
|
||||
int get assetCount => albumEntity.assetCount;
|
||||
Future<int> get assetCount => albumEntity.assetCountAsync;
|
||||
|
||||
String get id => albumEntity.id;
|
||||
|
||||
|
||||
@@ -183,17 +183,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
var assetList =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assetCountInAlbum = await album.assetCountAsync;
|
||||
if (assetCountInAlbum > 0) {
|
||||
var assetList =
|
||||
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
|
||||
|
||||
if (assetList.isNotEmpty) {
|
||||
var thumbnailAsset = assetList.first;
|
||||
var thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
if (assetList.isNotEmpty) {
|
||||
var thumbnailAsset = assetList.first;
|
||||
var thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum =
|
||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
}
|
||||
|
||||
availableAlbums.add(availableAlbum);
|
||||
}
|
||||
|
||||
availableAlbums.add(availableAlbum);
|
||||
}
|
||||
|
||||
state = state.copyWith(availableAlbums: availableAlbums);
|
||||
@@ -296,14 +300,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (var album in state.selectedBackupAlbums) {
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (var album in state.excludedBackupAlbums) {
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
@@ -353,11 +361,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await Future.wait([
|
||||
_getBackupAlbumsInfo(),
|
||||
_updateServerInfo(),
|
||||
]);
|
||||
|
||||
await _getBackupAlbumsInfo();
|
||||
await _updateServerInfo();
|
||||
await _updateBackupAssetCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,9 @@ class BackupService {
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
|
||||
result.addAll(await a.getAssetListRange(start: 0, end: a.assetCount));
|
||||
result.addAll(
|
||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||
);
|
||||
lastBackup[i] = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -205,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Text(
|
||||
albumInfo.assetCount.toString() +
|
||||
(albumInfo.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
child: FutureBuilder(
|
||||
builder: ((context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
snapshot.data.toString() +
|
||||
(albumInfo.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text("0");
|
||||
}),
|
||||
future: albumInfo.assetCount,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
||||
@@ -508,7 +508,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
DateTime.parse(
|
||||
backupState.currentUploadAsset.createdAt
|
||||
.toString(),
|
||||
),
|
||||
).toLocal(),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
|
||||
DateFormat.yMMMMd('en_US').format(
|
||||
DateTime.parse(
|
||||
errorAsset.createdAt.toString(),
|
||||
),
|
||||
).toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
enum RenderAssetGridElementType {
|
||||
assetRow,
|
||||
dayTitle,
|
||||
monthTitle;
|
||||
}
|
||||
|
||||
class RenderAssetGridRow {
|
||||
final List<AssetResponseDto> assets;
|
||||
|
||||
RenderAssetGridRow(this.assets);
|
||||
}
|
||||
|
||||
class RenderAssetGridElement {
|
||||
final RenderAssetGridElementType type;
|
||||
final RenderAssetGridRow? assetRow;
|
||||
final String? title;
|
||||
final int? month;
|
||||
final int? year;
|
||||
final List<AssetResponseDto>? relatedAssetList;
|
||||
|
||||
RenderAssetGridElement(
|
||||
this.type, {
|
||||
this.assetRow,
|
||||
this.title,
|
||||
this.month,
|
||||
this.year,
|
||||
this.relatedAssetList,
|
||||
});
|
||||
}
|
||||
|
||||
final renderListProvider = StateProvider((ref) {
|
||||
var assetGroups = ref.watch(assetGroupByDateTimeProvider);
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
|
||||
|
||||
List<RenderAssetGridElement> elements = [];
|
||||
DateTime? lastDate;
|
||||
|
||||
assetGroups.forEach((groupName, assets) {
|
||||
final date = DateTime.parse(groupName);
|
||||
|
||||
if (lastDate == null || lastDate!.month != date.month) {
|
||||
elements.add(
|
||||
RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
|
||||
title: groupName, month: date.month, year: date.year),
|
||||
);
|
||||
}
|
||||
|
||||
// Add group title
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.dayTitle,
|
||||
title: groupName,
|
||||
month: date.month,
|
||||
year: date.year,
|
||||
relatedAssetList: assets,
|
||||
),
|
||||
);
|
||||
|
||||
// Add rows
|
||||
int cursor = 0;
|
||||
while (cursor < assets.length) {
|
||||
int rowElements = min(assets.length - cursor, assetsPerRow);
|
||||
|
||||
final rowElement = RenderAssetGridElement(
|
||||
RenderAssetGridElementType.assetRow,
|
||||
month: date.month,
|
||||
year: date.year,
|
||||
assetRow: RenderAssetGridRow(
|
||||
assets.sublist(cursor, cursor + rowElements),
|
||||
),
|
||||
);
|
||||
|
||||
elements.add(rowElement);
|
||||
cursor += rowElements;
|
||||
}
|
||||
|
||||
lastDate = date;
|
||||
});
|
||||
|
||||
return elements;
|
||||
});
|
||||
107
mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
Normal file
107
mobile/lib/modules/home/ui/asset_list_v2/daily_title_text.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class DailyTitleText extends ConsumerWidget {
|
||||
const DailyTitleText({
|
||||
Key? key,
|
||||
required this.isoDate,
|
||||
required this.assetGroup,
|
||||
}) : super(key: key);
|
||||
|
||||
final String isoDate;
|
||||
final List<AssetResponseDto> assetGroup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var currentYear = DateTime.now().year;
|
||||
var groupYear = DateTime.parse(isoDate).year;
|
||||
var formatDateTemplate = currentYear == groupYear
|
||||
? "daily_title_text_date".tr()
|
||||
: "daily_title_text_date_year".tr();
|
||||
var dateText =
|
||||
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
||||
var isMultiSelectEnable =
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||
var selectedItems = ref.watch(homePageStateProvider).selectedItems;
|
||||
|
||||
void _handleTitleIconClick() {
|
||||
if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedDateGroup.length == 1 &&
|
||||
selectedItems.length <= assetGroup.length) {
|
||||
// Multi select is active - click again on the icon while it is the only active group -> disable multi select
|
||||
ref.watch(homePageStateProvider.notifier).disableMultiSelect();
|
||||
} else if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedItems.length != assetGroup.length) {
|
||||
// Multi select is active - click again on the icon while it is not the only active group -> remove that group from selected group/items
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeSelectedDateGroup(dateText);
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable &&
|
||||
selectedDateGroup.contains(dateText) &&
|
||||
selectedDateGroup.length > 1) {
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeSelectedDateGroup(dateText);
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.removeMultipleSelectedItem(assetGroup);
|
||||
} else if (isMultiSelectEnable && !selectedDateGroup.contains(dateText)) {
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.addSelectedDateGroup(dateText);
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.addMultipleSelectedItems(assetGroup);
|
||||
} else {
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.enableMultiSelect(assetGroup.toSet());
|
||||
ref
|
||||
.watch(homePageStateProvider.notifier)
|
||||
.addSelectedDateGroup(dateText);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 29.0,
|
||||
bottom: 29.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
dateText,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: _handleTitleIconClick,
|
||||
child: isMultiSelectEnable && selectedDateGroup.contains(dateText)
|
||||
? Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
/// Build the Scroll Thumb and label using the current configuration
|
||||
typedef ScrollThumbBuilder = Widget Function(
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text? labelText,
|
||||
BoxConstraints? labelConstraints,
|
||||
});
|
||||
|
||||
/// Build a Text widget using the current scroll offset
|
||||
typedef LabelTextBuilder = Text Function(int item);
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
class DraggableScrollbar extends StatefulWidget {
|
||||
/// The view that will be scrolled with the scroll thumb
|
||||
final ScrollablePositionedList child;
|
||||
|
||||
final ItemPositionsListener itemPositionsListener;
|
||||
|
||||
/// A function that builds a thumb using the current configuration
|
||||
final ScrollThumbBuilder scrollThumbBuilder;
|
||||
|
||||
/// The height of the scroll thumb
|
||||
final double heightScrollThumb;
|
||||
|
||||
/// The background color of the label and thumb
|
||||
final Color backgroundColor;
|
||||
|
||||
/// The amount of padding that should surround the thumb
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Determines how quickly the scrollbar will animate in and out
|
||||
final Duration scrollbarAnimationDuration;
|
||||
|
||||
/// How long should the thumb be visible before fading out
|
||||
final Duration scrollbarTimeToFade;
|
||||
|
||||
/// Build a Text widget from the current offset in the BoxScrollView
|
||||
final LabelTextBuilder? labelTextBuilder;
|
||||
|
||||
/// Determines box constraints for Container displaying label
|
||||
final BoxConstraints? labelConstraints;
|
||||
|
||||
/// The ScrollController for the BoxScrollView
|
||||
final ItemScrollController controller;
|
||||
|
||||
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
|
||||
final bool alwaysVisibleScrollThumb;
|
||||
|
||||
final Function(bool scrolling) scrollStateListener;
|
||||
|
||||
DraggableScrollbar.semicircle({
|
||||
Key? key,
|
||||
Key? scrollThumbKey,
|
||||
this.alwaysVisibleScrollThumb = false,
|
||||
required this.child,
|
||||
required this.controller,
|
||||
required this.itemPositionsListener,
|
||||
required this.scrollStateListener,
|
||||
this.heightScrollThumb = 48.0,
|
||||
this.backgroundColor = Colors.white,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder = _thumbSemicircleBuilder(
|
||||
heightScrollThumb * 0.6,
|
||||
scrollThumbKey,
|
||||
alwaysVisibleScrollThumb,
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
DraggableScrollbarState createState() => DraggableScrollbarState();
|
||||
|
||||
static buildScrollThumbAndLabel({
|
||||
required Widget scrollThumb,
|
||||
required Color backgroundColor,
|
||||
required Animation<double>? thumbAnimation,
|
||||
required Animation<double>? labelAnimation,
|
||||
required Text? labelText,
|
||||
required BoxConstraints? labelConstraints,
|
||||
required bool alwaysVisibleScrollThumb,
|
||||
}) {
|
||||
var scrollThumbAndLabel = labelText == null
|
||||
? scrollThumb
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ScrollLabel(
|
||||
animation: labelAnimation,
|
||||
backgroundColor: backgroundColor,
|
||||
constraints: labelConstraints,
|
||||
child: labelText,
|
||||
),
|
||||
scrollThumb,
|
||||
],
|
||||
);
|
||||
|
||||
if (alwaysVisibleScrollThumb) {
|
||||
return scrollThumbAndLabel;
|
||||
}
|
||||
return SlideFadeTransition(
|
||||
animation: thumbAnimation!,
|
||||
child: scrollThumbAndLabel,
|
||||
);
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbSemicircleBuilder(
|
||||
double width,
|
||||
Key? scrollThumbKey,
|
||||
bool alwaysVisibleScrollThumb,
|
||||
) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text? labelText,
|
||||
BoxConstraints? labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = CustomPaint(
|
||||
key: scrollThumbKey,
|
||||
foregroundPainter: ArrowCustomPainter(Colors.white),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(height),
|
||||
bottomLeft: Radius.circular(height),
|
||||
topRight: const Radius.circular(4.0),
|
||||
bottomRight: const Radius.circular(4.0),
|
||||
),
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(Size(width, height)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return buildScrollThumbAndLabel(
|
||||
scrollThumb: scrollThumb,
|
||||
backgroundColor: backgroundColor,
|
||||
thumbAnimation: thumbAnimation,
|
||||
labelAnimation: labelAnimation,
|
||||
labelText: labelText,
|
||||
labelConstraints: labelConstraints,
|
||||
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollLabel extends StatelessWidget {
|
||||
final Animation<double>? animation;
|
||||
final Color backgroundColor;
|
||||
final Text child;
|
||||
|
||||
final BoxConstraints? constraints;
|
||||
static const BoxConstraints _defaultConstraints =
|
||||
BoxConstraints.tightFor(width: 72.0, height: 28.0);
|
||||
|
||||
const ScrollLabel({
|
||||
Key? key,
|
||||
required this.child,
|
||||
required this.animation,
|
||||
required this.backgroundColor,
|
||||
this.constraints = _defaultConstraints,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: animation!,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 12.0),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
child: Container(
|
||||
constraints: constraints ?? _defaultConstraints,
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
with TickerProviderStateMixin {
|
||||
late double _barOffset;
|
||||
late bool _isDragInProcess;
|
||||
late int _currentItem;
|
||||
|
||||
late AnimationController _thumbAnimationController;
|
||||
late Animation<double> _thumbAnimation;
|
||||
late AnimationController _labelAnimationController;
|
||||
late Animation<double> _labelAnimation;
|
||||
Timer? _fadeoutTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_barOffset = 0.0;
|
||||
_isDragInProcess = false;
|
||||
_currentItem = 0;
|
||||
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
parent: _thumbAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
parent: _labelAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get barMaxScrollExtent =>
|
||||
(context.size?.height ?? 0) - widget.heightScrollThumb;
|
||||
|
||||
double get barMinScrollExtent => 0;
|
||||
|
||||
int get maxItemCount => widget.child.itemCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Text? labelText;
|
||||
if (widget.labelTextBuilder != null && _isDragInProcess) {
|
||||
int numberOfItems = widget.child.itemCount;
|
||||
|
||||
labelText = widget.labelTextBuilder!(_currentItem);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
//print("LayoutBuilder constraints=$constraints");
|
||||
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (ScrollNotification notification) {
|
||||
changePosition(notification);
|
||||
return false;
|
||||
},
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
RepaintBoundary(
|
||||
child: widget.child,
|
||||
),
|
||||
RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onVerticalDragStart,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
child: Container(
|
||||
alignment: Alignment.topRight,
|
||||
margin: EdgeInsets.only(top: _barOffset),
|
||||
padding: widget.padding,
|
||||
child: widget.scrollThumbBuilder(
|
||||
widget.backgroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.heightScrollThumb,
|
||||
labelText: labelText,
|
||||
labelConstraints: widget.labelConstraints,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// scroll bar has received notification that it's view was scrolled
|
||||
// so it should also changes his position
|
||||
// but only if it isn't dragged
|
||||
changePosition(ScrollNotification notification) {
|
||||
if (_isDragInProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
int firstItemIndex =
|
||||
widget.itemPositionsListener.itemPositions.value.first.index;
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
|
||||
|
||||
if (_barOffset < barMinScrollExtent) {
|
||||
_barOffset = barMinScrollExtent;
|
||||
}
|
||||
if (_barOffset > barMaxScrollExtent) {
|
||||
_barOffset = barMaxScrollExtent;
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification ||
|
||||
notification is OverscrollNotification) {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
if (itemPos < maxItemCount) {
|
||||
_currentItem = itemPos;
|
||||
}
|
||||
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_isDragInProcess = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeoutTimer?.cancel();
|
||||
});
|
||||
|
||||
widget.scrollStateListener(true);
|
||||
}
|
||||
|
||||
int get itemPos {
|
||||
int numberOfItems = widget.child.itemCount;
|
||||
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
|
||||
}
|
||||
|
||||
void _jumpToBarPos() {
|
||||
if (itemPos > maxItemCount - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
_currentItem = itemPos;
|
||||
|
||||
widget.controller.jumpTo(
|
||||
index: itemPos,
|
||||
);
|
||||
}
|
||||
|
||||
Timer? dragHaltTimer;
|
||||
int lastTimerPos = 0;
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
if (_isDragInProcess) {
|
||||
_barOffset += details.delta.dy;
|
||||
|
||||
if (_barOffset < barMinScrollExtent) {
|
||||
_barOffset = barMinScrollExtent;
|
||||
}
|
||||
if (_barOffset > barMaxScrollExtent) {
|
||||
_barOffset = barMaxScrollExtent;
|
||||
}
|
||||
|
||||
if (itemPos != lastTimerPos) {
|
||||
lastTimerPos = itemPos;
|
||||
dragHaltTimer?.cancel();
|
||||
widget.scrollStateListener(true);
|
||||
|
||||
dragHaltTimer = Timer(
|
||||
const Duration(milliseconds: 200),
|
||||
() {
|
||||
widget.scrollStateListener(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_jumpToBarPos();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_jumpToBarPos();
|
||||
_isDragInProcess = false;
|
||||
});
|
||||
|
||||
widget.scrollStateListener(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws 2 triangles like arrow up and arrow down
|
||||
class ArrowCustomPainter extends CustomPainter {
|
||||
Color color;
|
||||
|
||||
ArrowCustomPainter(this.color);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = color;
|
||||
const width = 12.0;
|
||||
const height = 8.0;
|
||||
final baseX = size.width / 2;
|
||||
final baseY = size.height / 2;
|
||||
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||
paint,
|
||||
);
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||
return Path()
|
||||
..moveTo(o.dx, o.dy)
|
||||
..lineTo(o.dx + width, o.dy)
|
||||
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
///This cut 2 lines in arrow shape
|
||||
class ArrowClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
Path path = Path();
|
||||
path.lineTo(0.0, size.height);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(size.width, 0.0);
|
||||
path.lineTo(0.0, 0.0);
|
||||
path.close();
|
||||
|
||||
double arrowWidth = 8.0;
|
||||
double startPointX = (size.width - arrowWidth) / 2;
|
||||
double startPointY = size.height / 2 - arrowWidth / 2;
|
||||
path.moveTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2,
|
||||
startPointY - arrowWidth / 2 + 1.0,
|
||||
);
|
||||
path.lineTo(startPointX, startPointY + 1.0);
|
||||
path.close();
|
||||
|
||||
startPointY = size.height / 2 + arrowWidth / 2;
|
||||
path.moveTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||
path.lineTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX, startPointY - 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2,
|
||||
startPointY + arrowWidth / 2 - 1.0,
|
||||
);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||
path.close();
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||
}
|
||||
|
||||
class SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
const SlideFadeTransition({
|
||||
Key? key,
|
||||
required this.animation,
|
||||
required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) =>
|
||||
animation.value == 0.0 ? const SizedBox() : child!,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0.3, 0.0),
|
||||
end: const Offset(0.0, 0.0),
|
||||
).animate(animation),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
166
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
166
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/src/widgets/framework.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/daily_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/draggable_scrollbar_custom.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import '../thumbnail_image.dart';
|
||||
|
||||
class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
final List<RenderAssetGridElement> renderList;
|
||||
final int assetsPerRow;
|
||||
final double margin;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
ImmichAssetGrid({
|
||||
super.key,
|
||||
required this.renderList,
|
||||
required this.assetsPerRow,
|
||||
required this.showStorageIndicator,
|
||||
this.margin = 5.0,
|
||||
});
|
||||
|
||||
List<AssetResponseDto> get _assets {
|
||||
return renderList
|
||||
.map((e) {
|
||||
if (e.type == RenderAssetGridElementType.assetRow) {
|
||||
return e.assetRow!.assets;
|
||||
} else {
|
||||
return List<AssetResponseDto>.empty();
|
||||
}
|
||||
})
|
||||
.flattened
|
||||
.toList();
|
||||
}
|
||||
|
||||
double _getItemSize(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width / assetsPerRow -
|
||||
margin * (assetsPerRow - 1) / assetsPerRow;
|
||||
}
|
||||
|
||||
Widget _buildThumbnailOrPlaceholder(
|
||||
AssetResponseDto asset, bool placeholder) {
|
||||
if (placeholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: _assets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
useGrayBoxPlaceholder: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context, RenderAssetGridRow row, bool scrolling) {
|
||||
double size = _getItemSize(context);
|
||||
|
||||
return Row(
|
||||
key: Key("asset-row-${row.assets.first.id}"),
|
||||
children: row.assets.map((AssetResponseDto asset) {
|
||||
bool last = asset == row.assets.last;
|
||||
|
||||
return Container(
|
||||
key: Key("asset-${asset.id}"),
|
||||
width: size,
|
||||
height: size,
|
||||
margin: EdgeInsets.only(top: margin, right: last ? 0.0 : margin),
|
||||
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(
|
||||
BuildContext context, String title, List<AssetResponseDto> assets) {
|
||||
return DailyTitleText(
|
||||
isoDate: title,
|
||||
assetGroup: assets,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthTitle(BuildContext context, String title) {
|
||||
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||
.format(DateTime.parse(title));
|
||||
|
||||
return Padding(
|
||||
key: Key("month-$title"),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||
child: Text(
|
||||
monthTitleText,
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).textTheme.headline1?.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemBuilder(BuildContext c, int position, bool scrolling) {
|
||||
final item = renderList[position];
|
||||
|
||||
if (item.type == RenderAssetGridElementType.dayTitle) {
|
||||
return _buildTitle(c, item.title!, item.relatedAssetList!);
|
||||
} else if (item.type == RenderAssetGridElementType.monthTitle) {
|
||||
return _buildMonthTitle(c, item.title!);
|
||||
} else if (item.type == RenderAssetGridElementType.assetRow) {
|
||||
return _buildAssetRow(c, item.assetRow!, scrolling);
|
||||
}
|
||||
|
||||
return const Text("Invalid widget type!");
|
||||
}
|
||||
|
||||
Text _labelBuilder(int pos) {
|
||||
return Text(
|
||||
"${renderList[pos].month} / ${renderList[pos].year}",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scrolling = useState(false);
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
scrolling.value = active;
|
||||
}
|
||||
|
||||
Widget itemBuilder(BuildContext c, int position) {
|
||||
return _itemBuilder(c, position, scrolling.value);
|
||||
}
|
||||
|
||||
return DraggableScrollbar.semicircle(
|
||||
scrollStateListener: dragScrolling,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
controller: _itemScrollController,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
labelTextBuilder: _labelBuilder,
|
||||
scrollbarAnimationDuration: const Duration(seconds: 1),
|
||||
scrollbarTimeToFade: const Duration(seconds: 4),
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemBuilder: itemBuilder,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemCount: renderList.length,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ class DailyTitleText extends ConsumerWidget {
|
||||
var formatDateTemplate = currentYear == groupYear
|
||||
? "daily_title_text_date".tr()
|
||||
: "daily_title_text_date_year".tr();
|
||||
var dateText =
|
||||
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate));
|
||||
var dateText = DateFormat(formatDateTemplate)
|
||||
.format(DateTime.parse(isoDate).toLocal());
|
||||
var isMultiSelectEnable =
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -10,13 +9,11 @@ class ImageGrid extends ConsumerWidget {
|
||||
final List<AssetResponseDto> sortedAssetGroup;
|
||||
final int tilesPerRow;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
ImageGrid({
|
||||
Key? key,
|
||||
required this.assetGroup,
|
||||
required this.sortedAssetGroup,
|
||||
this.cacheManager,
|
||||
this.tilesPerRow = 4,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
@@ -36,35 +33,10 @@ class ImageGrid extends ConsumerWidget {
|
||||
var assetType = assetGroup[index].type;
|
||||
return GestureDetector(
|
||||
onTap: () {},
|
||||
child: Stack(
|
||||
children: [
|
||||
ThumbnailImage(
|
||||
cacheManager: cacheManager,
|
||||
asset: assetGroup[index],
|
||||
assetList: sortedAssetGroup,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
),
|
||||
if (assetType != AssetTypeEnum.IMAGE)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
assetGroup[index].duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: ThumbnailImage(
|
||||
asset: assetGroup[index],
|
||||
assetList: sortedAssetGroup,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
|
||||
.format(DateTime.parse(isoDate));
|
||||
.format(DateTime.parse(isoDate).toLocal());
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
|
||||
@@ -2,8 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
@@ -17,14 +15,14 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
final bool useGrayBoxPlaceholder;
|
||||
|
||||
const ThumbnailImage({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.cacheManager,
|
||||
this.showStorageIndicator = true,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -36,7 +34,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
ref.watch(homePageStateProvider).isMultiSelectEnable;
|
||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||
|
||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
||||
Widget buildSelectionIcon(AssetResponseDto asset) {
|
||||
if (selectedAsset.contains(asset)) {
|
||||
return Icon(
|
||||
Icons.check_circle,
|
||||
@@ -52,7 +50,6 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
debugPrint("View ${asset.id}");
|
||||
if (isMultiSelectEnable &&
|
||||
selectedAsset.contains(asset) &&
|
||||
selectedAsset.length == 1) {
|
||||
@@ -95,26 +92,35 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
: const Border(),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: asset.id,
|
||||
cacheManager: cacheManager,
|
||||
cacheKey: 'thumbnail-image-${asset.id}',
|
||||
width: 300,
|
||||
height: 300,
|
||||
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
|
||||
memCacheHeight: 200,
|
||||
maxWidthDiskCache: 200,
|
||||
maxHeightDiskCache: 200,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) =>
|
||||
Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(
|
||||
value: downloadProgress.progress,
|
||||
),
|
||||
),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) {
|
||||
if (useGrayBoxPlaceholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
return Transform.scale(
|
||||
scale: 0.2,
|
||||
child: CircularProgressIndicator(
|
||||
value: downloadProgress.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) {
|
||||
debugPrint("Error getting thumbnail $url = $error");
|
||||
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
|
||||
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
@@ -127,7 +133,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _buildSelectionIcon(asset),
|
||||
child: buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
if (showStorageIndicator)
|
||||
@@ -141,7 +147,27 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
)
|
||||
),
|
||||
if (asset.type != AssetTypeEnum.IMAGE)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/home_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/disable_multi_select_button.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/image_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_list_v2/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/immich_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/monthly_title_text.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||
@@ -16,7 +18,6 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
@@ -25,7 +26,8 @@ class HomePage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
var renderList = ref.watch(renderListProvider);
|
||||
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||
@@ -91,7 +93,6 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
imageGridGroup.add(
|
||||
ImageGrid(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
assetGroup: immichAssetList,
|
||||
sortedAssetGroup: sortedAssetList,
|
||||
tilesPerRow:
|
||||
@@ -123,6 +124,31 @@ class HomePage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_buildAssetGrid() {
|
||||
if (appSettingService
|
||||
.getSetting(AppSettingsEnum.useExperimentalAssetGrid)) {
|
||||
return ImmichAssetGrid(
|
||||
renderList: renderList,
|
||||
assetsPerRow:
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
);
|
||||
} else {
|
||||
return DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
...imageGridGroup,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
bottom: !isMultiSelectEnable,
|
||||
top: !isMultiSelectEnable,
|
||||
@@ -135,17 +161,7 @@ class HomePage extends HookConsumerWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 60.0, bottom: 0.0),
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
...imageGridGroup,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _buildAssetGrid(),
|
||||
),
|
||||
if (isMultiSelectEnable) ...[
|
||||
_buildSelectedItemCountIndicator(),
|
||||
|
||||
@@ -62,7 +62,7 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
|
||||
(a, b) => b.compareTo(a),
|
||||
);
|
||||
return assets.groupListsBy(
|
||||
(element) =>
|
||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
||||
(element) => DateFormat('y-MM-dd')
|
||||
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
_buildThings() {
|
||||
return curatedObjects.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: ImmichLoadingIndicator()),
|
||||
loading: () => SizedBox(
|
||||
height: imageSize,
|
||||
child: const Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (objects) {
|
||||
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
// height: imageSize,
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
||||
@@ -10,7 +10,8 @@ enum AppSettingsEnum<T> {
|
||||
storageIndicator<bool>("storageIndicator", true),
|
||||
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||
imageCacheSize<int>("imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200);
|
||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
|
||||
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
|
||||
|
||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ class CacheSettings extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
final clearCacheState = useState(false);
|
||||
|
||||
Future<void> clearCache() async {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class ExperimentalSettings extends HookConsumerWidget {
|
||||
const ExperimentalSettings({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final useExperimentalAssetGrid = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
useExperimentalAssetGrid.value = appSettingService
|
||||
.getSetting(AppSettingsEnum.useExperimentalAssetGrid);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void changeUseExperimentalAssetGrid(bool status) {
|
||||
useExperimentalAssetGrid.value = status;
|
||||
appSettingService.setSetting(
|
||||
AppSettingsEnum.useExperimentalAssetGrid,
|
||||
status,
|
||||
);
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "settings_require_restart".tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
return ExpansionTile(
|
||||
textColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'experimental_settings_title',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
'experimental_settings_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
),
|
||||
).tr(),
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
"experimental_settings_new_asset_list_title",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
"experimental_settings_new_asset_list_subtitle",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
value: useExperimentalAssetGrid.value,
|
||||
onChanged: changeUseExperimentalAssetGrid,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||
@@ -42,8 +42,8 @@ class SettingsPage extends HookConsumerWidget {
|
||||
const ImageViewerQualitySetting(),
|
||||
const ThemeSetting(),
|
||||
const AssetListSettings(),
|
||||
const CacheSettings(),
|
||||
if (Platform.isAndroid) const NotificationSetting(),
|
||||
const ExperimentalSettings(),
|
||||
],
|
||||
).toList(),
|
||||
],
|
||||
|
||||
@@ -59,8 +59,6 @@ class _$AppRouter extends RootStackRouter {
|
||||
authToken: args.authToken,
|
||||
isZoomedFunction: args.isZoomedFunction,
|
||||
isZoomedListener: args.isZoomedListener,
|
||||
onLoadingCompleted: args.onLoadingCompleted,
|
||||
onLoadingStart: args.onLoadingStart,
|
||||
threeStageLoading: args.threeStageLoading));
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
@@ -297,8 +295,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
required String authToken,
|
||||
required void Function() isZoomedFunction,
|
||||
required ValueNotifier<bool> isZoomedListener,
|
||||
required void Function() onLoadingCompleted,
|
||||
required void Function() onLoadingStart,
|
||||
required bool threeStageLoading})
|
||||
: super(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page',
|
||||
@@ -309,8 +305,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart,
|
||||
threeStageLoading: threeStageLoading));
|
||||
|
||||
static const String name = 'ImageViewerRoute';
|
||||
@@ -324,8 +318,6 @@ class ImageViewerRouteArgs {
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
required this.threeStageLoading});
|
||||
|
||||
final Key? key;
|
||||
@@ -340,15 +332,11 @@ class ImageViewerRouteArgs {
|
||||
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
|
||||
final void Function() onLoadingCompleted;
|
||||
|
||||
final void Function() onLoadingStart;
|
||||
|
||||
final bool threeStageLoading;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, onLoadingCompleted: $onLoadingCompleted, onLoadingStart: $onLoadingStart, threeStageLoading: $threeStageLoading}';
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, threeStageLoading: $threeStageLoading}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
|
||||
(a, b) => b.compareTo(a),
|
||||
);
|
||||
return assets.groupListsBy(
|
||||
(element) =>
|
||||
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)),
|
||||
(element) => DateFormat('y-MM-dd')
|
||||
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
|
||||
);
|
||||
|
||||
return assets.groupListsBy(
|
||||
(element) =>
|
||||
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)),
|
||||
(element) => DateFormat('MMMM, y')
|
||||
.format(DateTime.parse(element.createdAt).toLocal()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
|
||||
enum CacheType {
|
||||
// Shared cache for asset thumbnails in various modules
|
||||
thumbnail,
|
||||
|
||||
imageViewerPreview,
|
||||
imageViewerFull,
|
||||
albumThumbnail,
|
||||
@@ -67,7 +66,10 @@ class CacheService {
|
||||
}
|
||||
|
||||
BaseCacheManager _getDefaultCache(
|
||||
String cacheName, int size, CacheInfoRepository repo) {
|
||||
String cacheName,
|
||||
int size,
|
||||
CacheInfoRepository repo,
|
||||
) {
|
||||
return CacheManager(
|
||||
Config(
|
||||
cacheName,
|
||||
|
||||
@@ -10,6 +10,7 @@ class ImmichToast {
|
||||
ToastType toastType = ToastType.info,
|
||||
ToastGravity gravity = ToastGravity.TOP,
|
||||
}) {
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
final fToast = FToast();
|
||||
fToast.init(context);
|
||||
|
||||
@@ -49,7 +50,7 @@ class ImmichToast {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
color: Colors.grey[50],
|
||||
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
border: Border.all(
|
||||
color: Colors.black12,
|
||||
width: 1,
|
||||
|
||||
@@ -115,4 +115,3 @@ lib/model/user_count_response_dto.dart
|
||||
lib/model/user_response_dto.dart
|
||||
lib/model/validate_access_token_response_dto.dart
|
||||
pubspec.yaml
|
||||
test/asset_count_by_user_id_response_dto_test.dart
|
||||
|
||||
@@ -76,69 +76,72 @@ class AssetResponseDto {
|
||||
SmartInfoResponseDto? smartInfo;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
|
||||
String toString() =>
|
||||
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final _json = <String, dynamic>{};
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
_json[r'type'] = type;
|
||||
_json[r'id'] = id;
|
||||
_json[r'deviceAssetId'] = deviceAssetId;
|
||||
_json[r'ownerId'] = ownerId;
|
||||
_json[r'deviceId'] = deviceId;
|
||||
_json[r'originalPath'] = originalPath;
|
||||
if (resizePath != null) {
|
||||
_json[r'resizePath'] = resizePath;
|
||||
} else {
|
||||
_json[r'resizePath'] = null;
|
||||
}
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
_json[r'createdAt'] = createdAt;
|
||||
_json[r'modifiedAt'] = modifiedAt;
|
||||
_json[r'isFavorite'] = isFavorite;
|
||||
if (mimeType != null) {
|
||||
_json[r'mimeType'] = mimeType;
|
||||
} else {
|
||||
_json[r'mimeType'] = null;
|
||||
}
|
||||
_json[r'duration'] = duration;
|
||||
_json[r'duration'] = duration;
|
||||
if (webpPath != null) {
|
||||
_json[r'webpPath'] = webpPath;
|
||||
} else {
|
||||
@@ -172,13 +175,13 @@ class AssetResponseDto {
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
|
||||
return AssetResponseDto(
|
||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||
@@ -202,7 +205,10 @@ class AssetResponseDto {
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<AssetResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <AssetResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@@ -230,12 +236,18 @@ class AssetResponseDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<AssetResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
final value = AssetResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -262,4 +274,3 @@ class AssetResponseDto {
|
||||
'encodedVideoPath',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ packages:
|
||||
name: _fe_analyzer_shared
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "38.0.0"
|
||||
version: "47.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
version: "4.7.0"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -42,14 +42,14 @@ packages:
|
||||
name: auto_route
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
version: "5.0.1"
|
||||
auto_route_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: auto_route_generator
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "5.0.2"
|
||||
badges:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -77,7 +77,7 @@ packages:
|
||||
name: build_config
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -98,7 +98,7 @@ packages:
|
||||
name: build_runner
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.10"
|
||||
version: "2.2.1"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -126,21 +126,21 @@ packages:
|
||||
name: cached_network_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "3.2.2"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "2.0.0"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
cancellation_token:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -162,13 +162,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -182,7 +175,7 @@ packages:
|
||||
name: chewie
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.3.5"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -238,7 +231,7 @@ packages:
|
||||
name: cupertino_icons
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "1.0.5"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -322,7 +315,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.6.8"
|
||||
flutter_cache_manager:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
@@ -461,7 +454,7 @@ packages:
|
||||
name: hive_generator
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
version: "1.1.3"
|
||||
hooks_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -503,7 +496,7 @@ packages:
|
||||
name: image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
version: "3.2.0"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -783,7 +776,7 @@ packages:
|
||||
name: photo_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.0+2"
|
||||
version: "2.2.1"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -839,7 +832,7 @@ packages:
|
||||
name: provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
version: "6.0.3"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -875,6 +868,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.27.3"
|
||||
scrollable_positioned_list:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.3.4"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1139,20 +1139,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
universal_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_html
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_io
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1223,27 +1209,20 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
very_good_analysis:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: very_good_analysis
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.7"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
version: "2.3.9"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1271,7 +1250,7 @@ packages:
|
||||
name: wakelock
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.6.1+2"
|
||||
version: "0.6.2"
|
||||
wakelock_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1341,7 +1320,7 @@ packages:
|
||||
name: xml
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.4.1"
|
||||
version: "6.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1350,5 +1329,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.17.0 <3.0.0"
|
||||
flutter: ">=3.0.0"
|
||||
dart: ">=2.18.0 <3.0.0"
|
||||
flutter: ">=3.3.0"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.28.2+40
|
||||
version: 1.30.0+46
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -11,22 +11,22 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
photo_manager: ^2.0.6
|
||||
photo_manager: ^2.2.1
|
||||
flutter_hooks: ^0.18.0
|
||||
hooks_riverpod: ^2.0.0-dev.0
|
||||
hive: ^2.2.1
|
||||
hive_flutter: ^1.1.0
|
||||
dio: ^4.0.4
|
||||
cached_network_image: ^3.2.1
|
||||
cached_network_image: ^3.2.2
|
||||
percent_indicator: ^4.2.2
|
||||
intl: ^0.17.0
|
||||
auto_route: ^4.0.1
|
||||
auto_route: ^5.0.1
|
||||
exif: ^3.1.1
|
||||
transparent_image: ^2.0.0
|
||||
flutter_launcher_icons: "^0.9.2"
|
||||
fluttertoast: ^8.0.8
|
||||
video_player: ^2.2.18
|
||||
chewie: ^1.2.2
|
||||
chewie: ^1.3.5
|
||||
badges: ^2.0.2
|
||||
photo_view: ^0.14.0
|
||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||
@@ -43,7 +43,7 @@ dependencies:
|
||||
easy_localization: ^3.0.1
|
||||
share_plus: ^4.0.10
|
||||
flutter_displaymode: ^0.4.0
|
||||
flutter_cache_manager: 3.3.0
|
||||
scrollable_positioned_list: ^0.3.4
|
||||
|
||||
path: ^1.8.1
|
||||
path_provider: ^2.0.11
|
||||
@@ -59,8 +59,8 @@ dev_dependencies:
|
||||
sdk: flutter
|
||||
flutter_lints: ^2.0.1
|
||||
hive_generator: ^1.1.2
|
||||
build_runner: ^2.1.7
|
||||
auto_route_generator: ^4.0.0
|
||||
build_runner: ^2.2.1
|
||||
auto_route_generator: ^5.0.2
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
FROM nginx:latest
|
||||
FROM docker.io/nginxinc/nginx-unprivileged:latest
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY nginx.conf "/etc/nginx/nginx.conf"
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
CMD nginx -g "daemon off;"
|
||||
144
nginx/nginx.conf
144
nginx/nginx.conf
@@ -1,73 +1,93 @@
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log;
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
|
||||
include /usr/share/nginx/modules/*.conf;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
# events {
|
||||
# worker_connections 1000;
|
||||
# }
|
||||
|
||||
server {
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gunzip on;
|
||||
|
||||
client_max_body_size 50000M;
|
||||
|
||||
listen 80;
|
||||
access_log off;
|
||||
|
||||
location /api {
|
||||
|
||||
# Compression
|
||||
gzip_static on;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 2;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 24k;
|
||||
proxy_buffers 64 4k;
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
rewrite /api/(.*) /$1 break;
|
||||
|
||||
proxy_pass http://immich-server:3001;
|
||||
http {
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
location / {
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp_path;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
|
||||
# Compression
|
||||
gzip_static on;
|
||||
# events {
|
||||
# worker_connections 1000;
|
||||
# }
|
||||
|
||||
server {
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 2;
|
||||
gunzip on;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 24k;
|
||||
proxy_buffers 64 4k;
|
||||
proxy_force_ranges on;
|
||||
client_max_body_size 50000M;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
listen 8080;
|
||||
access_log off;
|
||||
|
||||
proxy_pass http://immich-web:3000;
|
||||
location /api {
|
||||
|
||||
# Compression
|
||||
gzip_static on;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 2;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 24k;
|
||||
proxy_buffers 64 4k;
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
rewrite /api/(.*) /$1 break;
|
||||
|
||||
proxy_pass http://immich-server:3001;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
# Compression
|
||||
gzip_static on;
|
||||
gzip_min_length 1000;
|
||||
gzip_comp_level 2;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 24k;
|
||||
proxy_buffers 64 4k;
|
||||
proxy_force_ranges on;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_pass http://immich-web:3000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
server/.gitignore
vendored
3
server/.gitignore
vendored
@@ -38,4 +38,5 @@ lerna-debug.log*
|
||||
dist/
|
||||
upload/
|
||||
tmp/
|
||||
core
|
||||
core
|
||||
.reverse-geocoding-dump/
|
||||
|
||||
@@ -50,9 +50,14 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
where: { sharedUserId: userId },
|
||||
});
|
||||
|
||||
const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0);
|
||||
let sharedAlbumCount = 0;
|
||||
ownedAlbums.map((album) => {
|
||||
if (album.sharedUsers?.length) {
|
||||
sharedAlbumCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums);
|
||||
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
|
||||
}
|
||||
|
||||
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AlbumEntity } from '../../../../../libs/database/src/entities/album.ent
|
||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
||||
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
|
||||
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
|
||||
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
|
||||
@@ -18,6 +19,10 @@ import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
|
||||
provide: ALBUM_REPOSITORY,
|
||||
useClass: AlbumRepository,
|
||||
},
|
||||
{
|
||||
provide: ASSET_REPOSITORY,
|
||||
useClass: AssetRepository,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AlbumModule {}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||
import { IAssetRepository } from '../asset/asset-repository';
|
||||
|
||||
describe('Album service', () => {
|
||||
let sut: AlbumService;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: '1111',
|
||||
email: 'auth@test.com',
|
||||
@@ -118,7 +121,22 @@ describe('Album service', () => {
|
||||
getListByAssetId: jest.fn(),
|
||||
getCountByUserId: jest.fn(),
|
||||
};
|
||||
sut = new AlbumService(albumRepositoryMock);
|
||||
|
||||
assetRepositoryMock = {
|
||||
create: jest.fn(),
|
||||
getAllByUserId: jest.fn(),
|
||||
getAllByDeviceId: jest.fn(),
|
||||
getAssetCountByTimeBucket: jest.fn(),
|
||||
getById: jest.fn(),
|
||||
getDetectedObjectsByUserId: jest.fn(),
|
||||
getLocationsByUserId: jest.fn(),
|
||||
getSearchPropertiesByUserId: jest.fn(),
|
||||
getAssetByTimeBucket: jest.fn(),
|
||||
getAssetByChecksum: jest.fn(),
|
||||
getAssetCountByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
|
||||
});
|
||||
|
||||
it('creates album', async () => {
|
||||
|
||||
@@ -10,10 +10,14 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
|
||||
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
||||
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
|
||||
|
||||
@Injectable()
|
||||
export class AlbumService {
|
||||
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {}
|
||||
constructor(
|
||||
@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository,
|
||||
@Inject(ASSET_REPOSITORY) private _assetRepository: IAssetRepository,
|
||||
) {}
|
||||
|
||||
private async _getAlbum({
|
||||
authUser,
|
||||
@@ -54,6 +58,11 @@ export class AlbumService {
|
||||
return albums.map(mapAlbumExcludeAssetInfo);
|
||||
}
|
||||
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
|
||||
|
||||
for (const album of albums) {
|
||||
await this._checkValidThumbnail(album);
|
||||
}
|
||||
|
||||
return albums.map((album) => mapAlbumExcludeAssetInfo(album));
|
||||
}
|
||||
|
||||
@@ -123,4 +132,18 @@ export class AlbumService {
|
||||
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
|
||||
return this._albumRepository.getCountByUserId(authUser.id);
|
||||
}
|
||||
|
||||
async _checkValidThumbnail(album: AlbumEntity): Promise<AlbumEntity> {
|
||||
const assetId = album.albumThumbnailAssetId;
|
||||
if (assetId) {
|
||||
try {
|
||||
await this._assetRepository.getById(assetId);
|
||||
} catch (e) {
|
||||
album.albumThumbnailAssetId = null;
|
||||
return await this._albumRepository.updateAlbum(album, {});
|
||||
}
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export class AssetController {
|
||||
|
||||
await this.assetUploadedQueue.add(
|
||||
assetUploadedProcessorName,
|
||||
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
|
||||
{ asset: savedAsset, fileName: file.originalname },
|
||||
{ jobId: savedAsset.id },
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { IAssetRepository } from './asset-repository';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
|
||||
describe('AssetService', () => {
|
||||
let sui: AssetService;
|
||||
@@ -10,43 +14,85 @@ describe('AssetService', () => {
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd',
|
||||
id: 'user_id_1',
|
||||
email: 'auth@test.com',
|
||||
});
|
||||
|
||||
// const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
// const createAssetDto = new CreateAssetDto();
|
||||
// createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
// createAssetDto.deviceId = 'deviceId';
|
||||
// createAssetDto.assetType = AssetType.OTHER;
|
||||
// createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
// createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
// createAssetDto.isFavorite = false;
|
||||
// createAssetDto.duration = '0:00:00.000000';
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
createAssetDto.deviceId = 'deviceId';
|
||||
createAssetDto.assetType = AssetType.OTHER;
|
||||
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.isFavorite = false;
|
||||
createAssetDto.duration = '0:00:00.000000';
|
||||
|
||||
// return createAssetDto;
|
||||
// };
|
||||
// const _getAsset = () => {
|
||||
// const assetEntity = new AssetEntity();
|
||||
return createAssetDto;
|
||||
};
|
||||
|
||||
// assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
|
||||
// assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
|
||||
// assetEntity.deviceAssetId = '4967046344801';
|
||||
// assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
|
||||
// assetEntity.type = AssetType.VIDEO;
|
||||
// assetEntity.originalPath =
|
||||
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
|
||||
// assetEntity.resizePath = '';
|
||||
// assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
// assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
// assetEntity.isFavorite = false;
|
||||
// assetEntity.mimeType = 'image/jpeg';
|
||||
// assetEntity.webpPath = '';
|
||||
// assetEntity.encodedVideoPath = '';
|
||||
// assetEntity.duration = '0:00:00.000000';
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
|
||||
// return assetEntity;
|
||||
// };
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.userId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.resizePath = '';
|
||||
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.mimeType = 'image/jpeg';
|
||||
asset_1.webpPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
return asset_1;
|
||||
};
|
||||
|
||||
const _getAsset_2 = () => {
|
||||
const asset_2 = new AssetEntity();
|
||||
|
||||
asset_2.id = 'id_2';
|
||||
asset_2.userId = 'user_id_1';
|
||||
asset_2.deviceAssetId = 'device_asset_id_2';
|
||||
asset_2.deviceId = 'device_id_1';
|
||||
asset_2.type = AssetType.VIDEO;
|
||||
asset_2.originalPath = 'fake_path/asset_2.jpeg';
|
||||
asset_2.resizePath = '';
|
||||
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.isFavorite = false;
|
||||
asset_2.mimeType = 'image/jpeg';
|
||||
asset_2.webpPath = '';
|
||||
asset_2.encodedVideoPath = '';
|
||||
asset_2.duration = '0:00:00.000000';
|
||||
|
||||
return asset_2;
|
||||
};
|
||||
|
||||
const _getAssets = () => {
|
||||
return [_getAsset_1(), _getAsset_2()];
|
||||
};
|
||||
|
||||
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
|
||||
const result1 = new AssetCountByTimeBucket();
|
||||
result1.count = 2;
|
||||
result1.timeBucket = '2022-06-01T00:00:00.000Z';
|
||||
|
||||
const result2 = new AssetCountByTimeBucket();
|
||||
result1.count = 5;
|
||||
result1.timeBucket = '2022-07-01T00:00:00.000Z';
|
||||
|
||||
return [result1, result2];
|
||||
};
|
||||
|
||||
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
const result = new AssetCountByUserIdResponseDto(2, 2);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
assetRepositoryMock = {
|
||||
@@ -67,29 +113,65 @@ describe('AssetService', () => {
|
||||
});
|
||||
|
||||
// Currently failing due to calculate checksum from a file
|
||||
// it('create an asset', async () => {
|
||||
// const assetEntity = _getAsset();
|
||||
it('create an asset', async () => {
|
||||
const assetEntity = _getAsset_1();
|
||||
|
||||
// assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
|
||||
assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
|
||||
|
||||
// const originalPath =
|
||||
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
|
||||
// const mimeType = 'image/jpeg';
|
||||
// const createAssetDto = _getCreateAssetDto();
|
||||
// const result = await sui.createUserAsset(authUser, createAssetDto, originalPath, mimeType);
|
||||
const originalPath = 'fake_path/asset_1.jpeg';
|
||||
const mimeType = 'image/jpeg';
|
||||
const createAssetDto = _getCreateAssetDto();
|
||||
const result = await sui.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
originalPath,
|
||||
mimeType,
|
||||
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
|
||||
);
|
||||
|
||||
// expect(result.userId).toEqual(authUser.id);
|
||||
// expect(result.resizePath).toEqual('');
|
||||
// expect(result.webpPath).toEqual('');
|
||||
// });
|
||||
expect(result.userId).toEqual(authUser.id);
|
||||
expect(result.resizePath).toEqual('');
|
||||
expect(result.webpPath).toEqual('');
|
||||
});
|
||||
|
||||
it('get assets by device id', async () => {
|
||||
assetRepositoryMock.getAllByDeviceId.mockImplementation(() => Promise.resolve<string[]>(['4967046344801']));
|
||||
const assets = _getAssets();
|
||||
|
||||
const deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
|
||||
assetRepositoryMock.getAllByDeviceId.mockImplementation(() =>
|
||||
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
|
||||
);
|
||||
|
||||
const deviceId = 'device_id_1';
|
||||
const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
|
||||
|
||||
expect(result.length).toEqual(1);
|
||||
expect(result[0]).toEqual('4967046344801');
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
||||
});
|
||||
|
||||
it('get assets count by time bucket', async () => {
|
||||
const assetCountByTimeBucket = _getAssetCountByTimeBucket();
|
||||
|
||||
assetRepositoryMock.getAssetCountByTimeBucket.mockImplementation(() =>
|
||||
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
|
||||
);
|
||||
|
||||
const result = await sui.getAssetCountByTimeBucket(authUser, {
|
||||
timeGroup: TimeGroupEnum.Month,
|
||||
});
|
||||
|
||||
expect(result.totalCount).toEqual(assetCountByTimeBucket.reduce((a, b) => a + b.count, 0));
|
||||
expect(result.buckets.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('get asset count by user id', async () => {
|
||||
const assetCount = _getAssetCountByUserId();
|
||||
|
||||
assetRepositoryMock.getAssetCountByUserId.mockImplementation(() =>
|
||||
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
|
||||
);
|
||||
|
||||
const result = await sui.getAssetCountByUserId(authUser);
|
||||
|
||||
expect(result).toEqual(assetCount);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { timeUtils } from '@app/common/utils';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -56,6 +57,18 @@ export class AssetService {
|
||||
mimeType: string,
|
||||
checksum: Buffer,
|
||||
): Promise<AssetEntity> {
|
||||
// Check valid time.
|
||||
const createdAt = createAssetDto.createdAt;
|
||||
const modifiedAt = createAssetDto.modifiedAt;
|
||||
|
||||
if (!timeUtils.checkValidTimestamp(createdAt)) {
|
||||
createAssetDto.createdAt = await timeUtils.getTimestampFromExif(originalPath);
|
||||
}
|
||||
|
||||
if (!timeUtils.checkValidTimestamp(modifiedAt)) {
|
||||
createAssetDto.modifiedAt = await timeUtils.getTimestampFromExif(originalPath);
|
||||
}
|
||||
|
||||
const assetEntity = await this._assetRepository.create(
|
||||
createAssetDto,
|
||||
authUser.id,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Response,
|
||||
Request,
|
||||
ParseBoolPipe,
|
||||
} from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
@@ -22,7 +21,7 @@ import { AdminRolesGuard } from '../../middlewares/admin-role-guard.middleware';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { profileImageUploadOption } from '../../config/profile-image-upload.config';
|
||||
import { Response as Res, Request as Req } from 'express';
|
||||
import { Response as Res } from 'express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { UserResponseDto } from './response-dto/user-response.dto';
|
||||
import { UserCountResponseDto } from './response-dto/user-count-response.dto';
|
||||
@@ -74,8 +73,11 @@ export class UserController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Put()
|
||||
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
|
||||
return await this.userService.updateUser(updateUserDto);
|
||||
async updateUser(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) updateUserDto: UpdateUserDto,
|
||||
): Promise<UserResponseDto> {
|
||||
return await this.userService.updateUser(authUser, updateUserDto);
|
||||
}
|
||||
|
||||
@UseInterceptors(FileInterceptor('file', profileImageUploadOption))
|
||||
@@ -90,9 +92,7 @@ export class UserController {
|
||||
async createProfileImage(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@UploadedFile() fileInfo: Express.Multer.File,
|
||||
@Request() req: Req,
|
||||
): Promise<CreateProfileImageResponseDto> {
|
||||
console.log(req.body, req.file);
|
||||
return await this.userService.createProfileImage(authUser, fileInfo);
|
||||
}
|
||||
|
||||
|
||||
137
server/apps/immich/src/api-v1/user/user.service.spec.ts
Normal file
137
server/apps/immich/src/api-v1/user/user.service.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { UserEntity } from '@app/database/entities/user.entity';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { IUserRepository } from './user-repository';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
describe('UserService', () => {
|
||||
let sui: UserService;
|
||||
let userRepositoryMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
const adminAuthUser: AuthUserDto = Object.freeze({
|
||||
id: 'admin_id',
|
||||
email: 'admin@test.com',
|
||||
});
|
||||
|
||||
const immichAuthUser: AuthUserDto = Object.freeze({
|
||||
id: 'immich_id',
|
||||
email: 'immich@test.com',
|
||||
});
|
||||
|
||||
const adminUser: UserEntity = Object.freeze({
|
||||
id: 'admin_id',
|
||||
email: 'admin@test.com',
|
||||
password: 'admin_password',
|
||||
salt: 'admin_salt',
|
||||
firstName: 'admin_first_name',
|
||||
lastName: 'admin_last_name',
|
||||
isAdmin: true,
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
});
|
||||
|
||||
const immichUser: UserEntity = Object.freeze({
|
||||
id: 'immich_id',
|
||||
email: 'immich@test.com',
|
||||
password: 'immich_password',
|
||||
salt: 'immich_salt',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
});
|
||||
|
||||
const updatedImmichUser: UserEntity = Object.freeze({
|
||||
id: 'immich_id',
|
||||
email: 'immich@test.com',
|
||||
password: 'immich_password',
|
||||
salt: 'immich_salt',
|
||||
firstName: 'updated_immich_first_name',
|
||||
lastName: 'updated_immich_last_name',
|
||||
isAdmin: false,
|
||||
shouldChangePassword: true,
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
userRepositoryMock = {
|
||||
create: jest.fn(),
|
||||
createProfileImage: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
sui = new UserService(userRepositoryMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sui).toBeDefined();
|
||||
});
|
||||
|
||||
describe('Update user', () => {
|
||||
it('should update user', () => {
|
||||
const requestor = immichAuthUser;
|
||||
const userToUpdate = immichUser;
|
||||
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(immichUser));
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate));
|
||||
userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser));
|
||||
|
||||
const result = sui.updateUser(requestor, {
|
||||
id: userToUpdate.id,
|
||||
shouldChangePassword: true,
|
||||
});
|
||||
expect(result).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('user can only update its information', () => {
|
||||
const requestor = immichAuthUser;
|
||||
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(immichUser));
|
||||
|
||||
const result = sui.updateUser(requestor, {
|
||||
id: 'not_immich_auth_user_id',
|
||||
password: 'I take over your account now',
|
||||
});
|
||||
expect(result).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('admin can update any user information', async () => {
|
||||
const requestor = adminAuthUser;
|
||||
const userToUpdate = immichUser;
|
||||
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate));
|
||||
userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser));
|
||||
|
||||
const result = await sui.updateUser(requestor, {
|
||||
id: userToUpdate.id,
|
||||
shouldChangePassword: true,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toEqual(updatedImmichUser.id);
|
||||
expect(result.shouldChangePassword).toEqual(updatedImmichUser.shouldChangePassword);
|
||||
});
|
||||
|
||||
it('update user information should throw error if user not found', () => {
|
||||
const requestor = adminAuthUser;
|
||||
const userToUpdate = immichUser;
|
||||
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(adminUser));
|
||||
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(null));
|
||||
|
||||
const result = sui.updateUser(requestor, {
|
||||
id: userToUpdate.id,
|
||||
shouldChangePassword: true,
|
||||
});
|
||||
expect(result).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,19 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
|
||||
async updateUser(authUser: AuthUserDto, updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
|
||||
const requestor = await this.userRepository.get(authUser.id);
|
||||
|
||||
if (!requestor) {
|
||||
throw new NotFoundException('Requestor not found');
|
||||
}
|
||||
|
||||
if (!requestor.isAdmin) {
|
||||
if (requestor.id !== updateUserDto.id) {
|
||||
throw new BadRequestException('Unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.userRepository.get(updateUserDto.id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -88,8 +100,8 @@ export class UserService {
|
||||
|
||||
return mapUser(updatedUser);
|
||||
} catch (e) {
|
||||
Logger.error(e, 'Create new user');
|
||||
throw new InternalServerErrorException('Failed to register new user');
|
||||
Logger.error(e, 'Failed to update user info');
|
||||
throw new InternalServerErrorException('Failed to update user info');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@ import { diskStorage } from 'multer';
|
||||
import { extname, join } from 'path';
|
||||
import { Request } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
export const assetUploadOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp)$/)) {
|
||||
if (
|
||||
file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff|3gpp|nef)$/)
|
||||
) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
|
||||
@@ -19,17 +22,13 @@ export const assetUploadOption: MulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
// TODO these are currently not used. Shall we remove them?
|
||||
// const fileInfo = req.body as CreateAssetDto;
|
||||
|
||||
// const yearInfo = new Date(fileInfo.createdAt).getFullYear();
|
||||
// const monthInfo = new Date(fileInfo.createdAt).getMonth();
|
||||
|
||||
if (!req.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalUploadFolder = join(basePath, req.user.id, 'original', req.body['deviceId']);
|
||||
const sanitizedDeviceId = sanitize(String(req.body['deviceId']));
|
||||
const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId);
|
||||
|
||||
if (!existsSync(originalUploadFolder)) {
|
||||
mkdirSync(originalUploadFolder, { recursive: true });
|
||||
@@ -41,8 +40,9 @@ export const assetUploadOption: MulterOptions = {
|
||||
|
||||
filename: (req: Request, file: Express.Multer.File, cb: any) => {
|
||||
const fileNameUUID = randomUUID();
|
||||
|
||||
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`);
|
||||
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
|
||||
const sanitizedFileName = sanitize(fileName);
|
||||
cb(null, sanitizedFileName);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync } from 'fs';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Request } from 'express';
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
export const profileImageUploadOption: MulterOptions = {
|
||||
fileFilter: (req: Request, file: any, cb: any) => {
|
||||
@@ -35,8 +36,9 @@ export const profileImageUploadOption: MulterOptions = {
|
||||
return;
|
||||
}
|
||||
const userId = req.user.id;
|
||||
const fileName = `${userId}${extname(file.originalname)}`;
|
||||
|
||||
cb(null, `${userId}${extname(file.originalname)}`);
|
||||
cb(null, sanitize(String(fileName)));
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
||||
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 28,
|
||||
patch: 2,
|
||||
build: 40,
|
||||
minor: 30,
|
||||
patch: 0,
|
||||
build: 46,
|
||||
};
|
||||
|
||||
@@ -8,14 +8,16 @@ import { Queue } from 'bull';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||
import {
|
||||
exifExtractionProcessorName,
|
||||
generateWEBPThumbnailProcessorName,
|
||||
IMetadataExtractionJob,
|
||||
IVideoTranscodeJob,
|
||||
metadataExtractionQueueName,
|
||||
thumbnailGeneratorQueueName,
|
||||
videoConversionQueueName,
|
||||
generateWEBPThumbnailProcessorName,
|
||||
mp4ConversionProcessorName,
|
||||
reverseGeocodingProcessorName,
|
||||
thumbnailGeneratorQueueName,
|
||||
videoConversionQueueName,
|
||||
videoMetadataExtractionProcessorName,
|
||||
} from '@app/job';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@@ -80,11 +82,11 @@ export class ScheduleTasksService {
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_5_SECONDS)
|
||||
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||
async reverseGeocoding() {
|
||||
const isMapboxEnable = this.configService.get('ENABLE_MAPBOX');
|
||||
const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true';
|
||||
|
||||
if (isMapboxEnable) {
|
||||
if (isGeocodingEnabled) {
|
||||
const exifInfo = await this.exifRepository.find({
|
||||
where: {
|
||||
city: IsNull(),
|
||||
@@ -94,7 +96,37 @@ export class ScheduleTasksService {
|
||||
});
|
||||
|
||||
for (const exif of exifInfo) {
|
||||
await this.metadataExtractionQueue.add(reverseGeocodingProcessorName, { exif }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(
|
||||
reverseGeocodingProcessorName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
{ exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! },
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||
async extractExif() {
|
||||
const exifAssets = await this.assetRepository.find({
|
||||
where: {
|
||||
exifInfo: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
for (const asset of exifAssets) {
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
await this.metadataExtractionQueue.add(
|
||||
videoMetadataExtractionProcessorName,
|
||||
{ asset, fileName: asset.id },
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
} else {
|
||||
await this.metadataExtractionQueue.add(
|
||||
exifExtractionProcessorName,
|
||||
{ asset, fileName: asset.id },
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@app/job/constants/queue-name.constant';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
|
||||
import { MicroservicesService } from './microservices.service';
|
||||
@@ -40,42 +40,48 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||
},
|
||||
}),
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: thumbnailGeneratorQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
BullModule.registerQueue(
|
||||
{
|
||||
name: thumbnailGeneratorQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: assetUploadedQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: assetUploadedQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: metadataExtractionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: metadataExtractionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: videoConversionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: videoConversionQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: generateChecksumQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
{
|
||||
name: generateChecksumQueueName,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
CommunicationModule,
|
||||
],
|
||||
controllers: [],
|
||||
@@ -86,6 +92,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
|
||||
MetadataExtractionProcessor,
|
||||
VideoTranscodeProcessor,
|
||||
GenerateChecksumProcessor,
|
||||
ConfigService,
|
||||
],
|
||||
exports: [],
|
||||
})
|
||||
|
||||
@@ -42,13 +42,18 @@ export class AssetUploadedProcessor {
|
||||
*/
|
||||
@Process(assetUploadedProcessorName)
|
||||
async processUploadedVideo(job: Job<IAssetUploadedJob>) {
|
||||
const { asset, fileName, fileSize } = job.data;
|
||||
const { asset, fileName } = job.data;
|
||||
|
||||
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
|
||||
|
||||
// Video Conversion
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
|
||||
await this.metadataExtractionQueue.add(
|
||||
videoMetadataExtractionProcessorName,
|
||||
{ asset, fileName },
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
} else {
|
||||
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
||||
await this.metadataExtractionQueue.add(
|
||||
@@ -56,19 +61,9 @@ export class AssetUploadedProcessor {
|
||||
{
|
||||
asset,
|
||||
fileName,
|
||||
fileSize,
|
||||
},
|
||||
{ jobId: randomUUID() },
|
||||
);
|
||||
}
|
||||
|
||||
// Extract video duration if uploaded from the web & CLI
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
await this.metadataExtractionQueue.add(
|
||||
videoMetadataExtractionProcessorName,
|
||||
{ asset, fileName, fileSize },
|
||||
{ jobId: randomUUID() }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
|
||||
@@ -12,10 +13,9 @@ import {
|
||||
reverseGeocodingProcessorName,
|
||||
IReverseGeocodingProcessor,
|
||||
} from '@app/job';
|
||||
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
|
||||
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import axios from 'axios';
|
||||
import { Job } from 'bull';
|
||||
@@ -24,10 +24,65 @@ import ffmpeg from 'fluent-ffmpeg';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import geocoder, { InitOptions } from 'local-reverse-geocoder';
|
||||
import { getName } from 'i18n-iso-countries';
|
||||
import { find } from 'geo-tz';
|
||||
import * as luxon from 'luxon';
|
||||
import fs from 'node:fs';
|
||||
|
||||
function geocoderInit(init: InitOptions) {
|
||||
return new Promise<void>(function (resolve) {
|
||||
geocoder.init(init, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function geocoderLookup(points: { latitude: number; longitude: number }[]) {
|
||||
return new Promise<GeoData>(function (resolve) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
geocoder.lookUp(points, 1, (err, addresses) => {
|
||||
resolve(addresses[0][0]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const geocodingPrecisionLevels = ['cities15000', 'cities5000', 'cities1000', 'cities500'];
|
||||
|
||||
export interface AdminCode {
|
||||
name: string;
|
||||
asciiName: string;
|
||||
geoNameId: string;
|
||||
}
|
||||
|
||||
export interface GeoData {
|
||||
geoNameId: string;
|
||||
name: string;
|
||||
asciiName: string;
|
||||
alternateNames: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
featureClass: string;
|
||||
featureCode: string;
|
||||
countryCode: string;
|
||||
cc2?: any;
|
||||
admin1Code?: AdminCode;
|
||||
admin2Code?: AdminCode;
|
||||
admin3Code?: any;
|
||||
admin4Code?: any;
|
||||
population: string;
|
||||
elevation: string;
|
||||
dem: string;
|
||||
timezone: string;
|
||||
modificationDate: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
@Processor(metadataExtractionQueueName)
|
||||
export class MetadataExtractionProcessor {
|
||||
private geocodingClient?: GeocodeService;
|
||||
private isGeocodeInitialized = false;
|
||||
private logLevel: ImmichLogLevel;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
@@ -38,18 +93,55 @@ export class MetadataExtractionProcessor {
|
||||
|
||||
@InjectRepository(SmartInfoEntity)
|
||||
private smartInfoRepository: Repository<SmartInfoEntity>,
|
||||
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
|
||||
this.geocodingClient = mapboxGeocoding({
|
||||
accessToken: process.env.MAPBOX_KEY,
|
||||
if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
|
||||
Logger.log('Initialising Reverse Geocoding');
|
||||
geocoderInit({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
citiesFileOverride: geocodingPrecisionLevels[configService.get('REVERSE_GEOCODING_PRECISION')],
|
||||
load: {
|
||||
admin1: true,
|
||||
admin2: true,
|
||||
admin3And4: false,
|
||||
alternateNames: false,
|
||||
},
|
||||
countries: [],
|
||||
dumpDirectory: configService.get('REVERSE_GEOCODING_DUMP_DIRECTORY') || (process.cwd() + '/.reverse-geocoding-dump/'),
|
||||
}).then(() => {
|
||||
this.isGeocodeInitialized = true;
|
||||
Logger.log('Reverse Geocoding Initialised');
|
||||
});
|
||||
}
|
||||
|
||||
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
|
||||
}
|
||||
|
||||
private async reverseGeocodeExif(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): Promise<{ country: string; state: string; city: string }> {
|
||||
const geoCodeInfo = await geocoderLookup([{ latitude, longitude }]);
|
||||
|
||||
const country = getName(geoCodeInfo.countryCode, 'en');
|
||||
const city = geoCodeInfo.name;
|
||||
|
||||
let state = '';
|
||||
if (geoCodeInfo.admin2Code?.name) state += geoCodeInfo.admin2Code.name;
|
||||
if (geoCodeInfo.admin1Code?.name) {
|
||||
if (geoCodeInfo.admin2Code?.name) state += ', ';
|
||||
state += geoCodeInfo.admin1Code.name;
|
||||
}
|
||||
|
||||
return { country, state, city };
|
||||
}
|
||||
|
||||
@Process(exifExtractionProcessorName)
|
||||
async extractExifInfo(job: Job<IExifExtractionProcessor>) {
|
||||
try {
|
||||
const { asset, fileName, fileSize }: { asset: AssetEntity; fileName: string; fileSize: number } = job.data;
|
||||
const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
|
||||
const exifData = await exifr.parse(asset.originalPath, {
|
||||
tiff: true,
|
||||
ifd0: true as any,
|
||||
@@ -68,6 +160,11 @@ export class MetadataExtractionProcessor {
|
||||
throw new Error(`can not parse exif data from file ${asset.originalPath}`);
|
||||
}
|
||||
|
||||
const createdAt = new Date(exifData.DateTimeOriginal || exifData.CreateDate || new Date(asset.createdAt));
|
||||
|
||||
const fileStats = fs.statSync(asset.originalPath);
|
||||
const fileSizeInBytes = fileStats.size;
|
||||
|
||||
const newExif = new ExifEntity();
|
||||
newExif.assetId = asset.id;
|
||||
newExif.make = exifData['Make'] || null;
|
||||
@@ -75,9 +172,9 @@ export class MetadataExtractionProcessor {
|
||||
newExif.imageName = path.parse(fileName).name || null;
|
||||
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
|
||||
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
|
||||
newExif.fileSizeInByte = fileSize || null;
|
||||
newExif.fileSizeInByte = fileSizeInBytes || null;
|
||||
newExif.orientation = exifData['Orientation'] || null;
|
||||
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;
|
||||
newExif.dateTimeOriginal = createdAt;
|
||||
newExif.modifyDate = exifData['ModifyDate'] || null;
|
||||
newExif.lensModel = exifData['LensModel'] || null;
|
||||
newExif.fNumber = exifData['FNumber'] || null;
|
||||
@@ -87,39 +184,60 @@ export class MetadataExtractionProcessor {
|
||||
newExif.latitude = exifData['latitude'] || null;
|
||||
newExif.longitude = exifData['longitude'] || null;
|
||||
|
||||
// Reverse GeoCoding
|
||||
if (this.geocodingClient && exifData['longitude'] && exifData['latitude']) {
|
||||
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
||||
.reverseGeocode({
|
||||
query: [exifData['longitude'], exifData['latitude']],
|
||||
types: ['country', 'region', 'place'],
|
||||
})
|
||||
.send();
|
||||
/**
|
||||
* Correctly store UTC time based on timezone
|
||||
* The timestamp being extracted from EXIF is based on the timezone
|
||||
* of the container. We need to correct it to UTC time based on the
|
||||
* timezone of the location.
|
||||
*
|
||||
* The timezone of the location can be exracted from the lat/lon
|
||||
* GPS coordinates.
|
||||
*
|
||||
* Any assets that doesn't have this information will used the
|
||||
* createdAt timestamp of the asset instead.
|
||||
*
|
||||
* The updated/corrected timestamp will be used to update the
|
||||
* createdAt timestamp in the asset table. So that the information
|
||||
* is consistent across the database.
|
||||
* */
|
||||
if (newExif.longitude && newExif.latitude) {
|
||||
const tz = find(newExif.latitude, newExif.longitude)[0];
|
||||
const localTimeWithTimezone = createdAt.toISOString();
|
||||
|
||||
const res: [] = geoCodeInfo.body['features'];
|
||||
|
||||
let city = '';
|
||||
let state = '';
|
||||
let country = '';
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
if (localTimeWithTimezone.length == 24) {
|
||||
// Remove the last character
|
||||
const localTimeWithoutTimezone = localTimeWithTimezone.slice(0, -1);
|
||||
const correctUTCTime = luxon.DateTime.fromISO(localTimeWithoutTimezone, { zone: tz }).toUTC().toISO();
|
||||
newExif.dateTimeOriginal = new Date(correctUTCTime);
|
||||
await this.assetRepository.save({
|
||||
id: asset.id,
|
||||
createdAt: correctUTCTime,
|
||||
});
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
}
|
||||
|
||||
newExif.city = city || null;
|
||||
newExif.state = state || null;
|
||||
newExif.country = country || null;
|
||||
} else {
|
||||
await this.assetRepository.save({
|
||||
id: asset.id,
|
||||
createdAt: createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich metadata
|
||||
/**
|
||||
* Reverse Geocoding
|
||||
*
|
||||
* Get the city, state or region name of the asset
|
||||
* based on lat/lon GPS coordinates.
|
||||
*/
|
||||
if (this.isGeocodeInitialized && newExif.latitude && newExif.longitude) {
|
||||
const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
|
||||
newExif.country = country;
|
||||
newExif.state = state;
|
||||
newExif.city = city;
|
||||
}
|
||||
|
||||
/**
|
||||
* IF the EXIF doesn't contain the width and height of the image,
|
||||
* We will use Sharpjs to get the information.
|
||||
*/
|
||||
if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
|
||||
const metadata = await sharp(asset.originalPath).metadata();
|
||||
|
||||
@@ -139,40 +257,19 @@ export class MetadataExtractionProcessor {
|
||||
await this.exifRepository.save(newExif);
|
||||
} catch (e) {
|
||||
Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif');
|
||||
|
||||
if (this.logLevel === ImmichLogLevel.VERBOSE) {
|
||||
console.trace('Error extracting EXIF', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Process({ name: reverseGeocodingProcessorName })
|
||||
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
|
||||
const { exif } = job.data;
|
||||
|
||||
if (this.geocodingClient) {
|
||||
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
||||
.reverseGeocode({
|
||||
query: [Number(exif.longitude), Number(exif.latitude)],
|
||||
types: ['country', 'region', 'place'],
|
||||
})
|
||||
.send();
|
||||
|
||||
const res: [] = geoCodeInfo.body['features'];
|
||||
|
||||
let city = '';
|
||||
let state = '';
|
||||
let country = '';
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
}
|
||||
|
||||
await this.exifRepository.update({ id: exif.id }, { city, state, country });
|
||||
if (this.isGeocodeInitialized) {
|
||||
const { latitude, longitude } = job.data;
|
||||
const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude);
|
||||
await this.exifRepository.update({ id: job.data.exifId }, { city, state, country });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,35 +381,11 @@ export class MetadataExtractionProcessor {
|
||||
}
|
||||
|
||||
// Reverse GeoCoding
|
||||
if (this.geocodingClient && newExif.longitude && newExif.latitude) {
|
||||
const geoCodeInfo: MapiResponse = await this.geocodingClient
|
||||
.reverseGeocode({
|
||||
query: [newExif.longitude, newExif.latitude],
|
||||
types: ['country', 'region', 'place'],
|
||||
})
|
||||
.send();
|
||||
|
||||
const res: [] = geoCodeInfo.body['features'];
|
||||
|
||||
let city = '';
|
||||
let state = '';
|
||||
let country = '';
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) {
|
||||
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) {
|
||||
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text'];
|
||||
}
|
||||
|
||||
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]) {
|
||||
country = res.filter((geoInfo) => geoInfo['place_type'][0] == 'country')[0]['text'];
|
||||
}
|
||||
|
||||
newExif.city = city || null;
|
||||
newExif.state = state || null;
|
||||
newExif.country = country || null;
|
||||
if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
|
||||
const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
|
||||
newExif.country = country;
|
||||
newExif.state = state;
|
||||
newExif.city = city;
|
||||
}
|
||||
|
||||
for (const stream of data.streams) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { APP_UPLOAD_LOCATION } from '@app/common';
|
||||
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
|
||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
||||
import {
|
||||
WebpGeneratorProcessor,
|
||||
@@ -11,18 +13,23 @@ import {
|
||||
} from '@app/job';
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
|
||||
import { Job, Queue } from 'bull';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import sharp from 'sharp';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
|
||||
import { join } from 'path';
|
||||
import { CommunicationGateway } from 'apps/immich/src/api-v1/communication/communication.gateway';
|
||||
|
||||
@Processor(thumbnailGeneratorQueueName)
|
||||
export class ThumbnailGeneratorProcessor {
|
||||
private logLevel: ImmichLogLevel;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
@@ -34,13 +41,20 @@ export class ThumbnailGeneratorProcessor {
|
||||
|
||||
@InjectQueue(metadataExtractionQueueName)
|
||||
private metadataExtractionQueue: Queue,
|
||||
) {}
|
||||
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
|
||||
}
|
||||
|
||||
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
|
||||
async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) {
|
||||
const { asset } = job.data;
|
||||
const basePath = APP_UPLOAD_LOCATION;
|
||||
|
||||
const resizePath = `upload/${asset.userId}/thumb/${asset.deviceId}/`;
|
||||
const { asset } = job.data;
|
||||
const sanitizedDeviceId = sanitize(String(asset.deviceId));
|
||||
|
||||
const resizePath = join(basePath, asset.userId, 'thumb', sanitizedDeviceId);
|
||||
|
||||
if (!existsSync(resizePath)) {
|
||||
mkdirSync(resizePath, { recursive: true });
|
||||
@@ -51,8 +65,16 @@ export class ThumbnailGeneratorProcessor {
|
||||
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
||||
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||
try {
|
||||
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
|
||||
} catch (error) {
|
||||
Logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id);
|
||||
|
||||
if (this.logLevel == ImmichLogLevel.VERBOSE) {
|
||||
console.trace('Failed to generate jpeg thumbnail for asset', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update resize path to send to generate webp queue
|
||||
asset.resizePath = jpegThumbnailPath;
|
||||
@@ -105,7 +127,15 @@ export class ThumbnailGeneratorProcessor {
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
|
||||
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
||||
try {
|
||||
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
|
||||
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
|
||||
} catch (error) {
|
||||
Logger.error('Failed to generate webp thumbnail for asset: ' + asset.id);
|
||||
|
||||
if (this.logLevel == ImmichLogLevel.VERBOSE) {
|
||||
console.trace('Failed to generate webp thumbnail for asset', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,8 @@ export const immichAppConfig: ConfigModuleOptions = {
|
||||
DB_PASSWORD: Joi.string().required(),
|
||||
DB_DATABASE_NAME: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false),
|
||||
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', {
|
||||
is: false,
|
||||
then: Joi.string().optional().allow(null, ''),
|
||||
otherwise: Joi.string().required(),
|
||||
}),
|
||||
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
|
||||
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
|
||||
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
|
||||
}),
|
||||
};
|
||||
|
||||
4
server/libs/common/src/constants/log-level.constant.ts
Normal file
4
server/libs/common/src/constants/log-level.constant.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum ImmichLogLevel {
|
||||
SIMPLE = 'simple',
|
||||
VERBOSE = 'verbose',
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './config';
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
|
||||
1
server/libs/common/src/utils/index.ts
Normal file
1
server/libs/common/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './time-utils';
|
||||
37
server/libs/common/src/utils/time-utils.spec.ts
Normal file
37
server/libs/common/src/utils/time-utils.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// create unit test for time utils
|
||||
|
||||
import { timeUtils } from './time-utils';
|
||||
|
||||
describe('Time Utilities', () => {
|
||||
describe('checkValidTimestamp', () => {
|
||||
it('check for year 0000', () => {
|
||||
const result = timeUtils.checkValidTimestamp('0000-00-00T00:00:00.000Z');
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('check for 6-digits year with plus sign', () => {
|
||||
const result = timeUtils.checkValidTimestamp('+12345-00-00T00:00:00.000Z');
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('check for 6-digits year with negative sign', () => {
|
||||
const result = timeUtils.checkValidTimestamp('-12345-00-00T00:00:00.000Z');
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('check for current date', () => {
|
||||
const result = timeUtils.checkValidTimestamp(new Date().toISOString());
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('check for year before 1583', () => {
|
||||
const result = timeUtils.checkValidTimestamp('1582-12-31T23:59:59.999Z');
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('check for year after 9999', () => {
|
||||
const result = timeUtils.checkValidTimestamp('10000-00-00T00:00:00.000Z');
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
48
server/libs/common/src/utils/time-utils.ts
Normal file
48
server/libs/common/src/utils/time-utils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import exifr from 'exifr';
|
||||
|
||||
function createTimeUtils() {
|
||||
const checkValidTimestamp = (timestamp: string): boolean => {
|
||||
const parsedTimestamp = Date.parse(timestamp);
|
||||
|
||||
if (isNaN(parsedTimestamp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const date = new Date(parsedTimestamp);
|
||||
|
||||
if (date.getFullYear() < 1583 || date.getFullYear() > 9999) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return date.getFullYear() > 0;
|
||||
};
|
||||
|
||||
const getTimestampFromExif = async (originalPath: string): Promise<string> => {
|
||||
try {
|
||||
const exifData = await exifr.parse(originalPath, {
|
||||
tiff: true,
|
||||
ifd0: true as any,
|
||||
ifd1: true,
|
||||
exif: true,
|
||||
gps: true,
|
||||
interop: true,
|
||||
xmp: true,
|
||||
icc: true,
|
||||
iptc: true,
|
||||
jfif: true,
|
||||
ihdr: true,
|
||||
});
|
||||
|
||||
if (exifData && exifData['DateTimeOriginal']) {
|
||||
return exifData['DateTimeOriginal'];
|
||||
} else {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
} catch (error) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
};
|
||||
return { checkValidTimestamp, getTimestampFromExif };
|
||||
}
|
||||
|
||||
export const timeUtils = createTimeUtils();
|
||||
@@ -10,9 +10,4 @@ export interface IAssetUploadedJob {
|
||||
* Original file name
|
||||
*/
|
||||
fileName: string;
|
||||
|
||||
/**
|
||||
* File size in byte
|
||||
*/
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { ExifEntity } from '@app/database/entities/exif.entity';
|
||||
|
||||
export interface IExifExtractionProcessor {
|
||||
/**
|
||||
@@ -11,11 +10,6 @@ export interface IExifExtractionProcessor {
|
||||
* Original file name
|
||||
*/
|
||||
fileName: string;
|
||||
|
||||
/**
|
||||
* File size in byte
|
||||
*/
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface IVideoLengthExtractionProcessor {
|
||||
@@ -28,18 +22,12 @@ export interface IVideoLengthExtractionProcessor {
|
||||
* Original file name
|
||||
*/
|
||||
fileName: string;
|
||||
|
||||
/**
|
||||
* File size in byte
|
||||
*/
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface IReverseGeocodingProcessor {
|
||||
/**
|
||||
* The Asset entity that was saved in the database
|
||||
*/
|
||||
exif: ExifEntity;
|
||||
exifId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export type IMetadataExtractionJob =
|
||||
|
||||
3090
server/package-lock.json
generated
3090
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@
|
||||
"api:generate": "npm run api:typescript && npm run api:dart"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-sdk": "^0.13.3",
|
||||
"@nestjs/bull": "^0.5.5",
|
||||
"@nestjs/common": "^8.4.7",
|
||||
"@nestjs/config": "^2.1.0",
|
||||
@@ -37,7 +36,6 @@
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/passport": "^8.2.2",
|
||||
"@nestjs/platform-express": "^8.4.7",
|
||||
"@nestjs/platform-fastify": "^8.4.7",
|
||||
"@nestjs/platform-socket.io": "^8.4.7",
|
||||
"@nestjs/schedule": "^2.0.1",
|
||||
"@nestjs/swagger": "^5.2.1",
|
||||
@@ -54,15 +52,20 @@
|
||||
"dotenv": "^14.2.0",
|
||||
"exifr": "^7.1.3",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^7.0.2",
|
||||
"i18n-iso-countries": "^7.5.0",
|
||||
"joi": "^17.5.0",
|
||||
"local-reverse-geocoder": "^0.12.2",
|
||||
"lodash": "^4.17.21",
|
||||
"passport": "^0.5.2",
|
||||
"luxon": "^3.0.3",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"pg": "^8.7.1",
|
||||
"redis": "^3.1.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^7.2.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sharp": "^0.28.0",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"swagger-ui-express": "^4.4.0",
|
||||
@@ -83,7 +86,6 @@
|
||||
"@types/imagemin": "^8.0.0",
|
||||
"@types/jest": "27.0.2",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/mapbox__mapbox-sdk": "^0.13.4",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
@@ -129,6 +131,7 @@
|
||||
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
|
||||
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
|
||||
"@app/database/config": "<rootDir>/libs/database/src/config",
|
||||
"@app/common": "<rootDir>/libs/common/src",
|
||||
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user