Compare commits

...

34 Commits

Author SHA1 Message Date
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
Jonas Janz
97aed8ef23 fix(nginx): revert nginx image to support arm/v7 (#692) 2022-09-14 13:36:29 -05:00
Alex
0ee3fe9157 Update install.sh to use latest released tag 2022-09-14 11:07:37 -05:00
Alex
434770155f Up version for release 2022-09-14 10:27:34 -05:00
Alex
7e8bf94543 fix/cache read write error ios16 (#691)
* Fix(mobile) cache read/write issue, cannot load image on ios16

* Update
2022-09-14 10:18:25 -05:00
Zack Pollard
8d8944705c Merge pull request #690 from beune/fix-typo
Fix typo
2022-09-14 13:16:47 +01:00
Pim Beune
7c9c1a5169 Fix typo 2022-09-14 13:53:34 +02:00
Jonas Janz
1a6c16d8ea breaking(setup): use non-root image for immich-proxy (#651)
* feat(nginx): use non-root container for immich-proxy

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>

* re-add test env

* feat(nginx): add correct port for staging

* add the new port to the default docker-compose.yml

Signed-off-by: PixelJonas <5434875+PixelJonas@users.noreply.github.com>
2022-09-13 21:50:10 -05:00
Alex
ccf792f9d3 fix(server): mismatch createdAt value in table and table (#688) 2022-09-13 20:12:42 -05:00
Fynn Petersen-Frey
789bc8563c fix Android BackgroundServiceStartNotAllowedException (#687) 2022-09-13 20:12:31 -05:00
Manuel
99a50f70dd readme: add app store links (#689) 2022-09-13 18:23:27 -05:00
Alex Tran
9bef411056 Up server version: 2022-09-13 12:14:36 -05:00
Alex
e79e92c60f Added Log level to background service (#685) 2022-09-13 12:09:57 -05:00
Alex
858ad43d3b fix(server): harden inserting process, self-healing timestamp info on bad timestamp (#682)
* fix(server): harden inserting process, self-healing timestamp info
2022-09-12 23:35:44 -05:00
Alex
5761765ea7 fix(server): remove album thumbnail when the asset is deleted from the database (#681) 2022-09-12 22:06:52 -05:00
Thanh Pham
6abc733763 fix(web): datetime display and add TZ into environment (#618)
* fix(web): timezone

* doc(): update readme.md

* feat(web): keep using UTC timezone in default

* chore(): update doc and remove debug code

* chore(): update readme.md

* Move timezone into to .env.example

* Run prettier check

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2022-09-12 14:40:18 -05:00
Alex Tran
4271e24e59 Up version for release 2022-09-11 16:05:53 -05:00
Alex
9e4ed2214b fix(web): incorrect shared album count (#677) 2022-09-11 10:07:04 -05:00
Alex
011332e509 fix(mobile) memory leaked causes app to crash when swiping (#673)
* Dispose image provider when swiping away from the asset
2022-09-11 09:56:26 -05:00
Alex
5403ef4d84 Fix(mobile) oversize play button (#672) 2022-09-11 00:25:04 -05:00
90 changed files with 6739 additions and 3588 deletions

View File

@@ -147,6 +147,7 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
* Populate `UPLOAD_LOCATION` as prefered location for storing backup assets. * Populate `UPLOAD_LOCATION` as prefered location for storing backup assets.
* Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128` * Populate a secret value for `JWT_SECRET`, you can use this command: `openssl rand -base64 128`
* [Optional] Populate Mapbox value to use reverse geocoding. * [Optional] Populate Mapbox value to use reverse geocoding.
* [Optional] Populate `TZ` as your timezone, default is `Etc/UTC`.
### Step 3 - Start the containers ### Step 3 - Start the containers
@@ -185,9 +186,9 @@ docker-compose pull && docker-compose up -d
| F-Droid | Google Play | iOS | | F-Droid | Google Play | iOS |
| - | - | - | | - | - | - |
| <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <img src="design/google-play-qr-code.png" width="200" title="Google Play Store"> <p/> | <p align="left"> <img src="design/ios-qr-code.png" width="200" title="Apple App Store"> <p/> | | <a href="https://f-droid.org/packages/app.alextran.immich"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80"></a> | <p align="left"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich"><img src="design/google-play-qr-code.png" width="200" title="Google Play Store"></a> <p/> | <p align="left"> <a href="https://apps.apple.com/us/app/immich/id1613945652"><img src="design/ios-qr-code.png" width="200" title="Apple App Store"></a> <p/> |
> *The App version might be lagging behind the latest release due to the review process.* > *The Play/App Store version might be lagging behind the latest release due to the review process.*
<br/> <br/>
@@ -236,7 +237,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`:
@@ -244,7 +245,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

@@ -36,6 +36,11 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Log message level - [simple|verbose]
###################################################################################
LOG_LEVEL=simple
################################################################################### ###################################################################################
@@ -64,3 +69,11 @@ 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

@@ -102,8 +102,7 @@ services:
context: ../nginx context: ../nginx
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- 2283:80 - 2283:8080
- 2284:443
logging: logging:
driver: none driver: none
depends_on: depends_on:

View File

@@ -72,8 +72,7 @@ services:
container_name: immich_proxy container_name: immich_proxy
image: altran1502/immich-proxy:staging image: altran1502/immich-proxy:staging
ports: ports:
- 2283:80 - 2283:8080
- 2284:443
logging: logging:
driver: none driver: none
depends_on: depends_on:

View File

@@ -47,6 +47,8 @@ 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:
@@ -72,7 +74,7 @@ services:
container_name: immich_proxy container_name: immich_proxy
image: altran1502/immich-proxy:release image: altran1502/immich-proxy:release
ports: ports:
- 2283:80 - 2283:8080
logging: logging:
driver: none driver: none
depends_on: depends_on:

View File

@@ -2,10 +2,19 @@ echo "Starting Immich installation..."
ip_address=$(hostname -I | awk '{print $1}') ip_address=$(hostname -I | awk '{print $1}')
release_version=$(curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" |
grep '"tag_name":' |
sed -E 's/.*"([^"]+)".*/\1/')
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\032[0;31m' GREEN='\032[0;31m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
get_release_version() {
curl --silent "https://api.github.com/repos/immich-app/immich/releases/latest" | # Get latest release from GitHub api
grep '"tag_name":' | # Get tag line
sed -E 's/.*"([^"]+)".*/\1/' # Pluck JSON value
}
create_immich_directory() { create_immich_directory() {
echo "Creating Immich directory..." echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data mkdir -p ./immich-app/immich-data
@@ -13,12 +22,12 @@ create_immich_directory() {
download_docker_compose_file() { download_docker_compose_file() {
echo "Downloading docker-compose.yml..." echo "Downloading docker-compose.yml..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1 curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/docker-compose.yml -o ./immich-app/docker-compose.yml >/dev/null 2>&1
} }
download_dot_env_file() { download_dot_env_file() {
echo "Downloading .env file..." echo "Downloading .env file..."
curl -L https://raw.githubusercontent.com/immich-app/immich/main/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1 curl -L https://raw.githubusercontent.com/immich-app/immich/$release_version/docker/.env.example -o ./immich-app/.env >/dev/null 2>&1
} }
populate_upload_location() { populate_upload_location() {
@@ -41,9 +50,9 @@ populate_upload_location() {
start_docker_compose() { start_docker_compose() {
echo "Starting Immich's docker containers" echo "Starting Immich's docker containers"
if docker compose &> /dev/null; then if docker compose &>/dev/null; then
docker_bin="docker compose" docker_bin="docker compose"
elif docker-compose &> /dev/null; then elif docker-compose &>/dev/null; then
docker_bin="docker-compose" docker_bin="docker-compose"
else else
echo 'Cannot find `docker compose` or `docker-compose`.' echo 'Cannot find `docker compose` or `docker-compose`.'
@@ -64,7 +73,7 @@ show_friendly_message() {
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api" echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
echo "The backup (or upload) location is $upload_location" echo "The backup (or upload) location is $upload_location"
echo "---------------------------------------------------" echo "---------------------------------------------------"
echo "If you want to confgure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc. echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory, 1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,

View File

@@ -14,7 +14,12 @@ class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
startService(Intent(getBaseContext(), AppClearedService::class.java)); try {
startService(Intent(getBaseContext(), AppClearedService::class.java));
} catch (e: Exception) {
// startService must not be called when app is in background (crashes app)
// there is nothing we can do
}
} }
} }

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" => 40, "android.injected.version.code" => 43,
"android.injected.version.name" => "1.28.2", "android.injected.version.name" => "1.29.1",
} }
) )
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,2 @@
* Fixed oversize play button on video
* Fixed app crashing when swipe between assets

View File

@@ -0,0 +1,2 @@
* Fixed Android BackgroundServiceStartNotAllowedException
* Restore old cache mechanism

View File

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

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000222"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="55.311329"> <testcase classname="fastlane.lanes" name="1: bundleRelease" time="58.722434">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.070842"> <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.768014">
</testcase> </testcase>

View File

@@ -360,11 +360,11 @@
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 = 52; 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;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -495,11 +495,11 @@
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 = 52; 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;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -522,11 +522,11 @@
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 = 52; 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;
IPHONEOS_DEPLOYMENT_TARGET = 11.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

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.27.0</string> <string>1.30.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>52</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.28.2" version_number: "1.29.1"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000173">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192"> <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="30.057077"> <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.438506"> <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.408563">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106"> <testcase classname="fastlane.lanes" name="4: build_app" time="65.350555">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139"> <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.894733">
</testcase> </testcase>

View File

@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -15,11 +14,9 @@ class AlbumThumbnailCard extends StatelessWidget {
const AlbumThumbnailCard({ const AlbumThumbnailCard({
Key? key, Key? key,
required this.album, required this.album,
required this.cacheService,
}) : super(key: key); }) : super(key: key);
final AlbumResponseDto album; final AlbumResponseDto album;
final CacheService cacheService;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -39,7 +36,6 @@ class AlbumThumbnailCard extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.albumThumbnail),
memCacheHeight: max(400, cardSize.toInt() * 3), memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize, width: cardSize,
height: cardSize, height: cardSize,

View File

@@ -1,15 +1,12 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -17,13 +14,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
final AssetResponseDto asset; final AssetResponseDto asset;
final List<AssetResponseDto> assetList; final List<AssetResponseDto> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
const AlbumViewerThumbnail({ const AlbumViewerThumbnail({
Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.assetList,
this.cacheManager,
this.showStorageIndicator = true, this.showStorageIndicator = true,
}) : super(key: key); }) : super(key: key);
@@ -126,7 +121,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
return Container( return Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheManager: cacheManager,
cacheKey: asset.id, cacheKey: asset.id,
width: 300, width: 300,
height: 300, height: 300,

View File

@@ -1,11 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -24,7 +22,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
var newAssetsForAlbum = var newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
final cacheService = ref.watch(cacheServiceProvider);
Widget _buildSelectionIcon(AssetResponseDto asset) { Widget _buildSelectionIcon(AssetResponseDto asset) {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id); var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
@@ -114,7 +111,6 @@ class SelectionThumbnailImage extends HookConsumerWidget {
Container( Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id, cacheKey: asset.id,
width: 150, width: 150,
height: 150, height: 150,

View File

@@ -1,10 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -16,7 +14,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final cacheService = ref.watch(cacheServiceProvider);
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
return GestureDetector( return GestureDetector(
@@ -26,7 +23,6 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
CachedNetworkImage( CachedNetworkImage(
cacheManager: cacheService.getCache(CacheType.thumbnail),
cacheKey: asset.id, cacheKey: asset.id,
width: 500, width: 500,
height: 500, height: 500,

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@@ -192,7 +191,6 @@ class AlbumViewerPage extends HookConsumerWidget {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
final bool showStorageIndicator = final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator); appSettingService.getSetting(AppSettingsEnum.storageIndicator);
final cacheService = ref.watch(cacheServiceProvider);
if (albumInfo.assets.isNotEmpty) { if (albumInfo.assets.isNotEmpty) {
return SliverPadding( return SliverPadding(
@@ -207,7 +205,6 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return AlbumViewerThumbnail( return AlbumViewerThumbnail(
cacheManager: cacheService.getCache(CacheType.thumbnail),
asset: albumInfo.assets[index], asset: albumInfo.assets[index],
assetList: albumInfo.assets, assetList: albumInfo.assets,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,

View File

@@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart'; import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
class LibraryPage extends HookConsumerWidget { class LibraryPage extends HookConsumerWidget {
const LibraryPage({Key? key}) : super(key: key); const LibraryPage({Key? key}) : super(key: key);
@@ -14,7 +13,6 @@ class LibraryPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider); final albums = ref.watch(albumProvider);
final cacheService = ref.watch(cacheServiceProvider);
useEffect( useEffect(
() { () {
@@ -104,7 +102,6 @@ class LibraryPage extends HookConsumerWidget {
_buildCreateAlbumButton(), _buildCreateAlbumButton(),
for (var album in albums) for (var album in albums)
AlbumThumbnailCard( AlbumThumbnailCard(
cacheService: cacheService,
album: album, album: album,
), ),
], ],

View File

@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart'; import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -21,7 +20,6 @@ class SharingPage extends HookConsumerWidget {
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail'; var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider); final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
final CacheService cacheService = ref.watch(cacheServiceProvider);
useEffect( useEffect(
() { () {
@@ -47,8 +45,6 @@ class SharingPage extends HookConsumerWidget {
height: 60, height: 60,
memCacheHeight: 200, memCacheHeight: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager:
cacheService.getCache(CacheType.sharedAlbumThumbnail),
imageUrl: getAlbumThumbnailUrl(album), imageUrl: getAlbumThumbnailUrl(album),
cacheKey: album.albumThumbnailAssetId, cacheKey: album.albumThumbnailAssetId,
httpHeaders: { httpHeaders: {

View File

@@ -1,7 +1,5 @@
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:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
enum _RemoteImageStatus { empty, thumbnail, preview, full } enum _RemoteImageStatus { empty, thumbnail, preview, full }
@@ -12,6 +10,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
bool _zoomedIn = false; bool _zoomedIn = false;
static const int swipeThreshold = 100; static const int swipeThreshold = 100;
late CachedNetworkImageProvider fullProvider;
late CachedNetworkImageProvider previewProvider;
late CachedNetworkImageProvider thumbnailProvider;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -56,21 +57,14 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
widget.isZoomedFunction(); widget.isZoomedFunction();
} }
void _fireStartLoadingEvent() {
widget.onLoadingStart();
}
void _fireFinishedLoadingEvent() {
widget.onLoadingCompleted();
}
CachedNetworkImageProvider _authorizedImageProvider( CachedNetworkImageProvider _authorizedImageProvider(
String url, String cacheKey, BaseCacheManager? cacheManager) { String url,
String cacheKey,
) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
url, url,
headers: {"Authorization": widget.authToken}, headers: {"Authorization": widget.authToken},
cacheKey: cacheKey, cacheKey: cacheKey,
cacheManager: cacheManager,
); );
} }
@@ -91,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;
@@ -104,10 +92,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
void _loadImages() { void _loadImages() {
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider( thumbnailProvider = _authorizedImageProvider(
widget.thumbnailUrl, widget.thumbnailUrl,
widget.cacheKey, widget.cacheKey,
widget.thumbnailCacheManager,
); );
_imageProvider = thumbnailProvider; _imageProvider = thumbnailProvider;
@@ -121,10 +108,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
if (widget.previewUrl != null) { if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider = _authorizedImageProvider( previewProvider = _authorizedImageProvider(
widget.previewUrl!, widget.previewUrl!,
"${widget.cacheKey}_previewStage", "${widget.cacheKey}_previewStage",
widget.previewCacheManager,
); );
previewProvider.resolve(const ImageConfiguration()).addListener( previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
@@ -133,10 +119,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
); );
} }
CachedNetworkImageProvider fullProvider = _authorizedImageProvider( fullProvider = _authorizedImageProvider(
widget.imageUrl, widget.imageUrl,
"${widget.cacheKey}_fullStage", "${widget.cacheKey}_fullStage",
widget.fullCacheManager,
); );
fullProvider.resolve(const ImageConfiguration()).addListener( fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
@@ -147,8 +132,23 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
@override @override
void initState() { void initState() {
_loadImages();
super.initState(); super.initState();
_loadImages();
}
@override
void dispose() async {
super.dispose();
if (_status == _RemoteImageStatus.full) {
await fullProvider.evict();
} else if (_status == _RemoteImageStatus.preview) {
await previewProvider.evict();
} else if (_status == _RemoteImageStatus.thumbnail) {
await thumbnailProvider.evict();
}
await _imageProvider.evict();
} }
} }
@@ -163,11 +163,6 @@ class RemotePhotoView extends StatefulWidget {
required this.onSwipeDown, required this.onSwipeDown,
required this.onSwipeUp, required this.onSwipeUp,
this.previewUrl, this.previewUrl,
required this.onLoadingCompleted,
required this.onLoadingStart,
this.thumbnailCacheManager,
this.previewCacheManager,
this.fullCacheManager,
required this.cacheKey, required this.cacheKey,
}) : super(key: key); }) : super(key: key);
@@ -175,11 +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 BaseCacheManager? thumbnailCacheManager;
final BaseCacheManager? previewCacheManager;
final BaseCacheManager? fullCacheManager;
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

@@ -8,7 +8,6 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -19,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({
@@ -30,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);
@@ -41,7 +36,6 @@ class ImageViewerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadAssetStatus = final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus; ref.watch(imageViewerStateProvider).downloadAssetStatus;
final cacheService = ref.watch(cacheServiceProvider);
getAssetExif() async { getAssetExif() async {
assetDetail = assetDetail =
@@ -85,14 +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,
thumbnailCacheManager:
cacheService.getCache(CacheType.thumbnail),
previewCacheManager:
cacheService.getCache(CacheType.imageViewerPreview),
fullCacheManager:
cacheService.getCache(CacheType.imageViewerFull),
), ),
), ),
), ),

View File

@@ -79,7 +79,7 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
_createChewieController() { _createChewieController() {
chewieController = ChewieController( chewieController = ChewieController(
showOptions: true, showOptions: true,
showControlsOnInitialize: true, showControlsOnInitialize: false,
videoPlayerController: videoPlayerController, videoPlayerController: videoPlayerController,
autoPlay: true, autoPlay: true,
autoInitialize: true, autoInitialize: true,

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

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@@ -10,13 +9,11 @@ class ImageGrid extends ConsumerWidget {
final List<AssetResponseDto> sortedAssetGroup; final List<AssetResponseDto> sortedAssetGroup;
final int tilesPerRow; final int tilesPerRow;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
ImageGrid({ ImageGrid({
Key? key, Key? key,
required this.assetGroup, required this.assetGroup,
required this.sortedAssetGroup, required this.sortedAssetGroup,
this.cacheManager,
this.tilesPerRow = 4, this.tilesPerRow = 4,
this.showStorageIndicator = true, this.showStorageIndicator = true,
}) : super(key: key); }) : super(key: key);
@@ -39,7 +36,6 @@ class ImageGrid extends ConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
ThumbnailImage( ThumbnailImage(
cacheManager: cacheManager,
asset: assetGroup[index], asset: assetGroup[index],
assetList: sortedAssetGroup, assetList: sortedAssetGroup,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,

View File

@@ -2,8 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
@@ -17,13 +15,11 @@ class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset; final AssetResponseDto asset;
final List<AssetResponseDto> assetList; final List<AssetResponseDto> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final BaseCacheManager? cacheManager;
const ThumbnailImage({ const ThumbnailImage({
Key? key, Key? key,
required this.asset, required this.asset,
required this.assetList, required this.assetList,
this.cacheManager,
this.showStorageIndicator = true, this.showStorageIndicator = true,
}) : super(key: key); }) : super(key: key);
@@ -36,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,
@@ -52,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) {
@@ -95,11 +90,12 @@ class ThumbnailImage extends HookConsumerWidget {
: const Border(), : const Border(),
), ),
child: CachedNetworkImage( child: CachedNetworkImage(
cacheKey: asset.id, cacheKey: 'thumbnail-image-${asset.id}',
cacheManager: cacheManager,
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: {
@@ -115,6 +111,8 @@ 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,
@@ -127,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

@@ -16,7 +16,6 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/cache.service.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class HomePage extends HookConsumerWidget { class HomePage extends HookConsumerWidget {
@@ -25,7 +24,6 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
final cacheService = ref.watch(cacheServiceProvider);
ScrollController scrollController = useScrollController(); ScrollController scrollController = useScrollController();
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider); var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
@@ -91,7 +89,6 @@ class HomePage extends HookConsumerWidget {
imageGridGroup.add( imageGridGroup.add(
ImageGrid( ImageGrid(
cacheManager: cacheService.getCache(CacheType.thumbnail),
assetGroup: immichAssetList, assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList, sortedAssetGroup: sortedAssetList,
tilesPerRow: tilesPerRow:

View File

@@ -15,7 +15,6 @@ class CacheSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final CacheService cacheService = ref.watch(cacheServiceProvider); final CacheService cacheService = ref.watch(cacheServiceProvider);
final clearCacheState = useState(false); final clearCacheState = useState(false);
Future<void> clearCache() async { Future<void> clearCache() async {

View File

@@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart'; import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart'; import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart'; import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart'; import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
@@ -42,7 +41,6 @@ class SettingsPage extends HookConsumerWidget {
const ImageViewerQualitySetting(), const ImageViewerQualitySetting(),
const ThemeSetting(), const ThemeSetting(),
const AssetListSettings(), const AssetListSettings(),
const CacheSettings(),
if (Platform.isAndroid) const NotificationSetting(), if (Platform.isAndroid) const NotificationSetting(),
], ],
).toList(), ).toList(),

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

@@ -7,7 +7,6 @@ import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
enum CacheType { enum CacheType {
// Shared cache for asset thumbnails in various modules // Shared cache for asset thumbnails in various modules
thumbnail, thumbnail,
imageViewerPreview, imageViewerPreview,
imageViewerFull, imageViewerFull,
albumThumbnail, albumThumbnail,
@@ -67,7 +66,10 @@ class CacheService {
} }
BaseCacheManager _getDefaultCache( BaseCacheManager _getDefaultCache(
String cacheName, int size, CacheInfoRepository repo) { String cacheName,
int size,
CacheInfoRepository repo,
) {
return CacheManager( return CacheManager(
Config( Config(
cacheName, cacheName,

View File

@@ -115,4 +115,3 @@ lib/model/user_count_response_dto.dart
lib/model/user_response_dto.dart lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart lib/model/validate_access_token_response_dto.dart
pubspec.yaml pubspec.yaml
test/asset_count_by_user_id_response_dto_test.dart

View File

@@ -76,69 +76,72 @@ class AssetResponseDto {
SmartInfoResponseDto? smartInfo; SmartInfoResponseDto? smartInfo;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && bool operator ==(Object other) =>
other.type == type && identical(this, other) ||
other.id == id && other is AssetResponseDto &&
other.deviceAssetId == deviceAssetId && other.type == type &&
other.ownerId == ownerId && other.id == id &&
other.deviceId == deviceId && other.deviceAssetId == deviceAssetId &&
other.originalPath == originalPath && other.ownerId == ownerId &&
other.resizePath == resizePath && other.deviceId == deviceId &&
other.createdAt == createdAt && other.originalPath == originalPath &&
other.modifiedAt == modifiedAt && other.resizePath == resizePath &&
other.isFavorite == isFavorite && other.createdAt == createdAt &&
other.mimeType == mimeType && other.modifiedAt == modifiedAt &&
other.duration == duration && other.isFavorite == isFavorite &&
other.webpPath == webpPath && other.mimeType == mimeType &&
other.encodedVideoPath == encodedVideoPath && other.duration == duration &&
other.exifInfo == exifInfo && other.webpPath == webpPath &&
other.smartInfo == smartInfo; other.encodedVideoPath == encodedVideoPath &&
other.exifInfo == exifInfo &&
other.smartInfo == smartInfo;
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(type.hashCode) + (type.hashCode) +
(id.hashCode) + (id.hashCode) +
(deviceAssetId.hashCode) + (deviceAssetId.hashCode) +
(ownerId.hashCode) + (ownerId.hashCode) +
(deviceId.hashCode) + (deviceId.hashCode) +
(originalPath.hashCode) + (originalPath.hashCode) +
(resizePath == null ? 0 : resizePath!.hashCode) + (resizePath == null ? 0 : resizePath!.hashCode) +
(createdAt.hashCode) + (createdAt.hashCode) +
(modifiedAt.hashCode) + (modifiedAt.hashCode) +
(isFavorite.hashCode) + (isFavorite.hashCode) +
(mimeType == null ? 0 : mimeType!.hashCode) + (mimeType == null ? 0 : mimeType!.hashCode) +
(duration.hashCode) + (duration.hashCode) +
(webpPath == null ? 0 : webpPath!.hashCode) + (webpPath == null ? 0 : webpPath!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
(exifInfo == null ? 0 : exifInfo!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) +
(smartInfo == null ? 0 : smartInfo!.hashCode); (smartInfo == null ? 0 : smartInfo!.hashCode);
@override @override
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]'; String toString() =>
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final _json = <String, dynamic>{}; final _json = <String, dynamic>{};
_json[r'type'] = type; _json[r'type'] = type;
_json[r'id'] = id; _json[r'id'] = id;
_json[r'deviceAssetId'] = deviceAssetId; _json[r'deviceAssetId'] = deviceAssetId;
_json[r'ownerId'] = ownerId; _json[r'ownerId'] = ownerId;
_json[r'deviceId'] = deviceId; _json[r'deviceId'] = deviceId;
_json[r'originalPath'] = originalPath; _json[r'originalPath'] = originalPath;
if (resizePath != null) { if (resizePath != null) {
_json[r'resizePath'] = resizePath; _json[r'resizePath'] = resizePath;
} else { } else {
_json[r'resizePath'] = null; _json[r'resizePath'] = null;
} }
_json[r'createdAt'] = createdAt; _json[r'createdAt'] = createdAt;
_json[r'modifiedAt'] = modifiedAt; _json[r'modifiedAt'] = modifiedAt;
_json[r'isFavorite'] = isFavorite; _json[r'isFavorite'] = isFavorite;
if (mimeType != null) { if (mimeType != null) {
_json[r'mimeType'] = mimeType; _json[r'mimeType'] = mimeType;
} else { } else {
_json[r'mimeType'] = null; _json[r'mimeType'] = null;
} }
_json[r'duration'] = duration; _json[r'duration'] = duration;
if (webpPath != null) { if (webpPath != null) {
_json[r'webpPath'] = webpPath; _json[r'webpPath'] = webpPath;
} else { } else {
@@ -172,13 +175,13 @@ class AssetResponseDto {
// Ensure that the map contains the required keys. // Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null. // Note 1: the values aren't checked for validity beyond being non-null.
// 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), 'Required key "AssetResponseDto[$key]" is missing from JSON.'); // assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.'); // assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
}); // });
return true; // return true;
}()); // }());
return AssetResponseDto( return AssetResponseDto(
type: AssetTypeEnum.fromJson(json[r'type'])!, type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -202,7 +205,10 @@ class AssetResponseDto {
return null; return null;
} }
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { static List<AssetResponseDto>? listFromJson(
dynamic json, {
bool growable = false,
}) {
final result = <AssetResponseDto>[]; final result = <AssetResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
@@ -230,12 +236,18 @@ class AssetResponseDto {
} }
// maps a json object with a list of AssetResponseDto-objects as value to a dart map // maps a json object with a list of AssetResponseDto-objects as value to a dart map
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<AssetResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
final map = <String, List<AssetResponseDto>>{}; final map = <String, List<AssetResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,); final value = AssetResponseDto.listFromJson(
entry.value,
growable: growable,
);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -262,4 +274,3 @@ class AssetResponseDto {
'encodedVideoPath', 'encodedVideoPath',
}; };
} }

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:
@@ -126,21 +126,21 @@ packages:
name: cached_network_image name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.2.1" version: "3.2.2"
cached_network_image_platform_interface: cached_network_image_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: cached_network_image_platform_interface name: cached_network_image_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.0" version: "2.0.0"
cached_network_image_web: cached_network_image_web:
dependency: transitive dependency: transitive
description: description:
name: cached_network_image_web name: cached_network_image_web
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
cancellation_token: cancellation_token:
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:
@@ -182,7 +175,7 @@ packages:
name: chewie name: chewie
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.2" version: "1.3.5"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@@ -238,7 +231,7 @@ packages:
name: cupertino_icons name: cupertino_icons
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
dart_style: dart_style:
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:
@@ -839,7 +832,7 @@ packages:
name: provider name: provider
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.2" version: "6.0.3"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
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:
@@ -1223,27 +1202,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
very_good_analysis:
dependency: transitive
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
video_player: video_player:
dependency: "direct main" dependency: "direct main"
description: description:
name: video_player name: video_player
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.4.2" version: "2.4.7"
video_player_android: video_player_android:
dependency: transitive dependency: transitive
description: description:
name: video_player_android name: video_player_android
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.3.3" version: "2.3.9"
video_player_avfoundation: video_player_avfoundation:
dependency: transitive dependency: transitive
description: description:
@@ -1271,7 +1243,7 @@ packages:
name: wakelock name: wakelock
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.6.1+2" version: "0.6.2"
wakelock_macos: wakelock_macos:
dependency: transitive dependency: transitive
description: description:
@@ -1341,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:
@@ -1350,5 +1322,5 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.17.0 <3.0.0" dart: ">=2.18.0 <3.0.0"
flutter: ">=3.0.0" flutter: ">=3.3.0"

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.28.2+40 version: 1.29.1+43
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@@ -11,22 +11,22 @@ 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
hive_flutter: ^1.1.0 hive_flutter: ^1.1.0
dio: ^4.0.4 dio: ^4.0.4
cached_network_image: ^3.2.1 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"
fluttertoast: ^8.0.8 fluttertoast: ^8.0.8
video_player: ^2.2.18 video_player: ^2.2.18
chewie: ^1.2.2 chewie: ^1.3.5
badges: ^2.0.2 badges: ^2.0.2
photo_view: ^0.14.0 photo_view: ^0.14.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0 socket_io_client: ^2.0.0-beta.4-nullsafety.0
@@ -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

@@ -1,6 +1,5 @@
FROM nginx:latest FROM docker.io/nginxinc/nginx-unprivileged:latest
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf "/etc/nginx/nginx.conf"
EXPOSE 80 CMD nginx -g "daemon off;"
EXPOSE 443

View File

@@ -1,73 +1,93 @@
map $http_upgrade $connection_upgrade {
default upgrade; worker_processes auto;
'' close; error_log /var/log/nginx/error.log;
pid /tmp/nginx.pid;
# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
} }
# events { http {
# worker_connections 1000; map $http_upgrade $connection_upgrade {
# } default upgrade;
'' close;
server {
gzip on;
gzip_min_length 1000;
gunzip on;
client_max_body_size 50000M;
listen 80;
access_log off;
location /api {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
proxy_pass http://immich-server:3001;
} }
location / { client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# Compression # events {
gzip_static on; # worker_connections 1000;
# }
server {
gzip on;
gzip_min_length 1000; gzip_min_length 1000;
gzip_comp_level 2; gunzip on;
proxy_buffering off; client_max_body_size 50000M;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1; listen 8080;
proxy_set_header Host $host; access_log off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich-web:3000; location /api {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
rewrite /api/(.*) /$1 break;
proxy_pass http://immich-server:3001;
}
location / {
# Compression
gzip_static on;
gzip_min_length 1000;
gzip_comp_level 2;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k;
proxy_buffers 64 4k;
proxy_force_ranges on;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_pass http://immich-web:3000;
}
} }
} }

View File

@@ -50,9 +50,14 @@ export class AlbumRepository implements IAlbumRepository {
where: { sharedUserId: userId }, where: { sharedUserId: userId },
}); });
const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0); let sharedAlbumCount = 0;
ownedAlbums.map((album) => {
if (album.sharedUsers?.length) {
sharedAlbumCount += 1;
}
});
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums); return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
} }
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> { async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {

View File

@@ -8,6 +8,7 @@ import { AlbumEntity } from '../../../../../libs/database/src/entities/album.ent
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity'; import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
import { UserAlbumEntity } from '@app/database/entities/user-album.entity'; import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository'; import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])], imports: [TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity])],
@@ -18,6 +19,10 @@ import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
provide: ALBUM_REPOSITORY, provide: ALBUM_REPOSITORY,
useClass: AlbumRepository, useClass: AlbumRepository,
}, },
{
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
], ],
}) })
export class AlbumModule {} export class AlbumModule {}

View File

@@ -4,10 +4,13 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/database/entities/album.entity'; import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto'; import { AlbumResponseDto } from './response-dto/album-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
describe('Album service', () => { describe('Album service', () => {
let sut: AlbumService; let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>; let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
id: '1111', id: '1111',
email: 'auth@test.com', email: 'auth@test.com',
@@ -118,7 +121,22 @@ describe('Album service', () => {
getListByAssetId: jest.fn(), getListByAssetId: jest.fn(),
getCountByUserId: jest.fn(), getCountByUserId: jest.fn(),
}; };
sut = new AlbumService(albumRepositoryMock);
assetRepositoryMock = {
create: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetByTimeBucket: jest.fn(),
getAssetByChecksum: jest.fn(),
getAssetCountByUserId: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, assetRepositoryMock);
}); });
it('creates album', async () => { it('creates album', async () => {

View File

@@ -10,10 +10,14 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto'; import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository'; import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { ASSET_REPOSITORY, IAssetRepository } from '../asset/asset-repository';
@Injectable() @Injectable()
export class AlbumService { export class AlbumService {
constructor(@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository) {} constructor(
@Inject(ALBUM_REPOSITORY) private _albumRepository: IAlbumRepository,
@Inject(ASSET_REPOSITORY) private _assetRepository: IAssetRepository,
) {}
private async _getAlbum({ private async _getAlbum({
authUser, authUser,
@@ -54,6 +58,11 @@ export class AlbumService {
return albums.map(mapAlbumExcludeAssetInfo); return albums.map(mapAlbumExcludeAssetInfo);
} }
const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); const albums = await this._albumRepository.getList(authUser.id, getAlbumsDto);
for (const album of albums) {
await this._checkValidThumbnail(album);
}
return albums.map((album) => mapAlbumExcludeAssetInfo(album)); return albums.map((album) => mapAlbumExcludeAssetInfo(album));
} }
@@ -123,4 +132,18 @@ export class AlbumService {
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> { async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
return this._albumRepository.getCountByUserId(authUser.id); return this._albumRepository.getCountByUserId(authUser.id);
} }
async _checkValidThumbnail(album: AlbumEntity): Promise<AlbumEntity> {
const assetId = album.albumThumbnailAssetId;
if (assetId) {
try {
await this._assetRepository.getById(assetId);
} catch (e) {
album.albumThumbnailAssetId = null;
return await this._albumRepository.updateAlbum(album, {});
}
}
return album;
}
} }

View File

@@ -171,6 +171,7 @@ export class AssetRepository implements IAssetRepository {
.createQueryBuilder('asset') .createQueryBuilder('asset')
.where('asset.userId = :userId', { userId: userId }) .where('asset.userId = :userId', { userId: userId })
.andWhere('asset.resizePath is not NULL') .andWhere('asset.resizePath is not NULL')
.andWhere('asset.type = :type', { type: AssetType.IMAGE })
.leftJoinAndSelect('asset.exifInfo', 'exifInfo') .leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.orderBy('asset.createdAt', 'DESC'); .orderBy('asset.createdAt', 'DESC');
@@ -225,6 +226,7 @@ export class AssetRepository implements IAssetRepository {
where: { where: {
userId: userId, userId: userId,
deviceId: deviceId, deviceId: deviceId,
type: AssetType.IMAGE,
}, },
select: ['deviceAssetId'], select: ['deviceAssetId'],
}); });

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

@@ -36,6 +36,7 @@ import {
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto'; import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { timeUtils } from '@app/common/utils';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -56,6 +57,18 @@ export class AssetService {
mimeType: string, mimeType: string,
checksum: Buffer, checksum: Buffer,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
// Check valid time.
const createdAt = createAssetDto.createdAt;
const modifiedAt = createAssetDto.modifiedAt;
if (!timeUtils.checkValidTimestamp(createdAt)) {
createAssetDto.createdAt = await timeUtils.getTimestampFromExif(originalPath);
}
if (!timeUtils.checkValidTimestamp(modifiedAt)) {
createAssetDto.modifiedAt = await timeUtils.getTimestampFromExif(originalPath);
}
const assetEntity = await this._assetRepository.create( const assetEntity = await this._assetRepository.create(
createAssetDto, createAssetDto,
authUser.id, authUser.id,

View File

@@ -74,8 +74,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))

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

@@ -15,6 +15,7 @@ import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database'; import { DatabaseModule } from '@app/database';
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
@Module({ @Module({
imports: [ imports: [
@@ -64,7 +65,7 @@ export class AppModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
configure(consumer: MiddlewareConsumer): void { configure(consumer: MiddlewareConsumer): void {
if (process.env.NODE_ENV == 'development') { if (process.env.NODE_ENV == 'development') {
// consumer.apply(AppLoggerMiddleware).forRoutes('*'); consumer.apply(AppLoggerMiddleware).forRoutes('*');
} }
} }
} }

View File

@@ -6,6 +6,7 @@ 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) => {
@@ -19,17 +20,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 +38,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()}`); cb(null, sanitize(fileName));
}, },
}), }),
}; };

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

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = { export const serverVersion: IServerVersion = {
major: 1, major: 1,
minor: 28, minor: 29,
patch: 2, patch: 2,
build: 40, build: 43,
}; };

View File

@@ -13,7 +13,7 @@ import {
} from '@app/job/constants/queue-name.constant'; } from '@app/job/constants/queue-name.constant';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module'; import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
import { MicroservicesService } from './microservices.service'; import { MicroservicesService } from './microservices.service';
@@ -40,42 +40,48 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
}, },
}), }),
}), }),
BullModule.registerQueue({ BullModule.registerQueue(
name: thumbnailGeneratorQueueName, {
defaultJobOptions: { name: thumbnailGeneratorQueueName,
attempts: 3, defaultJobOptions: {
removeOnComplete: true, attempts: 3,
removeOnFail: false, removeOnComplete: true,
removeOnFail: false,
},
}, },
}, { {
name: assetUploadedQueueName, name: assetUploadedQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}, { {
name: metadataExtractionQueueName, name: metadataExtractionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}, { {
name: videoConversionQueueName, name: videoConversionQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}, { {
name: generateChecksumQueueName, name: generateChecksumQueueName,
defaultJobOptions: { defaultJobOptions: {
attempts: 3, attempts: 3,
removeOnComplete: true, removeOnComplete: true,
removeOnFail: false, removeOnFail: false,
},
}, },
}), ),
CommunicationModule, CommunicationModule,
], ],
controllers: [], controllers: [],
@@ -86,6 +92,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
MetadataExtractionProcessor, MetadataExtractionProcessor,
VideoTranscodeProcessor, VideoTranscodeProcessor,
GenerateChecksumProcessor, GenerateChecksumProcessor,
ConfigService,
], ],
exports: [], exports: [],
}) })

View File

@@ -1,3 +1,4 @@
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
@@ -16,6 +17,7 @@ import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding'; 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 { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios'; import axios from 'axios';
import { Job } from 'bull'; import { Job } from 'bull';
@@ -28,6 +30,7 @@ import { Repository } from 'typeorm/repository/Repository';
@Processor(metadataExtractionQueueName) @Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor { export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService; private geocodingClient?: GeocodeService;
private logLevel: ImmichLogLevel;
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
@@ -38,12 +41,16 @@ export class MetadataExtractionProcessor {
@InjectRepository(SmartInfoEntity) @InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>, private smartInfoRepository: Repository<SmartInfoEntity>,
private configService: ConfigService,
) { ) {
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) { if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
this.geocodingClient = mapboxGeocoding({ this.geocodingClient = mapboxGeocoding({
accessToken: process.env.MAPBOX_KEY, accessToken: process.env.MAPBOX_KEY,
}); });
} }
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
} }
@Process(exifExtractionProcessorName) @Process(exifExtractionProcessorName)
@@ -77,7 +84,7 @@ export class MetadataExtractionProcessor {
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null; newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null; newExif.fileSizeInByte = fileSize || null;
newExif.orientation = exifData['Orientation'] || null; newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null; newExif.dateTimeOriginal = new Date(asset.createdAt) || null;
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;
@@ -139,6 +146,10 @@ export class MetadataExtractionProcessor {
await this.exifRepository.save(newExif); await this.exifRepository.save(newExif);
} catch (e) { } catch (e) {
Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif'); Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif');
if (this.logLevel === ImmichLogLevel.VERBOSE) {
console.trace('Error extracting EXIF', e);
}
} }
} }

View File

@@ -1,3 +1,5 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity'; import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { import {
WebpGeneratorProcessor, WebpGeneratorProcessor,
@@ -11,18 +13,23 @@ import {
} from '@app/job'; } from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull'; import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto'; import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
import { Job, Queue } from 'bull'; 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 {
private logLevel: ImmichLogLevel;
constructor( constructor(
@InjectRepository(AssetEntity) @InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>, private assetRepository: Repository<AssetEntity>,
@@ -34,13 +41,20 @@ export class ThumbnailGeneratorProcessor {
@InjectQueue(metadataExtractionQueueName) @InjectQueue(metadataExtractionQueueName)
private metadataExtractionQueue: Queue, private metadataExtractionQueue: Queue,
) {}
private configService: ConfigService,
) {
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
}
@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 });
@@ -51,8 +65,16 @@ export class ThumbnailGeneratorProcessor {
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg'; const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
if (asset.type == AssetType.IMAGE) { if (asset.type == AssetType.IMAGE) {
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath); try {
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath }); await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
} catch (error) {
Logger.error('Failed to generate jpeg thumbnail for asset: ' + asset.id);
if (this.logLevel == ImmichLogLevel.VERBOSE) {
console.trace('Failed to generate jpeg thumbnail for asset', error);
}
}
// Update resize path to send to generate webp queue // Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath; asset.resizePath = jpegThumbnailPath;
@@ -105,7 +127,15 @@ export class ThumbnailGeneratorProcessor {
const webpPath = asset.resizePath.replace('jpeg', 'webp'); const webpPath = asset.resizePath.replace('jpeg', 'webp');
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath); try {
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath }); await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
} catch (error) {
Logger.error('Failed to generate webp thumbnail for asset: ' + asset.id);
if (this.logLevel == ImmichLogLevel.VERBOSE) {
console.trace('Failed to generate webp thumbnail for asset', error);
}
}
} }
} }

View File

@@ -16,5 +16,6 @@ export const immichAppConfig: ConfigModuleOptions = {
then: Joi.string().optional().allow(null, ''), then: Joi.string().optional().allow(null, ''),
otherwise: Joi.string().required(), otherwise: Joi.string().required(),
}), }),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
}), }),
}; };

View File

@@ -0,0 +1,4 @@
export enum ImmichLogLevel {
SIMPLE = 'simple',
VERBOSE = 'verbose',
}

View File

@@ -1,2 +1,3 @@
export * from './config'; export * from './config';
export * from './constants'; export * from './constants';
export * from './utils';

View File

@@ -0,0 +1 @@
export * from './time-utils';

View File

@@ -0,0 +1,37 @@
// create unit test for time utils
import { timeUtils } from './time-utils';
describe('Time Utilities', () => {
describe('checkValidTimestamp', () => {
it('check for year 0000', () => {
const result = timeUtils.checkValidTimestamp('0000-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
it('check for 6-digits year with plus sign', () => {
const result = timeUtils.checkValidTimestamp('+12345-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
it('check for 6-digits year with negative sign', () => {
const result = timeUtils.checkValidTimestamp('-12345-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
it('check for current date', () => {
const result = timeUtils.checkValidTimestamp(new Date().toISOString());
expect(result).toBeTruthy();
});
it('check for year before 1583', () => {
const result = timeUtils.checkValidTimestamp('1582-12-31T23:59:59.999Z');
expect(result).toBeFalsy();
});
it('check for year after 9999', () => {
const result = timeUtils.checkValidTimestamp('10000-00-00T00:00:00.000Z');
expect(result).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,48 @@
import exifr from 'exifr';
function createTimeUtils() {
const checkValidTimestamp = (timestamp: string): boolean => {
const parsedTimestamp = Date.parse(timestamp);
if (isNaN(parsedTimestamp)) {
return false;
}
const date = new Date(parsedTimestamp);
if (date.getFullYear() < 1583 || date.getFullYear() > 9999) {
return false;
}
return date.getFullYear() > 0;
};
const getTimestampFromExif = async (originalPath: string): Promise<string> => {
try {
const exifData = await exifr.parse(originalPath, {
tiff: true,
ifd0: true as any,
ifd1: true,
exif: true,
gps: true,
interop: true,
xmp: true,
icc: true,
iptc: true,
jfif: true,
ihdr: true,
});
if (exifData && exifData['DateTimeOriginal']) {
return exifData['DateTimeOriginal'];
} else {
return new Date().toISOString();
}
} catch (error) {
return new Date().toISOString();
}
};
return { checkValidTimestamp, getTimestampFromExif };
}
export const timeUtils = createTimeUtils();

1133
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,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",
@@ -56,13 +55,14 @@
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport": "^0.5.2", "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",
@@ -129,6 +129,7 @@
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1", "^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1", "@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
"@app/database/config": "<rootDir>/libs/database/src/config", "@app/database/config": "<rootDir>/libs/database/src/config",
"@app/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1" "^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
} }
} }

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

@@ -6,7 +6,8 @@
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');
@@ -30,6 +31,13 @@
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);
}
} }
}); });
@@ -126,11 +134,7 @@
<p>{moment(asset.exifInfo.dateTimeOriginal).format('MMM DD, YYYY')}</p> <p>{moment(asset.exifInfo.dateTimeOriginal).format('MMM DD, YYYY')}</p>
<div class="flex gap-2 text-sm"> <div class="flex gap-2 text-sm">
<p> <p>
{moment( {moment(asset.exifInfo.dateTimeOriginal).format('ddd, hh:mm A')}
asset.exifInfo.dateTimeOriginal
.toString()
.slice(0, asset.exifInfo.dateTimeOriginal.toString().length - 1)
).format('ddd, hh:mm A')}
</p> </p>
<p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p> <p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p>
</div> </div>
@@ -212,7 +216,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

@@ -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

@@ -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']
}
} }
}; };