Compare commits

...

30 Commits

Author SHA1 Message Date
Alex Tran
3581cf7305 Pump server version 2022-09-28 05:53:50 -05:00
Zack Pollard
c33775b944 feat(server): missing exif extract nightly task (#754)
* fix: nightly reverse geocoding task checking for mapbox

* refactor: remove file size from image processor and queue data

* feat: add missing exif nightly job

* Remove filesize requirement in assetUploadedProcessorName queue insertion

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-28 05:41:50 -05:00
Alex
b0cd2522e0 feat(server): support .NEF file (#746) 2022-09-23 19:09:45 -05:00
Alex
c3979f6e31 fix(machine-learning) Remove unsused database config (#745) 2022-09-23 19:00:47 -05:00
Alex
103df4d9f3 fix(web) navigating forward button get in the way of video control bar (#744)
* fix(web) navigating forward button get in the way of video control bar

* Remove unsued style
2022-09-23 18:22:06 -05:00
Zack Pollard
040e02cfc5 fix(server): handle missing reverse geocoding admin zones (#742) 2022-09-23 10:14:42 -05:00
Zack Pollard
f377b64065 feat(server) Remove mapbox and use local reverse geocoding (#738)
* feat: local reverse geocoding implementation, removes mapbox

* Disable non-null tslintrule

* Disable non-null tslintrule

* Remove tsignore

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-22 21:50:05 -05:00
Alex
e5459b68ff fix(server,web,mobile): Incorrectly record and show timestamp and time zone of the asset (#706)
Implemented a mechanism to extract the correct time zone from the GPS coordinate if presented in the file's EXIF, and to convert the timestamp to the correct UTC time so that the time will show correctly based on the mobile/web local time zone.
2022-09-22 15:58:17 -05:00
Alex Tran
fc194021a4 Pump server version 2022-09-22 11:38:50 -05:00
bo0tzz
39f8ca3bf1 Only run scheduled geocoding task once per day (#730) 2022-09-21 07:17:59 -05:00
Alex
7a807f7216 Update README.md 2022-09-19 16:04:22 -05:00
Alex
bedfb51b1c Add demo URL 2022-09-19 16:00:51 -05:00
Alex
b2afb95c19 Create codeql-analysis.yml 2022-09-19 14:03:49 -05:00
Alex
10239161fd fix(mobile): app crash when there is no object detection result on search page (#725)
* fix(mobile): app crash when there is no data for object detection

* Up version for release

* Up version for release
2022-09-19 11:03:51 -05:00
Alex
242f10952d fix(server): query only image (#724) 2022-09-19 10:48:44 -05:00
Alex Tran
e997bd371b Up server version 2022-09-18 21:44:55 -05:00
Alex
400167f4ef fix(server): sanitization error that crash the server (#721) 2022-09-18 21:44:13 -05:00
Alex
572f6d833d Up mobile version and update deprecated api 2022-09-18 16:11:30 -05:00
Alex
2e06be5155 Up mobile version and update deprecated api 2022-09-18 16:11:24 -05:00
Alex Tran
62121470a8 Up server version 2022-09-18 15:37:10 -05:00
Alex
e3ccc3ee6b feat(server): sanitized path for asset creation process to avoid security risk (#717)
* feat(server): sanitized path for asset creation process to avoid security risk

* Sanitize resize path
2022-09-18 15:16:53 -05:00
Alex
ece94f6bdc fix(server): correct user permission to update user info (#716) 2022-09-18 09:27:06 -05:00
Jamie Slome
03fc0703c0 Create SECURITY.md (#712) 2022-09-17 13:07:12 -05:00
Alex
0d13b25f56 feat(web): Update to latest version of SvelteKit (#705) 2022-09-16 23:13:22 -05:00
Alex
75c2067836 feat(web) Remove fetching fonts from GoogleFonts (#703) 2022-09-16 17:23:31 -05:00
Alex
824da6a07b Up server version 2022-09-16 16:55:04 -05:00
Alex
2c2ea24dc4 test(web) Add tests for asset repository (#680)
* Added back tests for asset repository

* Added more tests

* Added asset count test
2022-09-16 16:47:45 -05:00
Alex
47b73a5b64 fix(mobile): Fixed iOS 16 overflow cache and memory leaked in gallery viewer. (#700) 2022-09-16 16:46:23 -05:00
bo0tzz
6b3f8e548d Merge pull request #699 from JaCoB1123/patch-1
Fix spelling of Proxmox in Readme
2022-09-15 23:07:00 +02:00
Jan Bader
0ea483f901 Fix spelling of Proxmox in Readme 2022-09-15 23:05:15 +02:00
76 changed files with 8161 additions and 6165 deletions

74
.github/workflows/codeql-analysis.yml vendored Normal file
View 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}}"

View File

@@ -23,12 +23,29 @@
<br/> <br/>
</p> </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 ## Content
- [Features](#features) - [Features](#features)
- [Screenshots](#screenshots) - [Screenshots](#screenshots)
- [Installation](#installation) - [Installation](#installation)
- [Update](#update) - [Update](#update)
- [Mobile App](#-mobile-app) - [Mobile App](#mobile-app)
- [Development](#development) - [Development](#development)
- [Support](#support) - [Support](#support)
- [Known Issues](#known-issues) - [Known Issues](#known-issues)
@@ -237,7 +254,7 @@ Cheers! 🎉
## TensorFlow Build Issue ## 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`: 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`:
@@ -245,7 +262,7 @@ TensorFlow doesn't run with older CPU architecture, it requires a CPU with AVX a
more /proc/cpuinfo | grep flags 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. You need to change the CPU type from `kvm64` to `host` under VMs hardware tab.

5
SECURITY.md Normal file
View File

@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `alex.tran1502@gmail.com`

View File

@@ -10,9 +10,6 @@ DB_DATABASE_NAME=immich
# Optional Database settings: # Optional Database settings:
# DB_PORT=5432 # DB_PORT=5432
################################################################################### ###################################################################################
# Redis # Redis
################################################################################### ###################################################################################
@@ -25,41 +22,39 @@ REDIS_HOSTNAME=immich_redis
# REDIS_PASSWORD= # REDIS_PASSWORD=
# REDIS_SOCKET= # REDIS_SOCKET=
################################################################################### ###################################################################################
# Upload File Config # Upload File Config
################################################################################### ###################################################################################
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
################################################################################### ###################################################################################
# Log message level - [simple|verbose] # Log message level - [simple|verbose]
################################################################################### ###################################################################################
LOG_LEVEL=simple LOG_LEVEL=simple
################################################################################### ###################################################################################
# JWT SECRET # JWT SECRET
################################################################################### ###################################################################################
JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess
################################################################################### ###################################################################################
# MAPBOX # Reverse Geocoding
#################################################################################### ####################################################################################
# ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY # DISABLE_REVERSE_GEOCODING=false
ENABLE_MAPBOX=false
MAPBOX_KEY=
# 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 # WEB - Optional
@@ -69,11 +64,3 @@ MAPBOX_KEY=
# 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>" # 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=
# For correctly display your local time zone on the web, you can set the time zone here.
# Should work fine by default value, however, in case of incorrect timezone in EXIF, this value
# should be set to the correct timezone.
# Command to get timezone:
# - Linux: curl -s http://ip-api.com/json/ | grep -oP '(?<=timezone":")(.*?)(?=")'
# TZ=Etc/UTC

View File

@@ -47,8 +47,6 @@ services:
entrypoint: ["/bin/sh", "./entrypoint.sh"] entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file: env_file:
- .env - .env
environment:
- PUBLIC_TZ=${TZ}
restart: always restart: always
redis: redis:

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,6 @@
"@nestjs/core": "^8.0.0", "@nestjs/core": "^8.0.0",
"@nestjs/mapped-types": "^1.0.1", "@nestjs/mapped-types": "^1.0.1",
"@nestjs/platform-express": "^8.0.0", "@nestjs/platform-express": "^8.0.0",
"@nestjs/typeorm": "^8.0.3",
"@tensorflow-models/coco-ssd": "^2.2.2", "@tensorflow-models/coco-ssd": "^2.2.2",
"@tensorflow-models/mobilenet": "^2.1.0", "@tensorflow-models/mobilenet": "^2.1.0",
"@tensorflow/tfjs": "^3.19.0", "@tensorflow/tfjs": "^3.19.0",
@@ -34,11 +33,9 @@
"@tensorflow/tfjs-node": "^3.19.0", "@tensorflow/tfjs-node": "^3.19.0",
"@tensorflow/tfjs-node-gpu": "^3.19.0", "@tensorflow/tfjs-node-gpu": "^3.19.0",
"@trpc/server": "^9.20.3", "@trpc/server": "^9.20.3",
"pg": "^8.7.3",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0"
"typeorm": "^0.2.45"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^8.2.4", "@nestjs/cli": "^8.2.4",

View File

@@ -1,15 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ImageClassifierModule } from './image-classifier/image-classifier.module'; 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'; import { ObjectDetectionModule } from './object-detection/object-detection.module';
@Module({ @Module({
imports: [ imports: [ImageClassifierModule, ObjectDetectionModule],
TypeOrmModule.forRoot(databaseConfig),
ImageClassifierModule,
ObjectDetectionModule,
],
controllers: [], controllers: [],
providers: [], providers: [],
}) })

View File

@@ -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,
};

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 42, "android.injected.version.code" => 44,
"android.injected.version.name" => "1.29.0", "android.injected.version.name" => "1.29.4",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1 @@
* Update deprecated API that causes notification not dismissing after background upload progress finished.

View File

@@ -0,0 +1 @@
* Fixed app crashes when there is no object detection result.

View File

@@ -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.00043">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="58.722434"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="71.114285">
</testcase> </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="36.949677">
</testcase> </testcase>

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 57; CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 57; CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 57; CURRENT_PROJECT_VERSION = 58;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.29.0</string> <string>1.30.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>57</string> <string>58</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true /> <true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.29.0" version_number: "1.29.4"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -5,34 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000204"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000173">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="1.04331"> <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.412813">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.166831"> <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.69289">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.411879"> <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.408563">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="71.643901"> <testcase classname="fastlane.lanes" name="4: build_app" time="65.350555">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="7.590505"> <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.894733">
<failure message="/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/runner.rb:229:in `chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing&apos;&#10;Fastfile:30:in `block (2 levels) in parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/lane.rb:33:in `call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/runner.rb:45:in `chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/runner.rb:45:in `execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/commands_generator.rb:354:in `run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/commands_generator.rb:43:in `start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/gems/fastlane-2.209.0/bin/fastlane:23:in `&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/bin/fastlane:25:in `load&apos;&#10;/opt/homebrew/Cellar/fastlane/2.209.0/libexec/bin/fastlane:25:in `&lt;main&gt;&apos;&#10;&#10;Could not find transporter at /Applications/Xcode.app/Contents/Developer/. Please make sure you set the correct path to your Xcode installation." />
</testcase> </testcase>

View File

@@ -81,7 +81,7 @@ class ExifBottomSheet extends ConsumerWidget {
if (assetDetail.exifInfo?.dateTimeOriginal != null) if (assetDetail.exifInfo?.dateTimeOriginal != null)
Text( Text(
DateFormat('date_format'.tr()).format( DateFormat('date_format'.tr()).format(
assetDetail.exifInfo!.dateTimeOriginal!, assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
), ),
style: TextStyle( style: TextStyle(
color: Colors.grey[400], color: Colors.grey[400],

View File

@@ -1,5 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
@@ -58,14 +57,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
widget.isZoomedFunction(); widget.isZoomedFunction();
} }
void _fireStartLoadingEvent() {
widget.onLoadingStart();
}
void _fireFinishedLoadingEvent() {
widget.onLoadingCompleted();
}
CachedNetworkImageProvider _authorizedImageProvider( CachedNetworkImageProvider _authorizedImageProvider(
String url, String url,
String cacheKey, String cacheKey,
@@ -94,12 +85,6 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
if (!mounted) return; if (!mounted) return;
if (newStatus != _RemoteImageStatus.full) {
_fireStartLoadingEvent();
} else {
_fireFinishedLoadingEvent();
}
setState(() { setState(() {
_status = newStatus; _status = newStatus;
_imageProvider = provider; _imageProvider = provider;
@@ -147,21 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
@override @override
void initState() { void initState() {
_loadImages();
super.initState(); super.initState();
_loadImages();
} }
@override @override
void dispose() async { void dispose() async {
super.dispose(); 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(); await previewProvider.evict();
} else if (_status == _RemoteImageStatus.thumbnail) {
await thumbnailProvider.evict();
} }
_imageProvider.evict(); await _imageProvider.evict();
} }
} }
@@ -176,8 +163,6 @@ class RemotePhotoView extends StatefulWidget {
required this.onSwipeDown, required this.onSwipeDown,
required this.onSwipeUp, required this.onSwipeUp,
this.previewUrl, this.previewUrl,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.cacheKey, required this.cacheKey,
}) : super(key: key); }) : super(key: key);
@@ -185,8 +170,6 @@ class RemotePhotoView extends StatefulWidget {
final String imageUrl; final String imageUrl;
final String authToken; final String authToken;
final String? previewUrl; final String? previewUrl;
final Function onLoadingCompleted;
final Function onLoadingStart;
final String cacheKey; final String cacheKey;
final void Function() onSwipeDown; final void Function() onSwipeDown;

View File

@@ -24,11 +24,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
double iconSize = 18.0; double iconSize = 18.0;
return AppBar( return AppBar(
// iconTheme: IconThemeData(color: Colors.grey[100]),
// actionsIconTheme: IconThemeData(color: Colors.grey[100]),
foregroundColor: Colors.grey[100], foregroundColor: Colors.grey[100],
toolbarHeight: 60, toolbarHeight: 60,
backgroundColor: Colors.black, backgroundColor: Colors.transparent,
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();

View File

@@ -121,8 +121,6 @@ class GalleryViewerPage extends HookConsumerWidget {
authToken: 'Bearer ${box.get(accessTokenKey)}', authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod, isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
onLoadingCompleted: () => {},
onLoadingStart: () => {},
asset: assetList[index], asset: assetList[index],
heroTag: assetList[index].id, heroTag: assetList[index].id,
threeStageLoading: threeStageLoading.value, threeStageLoading: threeStageLoading.value,

View File

@@ -18,8 +18,6 @@ class ImageViewerPage extends HookConsumerWidget {
final String authToken; final String authToken;
final ValueNotifier<bool> isZoomedListener; final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction; final void Function() isZoomedFunction;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading; final bool threeStageLoading;
ImageViewerPage({ ImageViewerPage({
@@ -29,8 +27,6 @@ class ImageViewerPage extends HookConsumerWidget {
required this.authToken, required this.authToken,
required this.isZoomedFunction, required this.isZoomedFunction,
required this.isZoomedListener, required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading, required this.threeStageLoading,
}) : super(key: key); }) : super(key: key);
@@ -83,8 +79,6 @@ class ImageViewerPage extends HookConsumerWidget {
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(), onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(), onSwipeUp: () => showInfo(),
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
), ),
), ),
), ),

View File

@@ -26,7 +26,7 @@ class AvailableAlbum {
String get name => albumEntity.name; String get name => albumEntity.name;
int get assetCount => albumEntity.assetCount; Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => albumEntity.id; String get id => albumEntity.id;

View File

@@ -183,17 +183,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
for (AssetPathEntity album in albums) { for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
var assetList = var assetCountInAlbum = await album.assetCountAsync;
await album.getAssetListRange(start: 0, end: album.assetCount); if (assetCountInAlbum > 0) {
var assetList =
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
if (assetList.isNotEmpty) { if (assetList.isNotEmpty) {
var thumbnailAsset = assetList.first; var thumbnailAsset = assetList.first;
var thumbnailData = await thumbnailAsset var thumbnailData = await thumbnailAsset
.thumbnailDataWithSize(const ThumbnailSize(512, 512)); .thumbnailDataWithSize(const ThumbnailSize(512, 512));
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData); availableAlbum =
availableAlbum.copyWith(thumbnailData: thumbnailData);
}
availableAlbums.add(availableAlbum);
} }
availableAlbums.add(availableAlbum);
} }
state = state.copyWith(availableAlbums: availableAlbums); state = state.copyWith(availableAlbums: availableAlbums);
@@ -296,14 +300,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<AssetEntity> assetsFromExcludedAlbums = {}; Set<AssetEntity> assetsFromExcludedAlbums = {};
for (var album in state.selectedBackupAlbums) { for (var album in state.selectedBackupAlbums) {
var assets = await album.albumEntity var assets = await album.albumEntity.getAssetListRange(
.getAssetListRange(start: 0, end: album.assetCount); start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromSelectedAlbums.addAll(assets); assetsFromSelectedAlbums.addAll(assets);
} }
for (var album in state.excludedBackupAlbums) { for (var album in state.excludedBackupAlbums) {
var assets = await album.albumEntity var assets = await album.albumEntity.getAssetListRange(
.getAssetListRange(start: 0, end: album.assetCount); start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets); assetsFromExcludedAlbums.addAll(assets);
} }
@@ -353,11 +361,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled(); final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled); state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) { if (state.backupProgress != BackUpProgressEnum.inBackground) {
await Future.wait([ await _getBackupAlbumsInfo();
_getBackupAlbumsInfo(), await _updateServerInfo();
_updateServerInfo(),
]);
await _updateBackupAssetCount(); await _updateBackupAssetCount();
} }
} }

View File

@@ -127,7 +127,9 @@ class BackupService {
for (int i = 0; i < albums.length; i++) { for (int i = 0; i < albums.length; i++) {
final AssetPathEntity? a = albums[i]; final AssetPathEntity? a = albums[i];
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) { 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; lastBackup[i] = now;
} }
} }

View File

@@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -205,15 +203,23 @@ class AlbumInfoCard extends HookConsumerWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 2.0), padding: const EdgeInsets.only(top: 2.0),
child: Text( child: FutureBuilder(
albumInfo.assetCount.toString() + builder: ((context, snapshot) {
(albumInfo.isAll if (snapshot.hasData) {
? " (${'backup_all'.tr()})" return Text(
: ""), snapshot.data.toString() +
style: TextStyle( (albumInfo.isAll
fontSize: 12, ? " (${'backup_all'.tr()})"
color: Colors.grey[600], : ""),
), style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: albumInfo.assetCount,
), ),
) )
], ],

View File

@@ -508,7 +508,7 @@ class BackupControllerPage extends HookConsumerWidget {
DateTime.parse( DateTime.parse(
backupState.currentUploadAsset.createdAt backupState.currentUploadAsset.createdAt
.toString(), .toString(),
), ).toLocal(),
) )
], ],
), ),

View File

@@ -90,7 +90,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
DateFormat.yMMMMd('en_US').format( DateFormat.yMMMMd('en_US').format(
DateTime.parse( DateTime.parse(
errorAsset.createdAt.toString(), errorAsset.createdAt.toString(),
), ).toLocal(),
), ),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,

View File

@@ -21,8 +21,8 @@ class DailyTitleText extends ConsumerWidget {
var formatDateTemplate = currentYear == groupYear var formatDateTemplate = currentYear == groupYear
? "daily_title_text_date".tr() ? "daily_title_text_date".tr()
: "daily_title_text_date_year".tr(); : "daily_title_text_date_year".tr();
var dateText = var dateText = DateFormat(formatDateTemplate)
DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); .format(DateTime.parse(isoDate).toLocal());
var isMultiSelectEnable = var isMultiSelectEnable =
ref.watch(homePageStateProvider).isMultiSelectEnable; ref.watch(homePageStateProvider).isMultiSelectEnable;
var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup; var selectedDateGroup = ref.watch(homePageStateProvider).selectedDateGroup;

View File

@@ -12,7 +12,7 @@ class MonthlyTitleText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr()) var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(isoDate)); .format(DateTime.parse(isoDate).toLocal());
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(

View File

@@ -32,7 +32,7 @@ class ThumbnailImage extends HookConsumerWidget {
ref.watch(homePageStateProvider).isMultiSelectEnable; ref.watch(homePageStateProvider).isMultiSelectEnable;
var deviceId = ref.watch(authenticationProvider).deviceId; var deviceId = ref.watch(authenticationProvider).deviceId;
Widget _buildSelectionIcon(AssetResponseDto asset) { Widget buildSelectionIcon(AssetResponseDto asset) {
if (selectedAsset.contains(asset)) { if (selectedAsset.contains(asset)) {
return Icon( return Icon(
Icons.check_circle, Icons.check_circle,
@@ -48,7 +48,6 @@ class ThumbnailImage extends HookConsumerWidget {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
debugPrint("View ${asset.id}");
if (isMultiSelectEnable && if (isMultiSelectEnable &&
selectedAsset.contains(asset) && selectedAsset.contains(asset) &&
selectedAsset.length == 1) { selectedAsset.length == 1) {
@@ -91,10 +90,12 @@ class ThumbnailImage extends HookConsumerWidget {
: const Border(), : const Border(),
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheKey: asset.id, cacheKey: 'thumbnail-image-${asset.id}',
width: 300, width: 300,
height: 300, height: 300,
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400, memCacheHeight: 200,
maxWidthDiskCache: 200,
maxHeightDiskCache: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl, imageUrl: thumbnailRequestUrl,
httpHeaders: { httpHeaders: {
@@ -109,7 +110,9 @@ class ThumbnailImage extends HookConsumerWidget {
), ),
), ),
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
// debugPrint("Error getting thumbnail $url = $error"); debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon( return Icon(
Icons.image_not_supported_outlined, Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
@@ -122,7 +125,7 @@ class ThumbnailImage extends HookConsumerWidget {
padding: const EdgeInsets.all(3.0), padding: const EdgeInsets.all(3.0),
child: Align( child: Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: _buildSelectionIcon(asset), child: buildSelectionIcon(asset),
), ),
), ),
if (showStorageIndicator) if (showStorageIndicator)

View File

@@ -62,7 +62,7 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('y-MM-dd')
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });

View File

@@ -99,9 +99,9 @@ class SearchPage extends HookConsumerWidget {
_buildThings() { _buildThings() {
return curatedObjects.when( return curatedObjects.when(
loading: () => const SizedBox( loading: () => SizedBox(
height: 200, height: imageSize,
child: Center(child: ImmichLoadingIndicator()), child: const Center(child: ImmichLoadingIndicator()),
), ),
error: (err, stack) => Text('Error: $err'), error: (err, stack) => Text('Error: $err'),
data: (objects) { data: (objects) {
@@ -133,8 +133,7 @@ class SearchPage extends HookConsumerWidget {
), ),
) )
: SizedBox( : SizedBox(
// height: imageSize, height: imageSize,
width: imageSize,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,

View File

@@ -59,8 +59,6 @@ class _$AppRouter extends RootStackRouter {
authToken: args.authToken, authToken: args.authToken,
isZoomedFunction: args.isZoomedFunction, isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener, isZoomedListener: args.isZoomedListener,
onLoadingCompleted: args.onLoadingCompleted,
onLoadingStart: args.onLoadingStart,
threeStageLoading: args.threeStageLoading)); threeStageLoading: args.threeStageLoading));
}, },
VideoViewerRoute.name: (routeData) { VideoViewerRoute.name: (routeData) {
@@ -297,8 +295,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
required String authToken, required String authToken,
required void Function() isZoomedFunction, required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener, required ValueNotifier<bool> isZoomedListener,
required void Function() onLoadingCompleted,
required void Function() onLoadingStart,
required bool threeStageLoading}) required bool threeStageLoading})
: super(ImageViewerRoute.name, : super(ImageViewerRoute.name,
path: '/image-viewer-page', path: '/image-viewer-page',
@@ -309,8 +305,6 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
authToken: authToken, authToken: authToken,
isZoomedFunction: isZoomedFunction, isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
onLoadingCompleted: onLoadingCompleted,
onLoadingStart: onLoadingStart,
threeStageLoading: threeStageLoading)); threeStageLoading: threeStageLoading));
static const String name = 'ImageViewerRoute'; static const String name = 'ImageViewerRoute';
@@ -324,8 +318,6 @@ class ImageViewerRouteArgs {
required this.authToken, required this.authToken,
required this.isZoomedFunction, required this.isZoomedFunction,
required this.isZoomedListener, required this.isZoomedListener,
required this.onLoadingCompleted,
required this.onLoadingStart,
required this.threeStageLoading}); required this.threeStageLoading});
final Key? key; final Key? key;
@@ -340,15 +332,11 @@ class ImageViewerRouteArgs {
final ValueNotifier<bool> isZoomedListener; final ValueNotifier<bool> isZoomedListener;
final void Function() onLoadingCompleted;
final void Function() onLoadingStart;
final bool threeStageLoading; final bool threeStageLoading;
@override @override
String toString() { 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}';
} }
} }

View File

@@ -81,8 +81,8 @@ final assetGroupByDateTimeProvider = StateProvider((ref) {
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('y-MM-dd')
DateFormat('y-MM-dd').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });
@@ -95,7 +95,7 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => (element) => DateFormat('MMMM, y')
DateFormat('MMMM, y').format(DateTime.parse(element.createdAt)), .format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });

View File

@@ -177,10 +177,8 @@ class AssetResponseDto {
// Note 2: this code is stripped in release mode! // Note 2: this code is stripped in release mode!
// assert(() { // assert(() {
// requiredKeys.forEach((key) { // requiredKeys.forEach((key) {
// assert(json.containsKey(key), // assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
// 'Required key "AssetResponseDto[$key]" is missing from JSON.'); // assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// assert(json[key] != null,
// 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
// }); // });
// return true; // return true;
// }()); // }());

View File

@@ -7,14 +7,14 @@ packages:
name: _fe_analyzer_shared name: _fe_analyzer_shared
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "38.0.0" version: "47.0.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.4.1" version: "4.7.0"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -42,14 +42,14 @@ packages:
name: auto_route name: auto_route
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.1" version: "5.0.1"
auto_route_generator: auto_route_generator:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: auto_route_generator name: auto_route_generator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.0" version: "5.0.2"
badges: badges:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -77,7 +77,7 @@ packages:
name: build_config name: build_config
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "1.1.0"
build_daemon: build_daemon:
dependency: transitive dependency: transitive
description: description:
@@ -98,7 +98,7 @@ packages:
name: build_runner name: build_runner
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.10" version: "2.2.1"
build_runner_core: build_runner_core:
dependency: transitive dependency: transitive
description: description:
@@ -162,13 +162,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.1" version: "1.2.1"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -322,7 +315,7 @@ packages:
source: hosted source: hosted
version: "0.6.8" version: "0.6.8"
flutter_cache_manager: flutter_cache_manager:
dependency: "direct main" dependency: transitive
description: description:
name: flutter_cache_manager name: flutter_cache_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@@ -461,7 +454,7 @@ packages:
name: hive_generator name: hive_generator
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.2" version: "1.1.3"
hooks_riverpod: hooks_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -503,7 +496,7 @@ packages:
name: image name: image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.3" version: "3.2.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -783,7 +776,7 @@ packages:
name: photo_manager name: photo_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0+2" version: "2.2.1"
photo_view: photo_view:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1139,20 +1132,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.3.1" 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: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1334,7 +1313,7 @@ packages:
name: xml name: xml
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "5.4.1" version: "6.1.0"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.29.0+42 version: 1.29.4+44
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@@ -11,7 +11,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
photo_manager: ^2.0.6 photo_manager: ^2.2.1
flutter_hooks: ^0.18.0 flutter_hooks: ^0.18.0
hooks_riverpod: ^2.0.0-dev.0 hooks_riverpod: ^2.0.0-dev.0
hive: ^2.2.1 hive: ^2.2.1
@@ -20,7 +20,7 @@ dependencies:
cached_network_image: ^3.2.2 cached_network_image: ^3.2.2
percent_indicator: ^4.2.2 percent_indicator: ^4.2.2
intl: ^0.17.0 intl: ^0.17.0
auto_route: ^4.0.1 auto_route: ^5.0.1
exif: ^3.1.1 exif: ^3.1.1
transparent_image: ^2.0.0 transparent_image: ^2.0.0
flutter_launcher_icons: "^0.9.2" flutter_launcher_icons: "^0.9.2"
@@ -43,7 +43,6 @@ dependencies:
easy_localization: ^3.0.1 easy_localization: ^3.0.1
share_plus: ^4.0.10 share_plus: ^4.0.10
flutter_displaymode: ^0.4.0 flutter_displaymode: ^0.4.0
flutter_cache_manager: 3.3.0
path: ^1.8.1 path: ^1.8.1
path_provider: ^2.0.11 path_provider: ^2.0.11
@@ -59,8 +58,8 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^2.0.1 flutter_lints: ^2.0.1
hive_generator: ^1.1.2 hive_generator: ^1.1.2
build_runner: ^2.1.7 build_runner: ^2.2.1
auto_route_generator: ^4.0.0 auto_route_generator: ^5.0.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -97,7 +97,7 @@ export class AssetController {
await this.assetUploadedQueue.add( await this.assetUploadedQueue.add(
assetUploadedProcessorName, assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size }, { asset: savedAsset, fileName: file.originalname },
{ jobId: savedAsset.id }, { jobId: savedAsset.id },
); );

View File

@@ -2,7 +2,11 @@ import { IAssetRepository } from './asset-repository';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetService } from './asset.service'; import { AssetService } from './asset.service';
import { Repository } from 'typeorm'; 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', () => { describe('AssetService', () => {
let sui: AssetService; let sui: AssetService;
@@ -10,43 +14,85 @@ describe('AssetService', () => {
let assetRepositoryMock: jest.Mocked<IAssetRepository>; let assetRepositoryMock: jest.Mocked<IAssetRepository>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
id: '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd', id: 'user_id_1',
email: 'auth@test.com', email: 'auth@test.com',
}); });
// const _getCreateAssetDto = (): CreateAssetDto => { const _getCreateAssetDto = (): CreateAssetDto => {
// const createAssetDto = new CreateAssetDto(); const createAssetDto = new CreateAssetDto();
// createAssetDto.deviceAssetId = 'deviceAssetId'; createAssetDto.deviceAssetId = 'deviceAssetId';
// createAssetDto.deviceId = 'deviceId'; createAssetDto.deviceId = 'deviceId';
// createAssetDto.assetType = AssetType.OTHER; createAssetDto.assetType = AssetType.OTHER;
// createAssetDto.createdAt = '2022-06-19T23:41:36.910Z'; createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
// createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z'; createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
// createAssetDto.isFavorite = false; createAssetDto.isFavorite = false;
// createAssetDto.duration = '0:00:00.000000'; createAssetDto.duration = '0:00:00.000000';
// return createAssetDto; return createAssetDto;
// }; };
// const _getAsset = () => {
// const assetEntity = new AssetEntity();
// assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67'; const _getAsset_1 = () => {
// assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd'; const asset_1 = new AssetEntity();
// 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';
// 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(() => { beforeAll(() => {
assetRepositoryMock = { assetRepositoryMock = {
@@ -67,29 +113,65 @@ describe('AssetService', () => {
}); });
// Currently failing due to calculate checksum from a file // Currently failing due to calculate checksum from a file
// it('create an asset', async () => { it('create an asset', async () => {
// const assetEntity = _getAsset(); const assetEntity = _getAsset_1();
// assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity)); assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
// const originalPath = const originalPath = 'fake_path/asset_1.jpeg';
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg'; const mimeType = 'image/jpeg';
// const mimeType = 'image/jpeg'; const createAssetDto = _getCreateAssetDto();
// const createAssetDto = _getCreateAssetDto(); const result = await sui.createUserAsset(
// const result = await sui.createUserAsset(authUser, createAssetDto, originalPath, mimeType); authUser,
createAssetDto,
originalPath,
mimeType,
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
);
// expect(result.userId).toEqual(authUser.id); expect(result.userId).toEqual(authUser.id);
// expect(result.resizePath).toEqual(''); expect(result.resizePath).toEqual('');
// expect(result.webpPath).toEqual(''); expect(result.webpPath).toEqual('');
// }); });
it('get assets by device id', async () => { 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); const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
expect(result.length).toEqual(1); expect(result.length).toEqual(2);
expect(result[0]).toEqual('4967046344801'); 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);
}); });
}); });

