Compare commits
59 Commits
v1.28.3_41
...
v1.30.2_48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536fda04f2 | ||
|
|
2094204877 | ||
|
|
ab375cca1a | ||
|
|
479f706f8a | ||
|
|
4342285507 | ||
|
|
8bb656cb17 | ||
|
|
3f1f835df3 | ||
|
|
87ca031335 | ||
|
|
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 |
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}}"
|
||||
33
README.md
33
README.md
@@ -23,12 +23,30 @@
|
||||
<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)
|
||||
- [App Beta Invitation links](#App-Beta-release-channel)
|
||||
- [Development](#development)
|
||||
- [Support](#support)
|
||||
- [Known Issues](#known-issues)
|
||||
@@ -146,7 +164,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,11 +202,15 @@ 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.*
|
||||
|
||||
# App Beta release channel
|
||||
|
||||
You can opt-in to join app beta release channel by following the links below:
|
||||
* Android: Invitation link from [web](https://play.google.com/store/apps/details?id=app.alextran.immich) or from [mobile](https://play.google.com/store/apps/details?id=app.alextran.immich)
|
||||
* iOS: [TestFlight invitation link](https://testflight.apple.com/join/1vYsAa8P)
|
||||
<br/>
|
||||
|
||||
# Development
|
||||
@@ -236,7 +257,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 +265,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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -16,12 +16,17 @@
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
desc "Build Android"
|
||||
lane :build do
|
||||
desc "Build Android and Release Testing"
|
||||
lane :beta do
|
||||
gradle(
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 47,
|
||||
"android.injected.version.name" => "1.30.2",
|
||||
}
|
||||
)
|
||||
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', track: 'beta')
|
||||
end
|
||||
|
||||
desc "Build and Release Android"
|
||||
@@ -30,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 41,
|
||||
"android.injected.version.name" => "1.28.3",
|
||||
"android.injected.version.code" => 48,
|
||||
"android.injected.version.name" => "1.30.2",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
|
||||
|
||||
## Android
|
||||
|
||||
### android build
|
||||
### android beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android build
|
||||
[bundle exec] fastlane android beta
|
||||
```
|
||||
|
||||
Build Android
|
||||
Build Android and Release Testing
|
||||
|
||||
### android release
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
* Improve scroll thumb date info
|
||||
@@ -0,0 +1 @@
|
||||
* Fixed parsing date error prevent timeline to be loaded.
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="58.722434">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.768014">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
|
||||
|
||||
</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 = 55;
|
||||
CURRENT_PROJECT_VERSION = 62;
|
||||
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 = 55;
|
||||
CURRENT_PROJECT_VERSION = 62;
|
||||
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 = 55;
|
||||
CURRENT_PROJECT_VERSION = 62;
|
||||
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.28.0</string>
|
||||
<string>1.30.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>55</string>
|
||||
<string>62</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.3"
|
||||
version_number: "1.30.2"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.594905">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.78333">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.207648">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.947588">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.391989">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.505399">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="77.835137">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="80.954627">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="70.775758">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.295965">
|
||||
|
||||
</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 }
|
||||
@@ -59,24 +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,
|
||||
) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"Authorization": widget.authToken},
|
||||
cacheKey: cacheKey,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (newStatus != _RemoteImageStatus.full) {
|
||||
_fireStartLoadingEvent();
|
||||
} else {
|
||||
_fireFinishedLoadingEvent();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = newStatus;
|
||||
_imageProvider = provider;
|
||||
@@ -113,7 +95,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
thumbnailProvider = _authorizedImageProvider(
|
||||
widget.thumbnailUrl,
|
||||
widget.cacheKey,
|
||||
widget.thumbnailCacheManager,
|
||||
);
|
||||
_imageProvider = thumbnailProvider;
|
||||
|
||||
@@ -130,7 +111,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
previewProvider = _authorizedImageProvider(
|
||||
widget.previewUrl!,
|
||||
"${widget.cacheKey}_previewStage",
|
||||
widget.previewCacheManager,
|
||||
);
|
||||
previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
@@ -142,7 +122,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
fullProvider = _authorizedImageProvider(
|
||||
widget.imageUrl,
|
||||
"${widget.cacheKey}_fullStage",
|
||||
widget.fullCacheManager,
|
||||
);
|
||||
fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
@@ -153,21 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_loadImages();
|
||||
super.initState();
|
||||
_loadImages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() async {
|
||||
super.dispose();
|
||||
await thumbnailProvider.evict();
|
||||
await fullProvider.evict();
|
||||
|
||||
if (widget.previewUrl != null) {
|
||||
if (_status == _RemoteImageStatus.full) {
|
||||
await fullProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.preview) {
|
||||
await previewProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.thumbnail) {
|
||||
await thumbnailProvider.evict();
|
||||
}
|
||||
|
||||
_imageProvider.evict();
|
||||
await _imageProvider.evict();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,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);
|
||||
|
||||
@@ -194,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,95 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.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/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 DateTime date;
|
||||
final List<AssetResponseDto>? relatedAssetList;
|
||||
|
||||
RenderAssetGridElement(
|
||||
this.type, {
|
||||
this.assetRow,
|
||||
this.title,
|
||||
required this.date,
|
||||
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) {
|
||||
try {
|
||||
final date = DateTime.parse(groupName);
|
||||
|
||||
if (lastDate == null || lastDate!.month != date.month) {
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.monthTitle,
|
||||
title: groupName,
|
||||
date: date,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add group title
|
||||
elements.add(
|
||||
RenderAssetGridElement(
|
||||
RenderAssetGridElementType.dayTitle,
|
||||
title: groupName,
|
||||
date: date,
|
||||
relatedAssetList: assets,
|
||||
),
|
||||
);
|
||||
|
||||
// Add rows
|
||||
int cursor = 0;
|
||||
while (cursor < assets.length) {
|
||||
int rowElements = min(assets.length - cursor, assetsPerRow);
|
||||
|
||||
final rowElement = RenderAssetGridElement(
|
||||
RenderAssetGridElementType.assetRow,
|
||||
date: date,
|
||||
assetRow: RenderAssetGridRow(
|
||||
assets.sublist(cursor, cursor + rowElements),
|
||||
),
|
||||
);
|
||||
|
||||
elements.add(rowElement);
|
||||
cursor += rowElements;
|
||||
}
|
||||
|
||||
lastDate = date;
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
});
|
||||
|
||||
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,535 @@
|
||||
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,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
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) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
167
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
167
mobile/lib/modules/home/ui/asset_list_v2/immich_asset_grid.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
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) {
|
||||
final date = renderList[pos].date;
|
||||
return Text(DateFormat.yMMMd().format(date),
|
||||
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,
|
||||
labelConstraints: const BoxConstraints(maxHeight: 28),
|
||||
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:
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -1334,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:
|
||||
@@ -1343,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.3+41
|
||||
version: 1.30.2+48
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -11,16 +11,16 @@ 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"
|
||||
@@ -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/
|
||||
|
||||
@@ -29,4 +29,6 @@ COPY --from=builder /usr/src/app/dist ./dist
|
||||
|
||||
RUN npm prune --production
|
||||
|
||||
VOLUME /usr/src/app/upload
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
@@ -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: 3,
|
||||
build: 41,
|
||||
minor: 30,
|
||||
patch: 2,
|
||||
build: 48,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
@@ -48,11 +62,19 @@ export class ThumbnailGeneratorProcessor {
|
||||
|
||||
const temp = asset.originalPath.split('/');
|
||||
const originalFilename = temp[temp.length - 1].split('.')[0];
|
||||
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
|
||||
const jpegThumbnailPath = join(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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user