View File

@@ -11,7 +11,6 @@ import {
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
Response, Response,
Request,
ParseBoolPipe, ParseBoolPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { UserService } from './user.service'; 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 { UpdateUserDto } from './dto/update-user.dto';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { profileImageUploadOption } from '../../config/profile-image-upload.config'; 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 { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { UserResponseDto } from './response-dto/user-response.dto'; import { UserResponseDto } from './response-dto/user-response.dto';
import { UserCountResponseDto } from './response-dto/user-count-response.dto'; import { UserCountResponseDto } from './response-dto/user-count-response.dto';
@@ -74,8 +73,11 @@ export class UserController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Put() @Put()
async updateUser(@Body(ValidationPipe) updateUserDto: UpdateUserDto): Promise<UserResponseDto> { async updateUser(
return await this.userService.updateUser(updateUserDto); @GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) updateUserDto: UpdateUserDto,
): Promise<UserResponseDto> {
return await this.userService.updateUser(authUser, updateUserDto);
} }
@UseInterceptors(FileInterceptor('file', profileImageUploadOption)) @UseInterceptors(FileInterceptor('file', profileImageUploadOption))
@@ -90,9 +92,7 @@ export class UserController {
async createProfileImage( async createProfileImage(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFile() fileInfo: Express.Multer.File, @UploadedFile() fileInfo: Express.Multer.File,
@Request() req: Req,
): Promise<CreateProfileImageResponseDto> { ): Promise<CreateProfileImageResponseDto> {
console.log(req.body, req.file);
return await this.userService.createProfileImage(authUser, fileInfo); return await this.userService.createProfileImage(authUser, fileInfo);
} }

View 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);
});
});
});

View File

@@ -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); const user = await this.userRepository.get(updateUserDto.id);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
@@ -88,8 +100,8 @@ export class UserService {
return mapUser(updatedUser); return mapUser(updatedUser);
} catch (e) { } catch (e) {
Logger.error(e, 'Create new user'); Logger.error(e, 'Failed to update user info');
throw new InternalServerErrorException('Failed to register new user'); throw new InternalServerErrorException('Failed to update user info');
} }
} }

View File

@@ -6,10 +6,13 @@ import { diskStorage } from 'multer';
import { extname, join } from 'path'; import { extname, join } from 'path';
import { Request } from 'express'; import { Request } from 'express';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import sanitize from 'sanitize-filename';
export const assetUploadOption: MulterOptions = { export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { 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); cb(null, true);
} else { } else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false); cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);
@@ -19,17 +22,13 @@ export const assetUploadOption: MulterOptions = {
storage: diskStorage({ storage: diskStorage({
destination: (req: Request, file: Express.Multer.File, cb: any) => { destination: (req: Request, file: Express.Multer.File, cb: any) => {
const basePath = APP_UPLOAD_LOCATION; 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) { if (!req.user) {
return; 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)) { if (!existsSync(originalUploadFolder)) {
mkdirSync(originalUploadFolder, { recursive: true }); mkdirSync(originalUploadFolder, { recursive: true });
@@ -41,8 +40,9 @@ export const assetUploadOption: MulterOptions = {
filename: (req: Request, file: Express.Multer.File, cb: any) => { filename: (req: Request, file: Express.Multer.File, cb: any) => {
const fileNameUUID = randomUUID(); const fileNameUUID = randomUUID();
const fileName = `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`;
cb(null, `${fileNameUUID}${req.body['fileExtension'].toLowerCase()}`); const sanitizedFileName = sanitize(fileName);
cb(null, sanitizedFileName);
}, },
}), }),
}; };

View File

@@ -5,6 +5,7 @@ import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { Request } from 'express'; import { Request } from 'express';
import sanitize from 'sanitize-filename';
export const profileImageUploadOption: MulterOptions = { export const profileImageUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => { fileFilter: (req: Request, file: any, cb: any) => {
@@ -35,8 +36,9 @@ export const profileImageUploadOption: MulterOptions = {
return; return;
} }
const userId = req.user.id; const userId = req.user.id;
const fileName = `${userId}${extname(file.originalname)}`;
cb(null, `${userId}${extname(file.originalname)}`); cb(null, sanitize(String(fileName)));
}, },
}), }),
}; };

View File

@@ -11,6 +11,6 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = { export const serverVersion: IServerVersion = {
major: 1, major: 1,
minor: 29, minor: 29,
patch: 0, patch: 6,
build: 42, build: 44,
}; };

View File

@@ -8,14 +8,16 @@ import { Queue } from 'bull';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { import {
exifExtractionProcessorName,
generateWEBPThumbnailProcessorName,
IMetadataExtractionJob, IMetadataExtractionJob,
IVideoTranscodeJob, IVideoTranscodeJob,
metadataExtractionQueueName, metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
generateWEBPThumbnailProcessorName,
mp4ConversionProcessorName, mp4ConversionProcessorName,
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
videoMetadataExtractionProcessorName,
} from '@app/job'; } from '@app/job';
import { ConfigService } from '@nestjs/config'; 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() { 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({ const exifInfo = await this.exifRepository.find({
where: { where: {
city: IsNull(), city: IsNull(),
@@ -94,7 +96,37 @@ export class ScheduleTasksService {
}); });
for (const exif of exifInfo) { 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() },
);
} }
} }
} }

View File

@@ -42,13 +42,18 @@ export class AssetUploadedProcessor {
*/ */
@Process(assetUploadedProcessorName) @Process(assetUploadedProcessorName)
async processUploadedVideo(job: Job<IAssetUploadedJob>) { async processUploadedVideo(job: Job<IAssetUploadedJob>) {
const { asset, fileName, fileSize } = job.data; const { asset, fileName } = job.data;
await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() }); await this.thumbnailGeneratorQueue.add(generateJPEGThumbnailProcessorName, { asset }, { jobId: randomUUID() });
// Video Conversion // Video Conversion
if (asset.type == AssetType.VIDEO) { if (asset.type == AssetType.VIDEO) {
await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() }); await this.videoConversionQueue.add(mp4ConversionProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(
videoMetadataExtractionProcessorName,
{ asset, fileName },
{ jobId: randomUUID() },
);
} else { } else {
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet // Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
await this.metadataExtractionQueue.add( await this.metadataExtractionQueue.add(
@@ -56,19 +61,9 @@ export class AssetUploadedProcessor {
{ {
asset, asset,
fileName, fileName,
fileSize,
}, },
{ jobId: randomUUID() }, { 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() }
);
}
} }
} }

View File

@@ -13,8 +13,6 @@ import {
reverseGeocodingProcessorName, reverseGeocodingProcessorName,
IReverseGeocodingProcessor, IReverseGeocodingProcessor,
} from '@app/job'; } 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 { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@@ -26,10 +24,64 @@ import ffmpeg from 'fluent-ffmpeg';
import path from 'path'; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository'; 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) @Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService; private isGeocodeInitialized = false;
private logLevel: ImmichLogLevel; private logLevel: ImmichLogLevel;
constructor( constructor(
@@ -44,19 +96,51 @@ export class MetadataExtractionProcessor {
private configService: ConfigService, private configService: ConfigService,
) { ) {
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) { if (configService.get('DISABLE_REVERSE_GEOCODING') !== 'true') {
this.geocodingClient = mapboxGeocoding({ Logger.log('Initialising Reverse Geocoding');
accessToken: process.env.MAPBOX_KEY, 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: [],
}).then(() => {
this.isGeocodeInitialized = true;
Logger.log('Reverse Geocoding Initialised');
}); });
} }
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE; 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) @Process(exifExtractionProcessorName)
async extractExifInfo(job: Job<IExifExtractionProcessor>) { async extractExifInfo(job: Job<IExifExtractionProcessor>) {
try { 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, { const exifData = await exifr.parse(asset.originalPath, {
tiff: true, tiff: true,
ifd0: true as any, ifd0: true as any,
@@ -75,6 +159,11 @@ export class MetadataExtractionProcessor {
throw new Error(`can not parse exif data from file ${asset.originalPath}`); 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(); const newExif = new ExifEntity();
newExif.assetId = asset.id; newExif.assetId = asset.id;
newExif.make = exifData['Make'] || null; newExif.make = exifData['Make'] || null;
@@ -82,9 +171,9 @@ export class MetadataExtractionProcessor {
newExif.imageName = path.parse(fileName).name || null; newExif.imageName = path.parse(fileName).name || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null; newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null; newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null; newExif.fileSizeInByte = fileSizeInBytes || null;
newExif.orientation = exifData['Orientation'] || null; newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = new Date(asset.createdAt) || null; newExif.dateTimeOriginal = createdAt;
newExif.modifyDate = exifData['ModifyDate'] || null; newExif.modifyDate = exifData['ModifyDate'] || null;
newExif.lensModel = exifData['LensModel'] || null; newExif.lensModel = exifData['LensModel'] || null;
newExif.fNumber = exifData['FNumber'] || null; newExif.fNumber = exifData['FNumber'] || null;
@@ -94,39 +183,60 @@ export class MetadataExtractionProcessor {
newExif.latitude = exifData['latitude'] || null; newExif.latitude = exifData['latitude'] || null;
newExif.longitude = exifData['longitude'] || null; newExif.longitude = exifData['longitude'] || null;
// Reverse GeoCoding /**
if (this.geocodingClient && exifData['longitude'] && exifData['latitude']) { * Correctly store UTC time based on timezone
const geoCodeInfo: MapiResponse = await this.geocodingClient * The timestamp being extracted from EXIF is based on the timezone
.reverseGeocode({ * of the container. We need to correct it to UTC time based on the
query: [exifData['longitude'], exifData['latitude']], * timezone of the location.
types: ['country', 'region', 'place'], *
}) * The timezone of the location can be exracted from the lat/lon
.send(); * 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']; if (localTimeWithTimezone.length == 24) {
// Remove the last character
let city = ''; const localTimeWithoutTimezone = localTimeWithTimezone.slice(0, -1);
let state = ''; const correctUTCTime = luxon.DateTime.fromISO(localTimeWithoutTimezone, { zone: tz }).toUTC().toISO();
let country = ''; newExif.dateTimeOriginal = new Date(correctUTCTime);
await this.assetRepository.save({
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]) { id: asset.id,
city = res.filter((geoInfo) => geoInfo['place_type'][0] == 'place')[0]['text']; createdAt: correctUTCTime,
});
} }
} else {
if (res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]) { await this.assetRepository.save({
state = res.filter((geoInfo) => geoInfo['place_type'][0] == 'region')[0]['text']; id: asset.id,
} createdAt: createdAt.toISOString(),
});
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;
} }
// 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) { if (!newExif.exifImageHeight || !newExif.exifImageWidth || !newExif.orientation) {
const metadata = await sharp(asset.originalPath).metadata(); const metadata = await sharp(asset.originalPath).metadata();
@@ -155,35 +265,10 @@ export class MetadataExtractionProcessor {
@Process({ name: reverseGeocodingProcessorName }) @Process({ name: reverseGeocodingProcessorName })
async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) { async reverseGeocoding(job: Job<IReverseGeocodingProcessor>) {
const { exif } = job.data; if (this.isGeocodeInitialized) {
const { latitude, longitude } = job.data;
if (this.geocodingClient) { const { country, state, city } = await this.reverseGeocodeExif(latitude, longitude);
const geoCodeInfo: MapiResponse = await this.geocodingClient await this.exifRepository.update({ id: job.data.exifId }, { city, state, country });
.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 });
} }
} }
@@ -295,35 +380,11 @@ export class MetadataExtractionProcessor {
} }
// Reverse GeoCoding // Reverse GeoCoding
if (this.geocodingClient && newExif.longitude && newExif.latitude) { if (this.isGeocodeInitialized && newExif.longitude && newExif.latitude) {
const geoCodeInfo: MapiResponse = await this.geocodingClient const { country, state, city } = await this.reverseGeocodeExif(newExif.latitude, newExif.longitude);
.reverseGeocode({ newExif.country = country;
query: [newExif.longitude, newExif.latitude], newExif.state = state;
types: ['country', 'region', 'place'], newExif.city = city;
})
.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;
} }
for (const stream of data.streams) { for (const stream of data.streams) {

View File

@@ -1,3 +1,4 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { ImmichLogLevel } from '@app/common/constants/log-level.constant'; import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { import {
@@ -19,9 +20,11 @@ import { Job, Queue } from 'bull';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync } from 'node:fs'; import { existsSync, mkdirSync } from 'node:fs';
import sanitize from 'sanitize-filename';
import sharp from 'sharp'; import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository'; 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) @Processor(thumbnailGeneratorQueueName)
export class ThumbnailGeneratorProcessor { export class ThumbnailGeneratorProcessor {
@@ -46,9 +49,12 @@ export class ThumbnailGeneratorProcessor {
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 }) @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
async generateJPEGThumbnail(job: Job<JpegGeneratorProcessor>) { 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)) { if (!existsSync(resizePath)) {
mkdirSync(resizePath, { recursive: true }); mkdirSync(resizePath, { recursive: true });

View File

@@ -10,12 +10,8 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_PASSWORD: Joi.string().required(), DB_PASSWORD: Joi.string().required(),
DB_DATABASE_NAME: Joi.string().required(), DB_DATABASE_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().required(), JWT_SECRET: Joi.string().required(),
ENABLE_MAPBOX: Joi.boolean().required().valid(true, false), DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
MAPBOX_KEY: Joi.any().when('ENABLE_MAPBOX', { REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0,1,2,3).default(3),
is: false,
then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().required(),
}),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
}), }),
}; };

View File

@@ -10,9 +10,4 @@ export interface IAssetUploadedJob {
* Original file name * Original file name
*/ */
fileName: string; fileName: string;
/**
* File size in byte
*/
fileSize: number;
} }

View File

@@ -1,5 +1,4 @@
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
export interface IExifExtractionProcessor { export interface IExifExtractionProcessor {
/** /**
@@ -11,11 +10,6 @@ export interface IExifExtractionProcessor {
* Original file name * Original file name
*/ */
fileName: string; fileName: string;
/**
* File size in byte
*/
fileSize: number;
} }
export interface IVideoLengthExtractionProcessor { export interface IVideoLengthExtractionProcessor {
@@ -28,18 +22,12 @@ export interface IVideoLengthExtractionProcessor {
* Original file name * Original file name
*/ */
fileName: string; fileName: string;
/**
* File size in byte
*/
fileSize: number;
} }
export interface IReverseGeocodingProcessor { export interface IReverseGeocodingProcessor {
/** exifId: string;
* The Asset entity that was saved in the database latitude: number;
*/ longitude: number;
exif: ExifEntity;
} }
export type IMetadataExtractionJob = export type IMetadataExtractionJob =

3090
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@
"api:generate": "npm run api:typescript && npm run api:dart" "api:generate": "npm run api:typescript && npm run api:dart"
}, },
"dependencies": { "dependencies": {
"@mapbox/mapbox-sdk": "^0.13.3",
"@nestjs/bull": "^0.5.5", "@nestjs/bull": "^0.5.5",
"@nestjs/common": "^8.4.7", "@nestjs/common": "^8.4.7",
"@nestjs/config": "^2.1.0", "@nestjs/config": "^2.1.0",
@@ -37,7 +36,6 @@
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
"@nestjs/passport": "^8.2.2", "@nestjs/passport": "^8.2.2",
"@nestjs/platform-express": "^8.4.7", "@nestjs/platform-express": "^8.4.7",
"@nestjs/platform-fastify": "^8.4.7",
"@nestjs/platform-socket.io": "^8.4.7", "@nestjs/platform-socket.io": "^8.4.7",
"@nestjs/schedule": "^2.0.1", "@nestjs/schedule": "^2.0.1",
"@nestjs/swagger": "^5.2.1", "@nestjs/swagger": "^5.2.1",
@@ -54,15 +52,20 @@
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0", "joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport": "^0.5.2", "luxon": "^3.0.3",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"pg": "^8.7.1", "pg": "^8.7.1",
"redis": "^3.1.2", "redis": "^3.1.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3",
"sharp": "^0.28.0", "sharp": "^0.28.0",
"socket.io-redis": "^6.1.1", "socket.io-redis": "^6.1.1",
"swagger-ui-express": "^4.4.0", "swagger-ui-express": "^4.4.0",
@@ -83,7 +86,6 @@
"@types/imagemin": "^8.0.0", "@types/imagemin": "^8.0.0",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/lodash": "^4.14.178", "@types/lodash": "^4.14.178",
"@types/mapbox__mapbox-sdk": "^0.13.4",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",

7744
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,18 @@
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Snowburst+One&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@font-face {
font-family: 'Work Sans';
src: url('/fonts/WorkSans-VariableFont_wght.ttf') format('truetype-variations');
font-weight: 1 999;
}
@font-face {
font-family: 'Snowburst One';
src: url('/fonts/SnowburstOne-Regular.ttf') format('truetype');
}
:root { :root {
font-family: 'Work Sans', sans-serif; font-family: 'Work Sans', sans-serif;
/* --immich-icon-button-hover-color: #d3d3d3; */ /* --immich-icon-button-hover-color: #d3d3d3; */

View File

@@ -25,7 +25,7 @@
notificationController, notificationController,
NotificationType NotificationType
} from '../shared-components/notification/notification'; } from '../shared-components/notification/notification';
import { browser } from '$app/env'; import { browser } from '$app/environment';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
export let album: AlbumResponseDto; export let album: AlbumResponseDto;
@@ -69,9 +69,9 @@
$: isMultiSelectionMode = multiSelectAsset.size > 0; $: isMultiSelectionMode = multiSelectAsset.size > 0;
afterNavigate(({ from }) => { afterNavigate(({ from }) => {
backUrl = from?.pathname ?? '/albums'; backUrl = from?.url.pathname ?? '/albums';
if (from?.pathname === '/sharing') { if (from?.url.pathname === '/sharing') {
isCreatingSharedAlbum = true; isCreatingSharedAlbum = true;
} }
}); });

View File

@@ -150,7 +150,7 @@
</div> </div>
<div <div
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${ class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]' asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
}`} }`}
on:mouseenter={() => { on:mouseenter={() => {
@@ -182,7 +182,7 @@
</div> </div>
<div <div
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${ class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end mb-[60px] ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]' asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
}`} }`}
on:click={navigateAssetForward} on:click={navigateAssetForward}
@@ -195,7 +195,7 @@
}} }}
> >
<button <button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]" class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4"
class:navigation-button-hover={halfRightHover} class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward} on:click={navigateAssetForward}
> >

View File

@@ -6,8 +6,7 @@
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte'; import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
import moment from 'moment'; import moment from 'moment';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { browser } from '$app/env'; import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { AssetResponseDto, AlbumResponseDto } from '@api'; import { AssetResponseDto, AlbumResponseDto } from '@api';
type Leaflet = typeof import('leaflet'); type Leaflet = typeof import('leaflet');
@@ -31,13 +30,6 @@
if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) { if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude); await drawMap(asset.exifInfo.latitude, asset.exifInfo.longitude);
} }
// remove timezone when user not config PUBLIC_TZ var. Etc/UTC is used in default.
if (asset.exifInfo?.dateTimeOriginal && !env.PUBLIC_TZ) {
const dateTimeOriginal = asset.exifInfo.dateTimeOriginal;
asset.exifInfo.dateTimeOriginal = dateTimeOriginal.slice(0, dateTimeOriginal.length - 1);
}
} }
}); });
@@ -216,7 +208,7 @@
<p class="text-sm pb-4">APPEARS IN</p> <p class="text-sm pb-4">APPEARS IN</p>
{/if} {/if}
{#each albums as album} {#each albums as album}
<a sveltekit:prefetch href={`/albums/${album.id}`}> <a data-sveltekit-prefetch href={`/albums/${album.id}`}>
<div class="flex gap-4 py-2 hover:cursor-pointer" on:click={() => dispatch('click', album)}> <div class="flex gap-4 py-2 hover:cursor-pointer" on:click={() => dispatch('click', album)}>
<div> <div>
<img <img

View File

@@ -3,7 +3,7 @@
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import Close from 'svelte-material-icons/Close.svelte'; import Close from 'svelte-material-icons/Close.svelte';
import { createEventDispatcher, onMount, onDestroy } from 'svelte'; import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { browser } from '$app/env'; import { browser } from '$app/environment';
import CircleIconButton from './circle-icon-button.svelte'; import CircleIconButton from './circle-icon-button.svelte';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/env'; import { browser } from '$app/environment';
import { createEventDispatcher, onDestroy, onMount } from 'svelte'; import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import Close from 'svelte-material-icons/Close.svelte'; import Close from 'svelte-material-icons/Close.svelte';

View File

@@ -44,7 +44,11 @@
<section id="dashboard-navbar" class="fixed w-screen z-[100] bg-immich-bg text-sm"> <section id="dashboard-navbar" class="fixed w-screen z-[100] bg-immich-bg text-sm">
<div class="flex border-b place-items-center px-6 py-2 "> <div class="flex border-b place-items-center px-6 py-2 ">
<a sveltekit:prefetch class="flex gap-2 place-items-center hover:cursor-pointer" href="/photos"> <a
data-sveltekit-prefetch
class="flex gap-2 place-items-center hover:cursor-pointer"
href="/photos"
>
<img src="/immich-logo.svg" alt="immich logo" height="35" width="35" /> <img src="/immich-logo.svg" alt="immich logo" height="35" width="35" />
<h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1> <h1 class="font-immich-title text-2xl text-immich-primary">IMMICH</h1>
</a> </a>
@@ -67,7 +71,7 @@
{/if} {/if}
{#if user.isAdmin} {#if user.isAdmin}
<a sveltekit:prefetch href={`admin`}> <a data-sveltekit-prefetch href={`admin`}>
<button <button
class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${ class={`flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium ${
$page.url.pathname == '/admin' && 'text-immich-primary underline' $page.url.pathname == '/admin' && 'text-immich-primary underline'

View File

@@ -55,8 +55,8 @@
<section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6"> <section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6">
<!-- {domCount} --> <!-- {domCount} -->
<a <a
sveltekit:prefetch data-sveltekit-prefetch
sveltekit:noscroll data-sveltekit-noscroll
href={$page.routeId !== 'photos' ? `/photos` : null} href={$page.routeId !== 'photos' ? `/photos` : null}
class="relative" class="relative"
> >
@@ -92,7 +92,11 @@
</div> </div>
</a> </a>
<a sveltekit:prefetch href={$page.routeId !== 'sharing' ? `/sharing` : null} class="relative"> <a
data-sveltekit-prefetch
href={$page.routeId !== 'sharing' ? `/sharing` : null}
class="relative"
>
<SideBarButton <SideBarButton
title="Sharing" title="Sharing"
logo={AccountMultipleOutline} logo={AccountMultipleOutline}
@@ -126,7 +130,7 @@
<div class="text-xs ml-5 my-4"> <div class="text-xs ml-5 my-4">
<p>LIBRARY</p> <p>LIBRARY</p>
</div> </div>
<a sveltekit:prefetch href={$page.routeId !== 'albums' ? `/albums` : null} class="relative"> <a data-sveltekit-prefetch href={$page.routeId !== 'albums' ? `/albums` : null} class="relative">
<SideBarButton <SideBarButton
title="Albums" title="Albums"
logo={ImageAlbum} logo={ImageAlbum}

View File

@@ -1,2 +1,2 @@
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
export const loginPageMessage: string = env.PUBLIC_LOGIN_PAGE_MESSAGE; export const loginPageMessage: string | undefined = env.PUBLIC_LOGIN_PAGE_MESSAGE;

View File

@@ -31,7 +31,7 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
fileSelector.type = 'file'; fileSelector.type = 'file';
fileSelector.multiple = true; fileSelector.multiple = true;
fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp'; fileSelector.accept = 'image/*,video/*,.heic,.heif,.dng,.3gp,.nef';
fileSelector.onchange = async (e: Event) => { fileSelector.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;

View File

@@ -7,12 +7,12 @@
<code class="text-xs text-red-500">Error code {$page.status}</code> <code class="text-xs text-red-500">Error code {$page.status}</code>
<br /> <br />
<code class="text-sm"> <code class="text-sm">
{$page.error.message} {$page.error?.message}
</code> </code>
<br /> <br />
<div class="mt-5"> <div class="mt-5">
<p class="text-sm font-medium">Verbose</p> <p class="text-sm font-medium">Verbose</p>
<pre class="text-xs">{Object.values($page.error)}</pre> <pre class="text-xs">{JSON.stringify($page.error)}</pre>
</div> </div>
<a <a

View File

@@ -1,8 +1,8 @@
export const prerender = false; export const prerender = false;
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { api } from '@api'; import { api } from '@api';
import { browser } from '$app/env';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { browser } from '$app/environment';
export const load: PageLoad = async ({ parent }) => { export const load: PageLoad = async ({ parent }) => {
const { user } = await parent(); const { user } = await parent();

View File

@@ -72,7 +72,7 @@
<div class="flex flex-wrap gap-8"> <div class="flex flex-wrap gap-8">
{#each $albums as album} {#each $albums as album}
{#key album.id} {#key album.id}
<a sveltekit:prefetch href={`albums/${album.id}`}> <a data-sveltekit-prefetch href={`albums/${album.id}`}>
<AlbumCard <AlbumCard
{album} {album}
on:showalbumcontextmenu={(e) => showAlbumContextMenu(e.detail, album)} on:showalbumcontextmenu={(e) => showAlbumContextMenu(e.detail, album)}

View File

@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ parent }) => {
user user
}; };
} catch (e) { } catch (e) {
console.log('Photo page load error', e);
throw redirect(302, '/auth/login'); throw redirect(302, '/auth/login');
} }
}; };

View File

@@ -70,7 +70,7 @@
<!-- Share Album List --> <!-- Share Album List -->
<div class="w-full flex flex-col place-items-center"> <div class="w-full flex flex-col place-items-center">
{#each data.sharedAlbums as album} {#each data.sharedAlbums as album}
<a sveltekit:prefetch href={`albums/${album.id}`}> <a data-sveltekit-prefetch href={`albums/${album.id}`}>
<SharedAlbumListTile {album} user={data.user} /> <SharedAlbumListTile {album} user={data.user} />
</a> </a>
{/each} {/each}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -6,10 +6,7 @@ const config = {
preprocess: preprocess(), preprocess: preprocess(),
kit: { kit: {
adapter: adapter({ out: 'build' }), adapter: adapter({ out: 'build' })
methodOverride: {
allowed: ['PATCH', 'DELETE']
}
} }
}; };