Compare commits

..

29 Commits

Author SHA1 Message Date
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
Alex Tran
31739aca02 Up version for release 2022-09-10 11:58:59 -05:00
Thanh Pham
8f2e7b6f65 fix(server): loop on checksum generation (#662) 2022-09-10 11:52:39 -05:00
Brett Profitt
4ed647c43d fix(install): Fix checking for docker compose. (#663) 2022-09-10 11:48:50 -05:00
Alex
f88ff4fb5c fix(mobile): background backup not working in release mode (#664) 2022-09-10 11:46:51 -05:00
Alex Tran
cc4881d633 Up version for release 2022-09-09 23:23:37 -05:00
Alex
d856b35afc feat(web) add scrollbar with timeline information (#658)
- Implement a scrollbar with a timeline similar to Google Photos
- The scrollbar can also be dragged
2022-09-09 15:55:20 -05:00
Jaime Baez
b6d025da09 Fix Notification components possible memory leaks (#650)
Dispose subscriptions and timeouts when
the components are removed from the DOM
2022-09-09 07:40:35 -05:00
Jaime Baez
cc79ff1ca3 Merge pull request #642 from immich-app/add/ci-web-checks
Add web test / check commands and workflow to run in CI
2022-09-08 19:12:39 +02:00
Jaime Baez
131aa2b6be Add command to test/check code in dev-setup docs 2022-09-08 17:54:45 +02:00
Jaime Baez
02a6b73122 Add web-unit-test workflow to run in CI 2022-09-08 17:44:13 +02:00
Jaime Baez
d87366c095 Add dev-setup documentation 2022-09-08 17:41:24 +02:00
Jaime Baez
4f7a3afbfc Fix web lint issues 2022-09-08 17:30:49 +02:00
Jaime Baez
6725954b70 Add web check / lint npm commands
`svelte-check` returns some "hints" that can be ignored since some
are not true and others are not relevant.
2022-09-08 17:17:15 +02:00
Fynn Petersen-Frey
4fe535e5e8 improve Android background service reliability (#603)
This change greatly reduces the chance that a backup is not performed
when a new photo/video is made.
Instead of combining the change trigger and additonal constraints (wifi
or charging) into a single worker, these aspects are now separated.
Thus, it is now reliably possible to take pictures while the wifi
constraint is not satisfied and upload them hours/days later once
connected to wifi without taking a new photo.
As a positive side effect, this simplifies the error/retry handling
by directly leveraging Android's WorkManager without workarounds.
The separation also allows to notify the currently running BackupWorker
that new assets were added while backing up other assets to also upload
those newly added assets.
Further, a new tiny service checks if the app is killed, to reschedule
the content change worker and allow to detect the first new photo.
Bonus: The home screen now shows backup as enabled if background backup
is active.

* use separate worker/task for listening on changed/added assets
* use separate worker/task for performing the backup
* content observer worker enqueues backup worker on each new asset
* wifi/charging constraints only apply to backup worker
* backupworker is notified of assets added while running to re-run
* new service to catch app being killed to workaround WorkManager issue
2022-09-08 08:36:08 -05:00
Jaime Baez
aed94bfc4c Format web code with prettier
Added `.md` and `.json` to .prettierignore
2022-09-08 12:53:09 +02:00
Jaime Baez
de996c0a81 Merge pull request #612 from immich-app/add/web-ui-tests-setup
Add web UI components tests setup

@alextran1502 I'll get this merged so I can add CI checks for the web as well. Let me know if you have any questions 😃
2022-09-08 11:24:08 +02:00
Jaime Baez
1a39aa4da5 Merge pull request #633 from immich-app/fix/server-lint-errors
Add all server checks to CI - fix lint issues
2022-09-08 11:12:31 +02:00
Jaime Baez
1f4ba73da7 Add all server checks to CI - fix lint issues
CI will now run linter, type-checks and tests for the server.

All the lint issues have been fixed.
2022-09-08 11:07:27 +02:00
Alex Tran
836b174d33 Better styling for count info 2022-09-07 21:19:24 -05:00
Jaime Baez
6b702b13e4 Rename albums BLoC (.bloc.ts convention)
By convention now it's `album.bloc.ts`
2022-09-07 16:04:50 +02:00
Jaime Baez
f476bd985b Add AlbumCard UI tests
- add libraries for component UI testing
- implement AlbumCard UI tests
2022-09-07 16:00:57 +02:00
120 changed files with 1881 additions and 722 deletions

View File

@@ -18,8 +18,8 @@ jobs:
- name: Run Immich Server 2E2 Test
run: docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test up --abort-on-container-exit --exit-code-from immich-server-test
unit-tests:
name: Run unit test suites
server-unit-tests:
name: Run server unit test suites and checks
runs-on: ubuntu-latest
steps:
@@ -27,4 +27,15 @@ jobs:
uses: actions/checkout@v3
- name: Run tests
run: cd server && npm install && npm run test
run: cd server && npm ci && npm run check:all
web-unit-tests:
name: Run web unit test suites and checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run tests
run: cd web && npm ci && npm run check:all

View File

@@ -27,6 +27,7 @@
- [Features](#features)
- [Screenshots](#screenshots)
- [Installation](#installation)
- [Update](#update)
- [Mobile App](#-mobile-app)
- [Development](#development)
- [Support](#support)
@@ -146,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 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 `TZ` as your timezone, default is `Etc/UTC`.
### Step 3 - Start the containers
@@ -172,6 +174,14 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
<br/>
## Update
If you have installed, you can update the application by navigate to the directory that contains the `docker-compose.yml` file and run the following command:
```bash
docker-compose pull && docker-compose up -d
```
# Mobile app
| F-Droid | Google Play | iOS |

32
dev-setup.md Normal file
View File

@@ -0,0 +1,32 @@
# Development Setup
## Lint / format extensions
Setting these in the IDE give a better developer experience auto-formatting code on save and providing instant feedback on lint issues.
### VSCode
Install Prettier, ESLint and Svelte extensions.
in User `settings.json` (`cmd + shift + p` and search for Open User Settings JSON) add the following:
```json
{
"editor.formatOnSave": true,
"[javascript][typescript][css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": ["javascript", "svelte"]
}
```
## Running tests / checks
In both server and web:
`npm run check:all`

View File

@@ -36,6 +36,11 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Log message level - [simple|verbose]
###################################################################################
LOG_LEVEL=simple
###################################################################################
@@ -63,4 +68,12 @@ MAPBOX_KEY=
# Custom message on the login page, should be written in HTML form.
# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
PUBLIC_LOGIN_PAGE_MESSAGE=
PUBLIC_LOGIN_PAGE_MESSAGE=
# For correctly display your local time zone on the web, you can set the time zone here.
# Should work fine by default value, however, in case of incorrect timezone in EXIF, this value
# should be set to the correct timezone.
# Command to get timezone:
# - Linux: curl -s http://ip-api.com/json/ | grep -oP '(?<=timezone":")(.*?)(?=")'
# TZ=Etc/UTC

View File

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

View File

@@ -6,10 +6,6 @@ RED='\033[0;31m'
GREEN='\032[0;31m'
NC='\033[0m' # No Color
machine_has() {
type "$1" >/dev/null 2>&1
}
create_immich_directory() {
echo "Creating Immich directory..."
mkdir -p ./immich-app/immich-data
@@ -45,18 +41,21 @@ populate_upload_location() {
start_docker_compose() {
echo "Starting Immich's docker containers"
if machine_has "docker compose"; then {
docker compose up --remove-orphans -d
show_friendly_message
exit 0
}; fi
if machine_has "docker-compose"; then
docker-compose up --remove-orphans -d
if docker compose &> /dev/null; then
docker_bin="docker compose"
elif docker-compose &> /dev/null; then
docker_bin="docker-compose"
else
echo 'Cannot find `docker compose` or `docker-compose`.'
exit 1
fi
if $docker_bin up --remove-orphans -d; then
show_friendly_message
exit 0
else
echo "Could not start. Check for errors above."
exit 1
fi
}

View File

@@ -12,6 +12,7 @@
</intent-filter>
</activity>
<service android:name=".AppClearedService" android:stopWithTask="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />

View File

@@ -0,0 +1,25 @@
package app.alextran.immich
import android.app.Service
import android.content.Intent
import android.os.IBinder
/**
* Catches the event when either the system or the user kills the app
* (does not apply on force close!)
*/
class AppClearedService() : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
return START_NOT_STICKY;
}
override fun onTaskRemoved(rootIntent: Intent) {
ContentObserverWorker.workManagerAppClearedWorkaround(applicationContext)
stopSelf();
}
}

View File

@@ -1,11 +1,6 @@
package app.alextran.immich
import android.content.Context
import android.net.Uri
import android.content.Intent
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
@@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val ctx = context!!
when(call.method) {
"initialize" -> { // needs to be called prior to any other method
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long).apply()
.edit()
.putLong(BackupWorker.SHARED_PREF_CALLBACK_KEY, args.get(0) as Long)
.putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, args.get(1) as String)
.apply()
ContentObserverWorker.enable(ctx, immediate = args.get(2) as Boolean)
result.success(true)
}
"start" -> {
"configure" -> {
val args = call.arguments<ArrayList<*>>()!!
val immediate = args.get(0) as Boolean
val keepExisting = args.get(1) as Boolean
val requireUnmeteredNetwork = args.get(2) as Boolean
val requireCharging = args.get(3) as Boolean
val notificationTitle = args.get(4) as String
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putString(BackupWorker.SHARED_PREF_NOTIFICATION_TITLE, notificationTitle).apply()
BackupWorker.startWork(ctx, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
result.success(true)
val requireUnmeteredNetwork = args.get(0) as Boolean
val requireCharging = args.get(1) as Boolean
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
result.success(true)
}
"stop" -> {
"disable" -> {
ContentObserverWorker.disable(ctx)
BackupWorker.stopWork(ctx)
result.success(true)
}
"isEnabled" -> {
result.success(BackupWorker.isEnabled(ctx))
result.success(ContentObserverWorker.isEnabled(ctx))
}
"isIgnoringBatteryOptimizations" -> {
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))

View File

@@ -8,17 +8,12 @@ import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.provider.MediaStore
import android.provider.BaseColumns
import android.provider.MediaStore.MediaColumns
import android.provider.MediaStore.Images.Media
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.concurrent.futures.ResolvableFuture
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.ListenableWorker
import androidx.work.NetworkType
@@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkInfo
import com.google.common.util.concurrent.ListenableFuture
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
@@ -41,14 +37,7 @@ import java.util.concurrent.TimeUnit
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
* `background.service.dart` to run the actual backup logic.
* Called by Android WorkManager when all constraints for the work are met,
* i.e. a new photo/video is created on the device AND battery is not low.
* Optionally, unmetered network (wifi) and charging can be required.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again with the same settings.
* In case the worker is stopped by the system (e.g. constraints like wifi
* are no longer met, or the system needs memory resources for more other
* more important work), the worker is replaced without the constraint on
* changed contents to run again as soon as deemed possible by the system.
* i.e. battery is not low and optionally Wifi and charging are active.
*/
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
@@ -57,14 +46,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private lateinit var backgroundChannel: MethodChannel
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
Log.d(TAG, "startWork")
val ctx = applicationContext
// enqueue itself once again to continue to listen on added photos/videos
enqueueMoreWork(ctx,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false))
if (!flutterLoader.initialized()) {
flutterLoader.startInitialization(ctx)
@@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
// Create a Notification channel if necessary
createChannel()
}
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user)
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
setForegroundAsync(createForegroundInfo(title))
} else {
showBackgroundInfo(title)
}
engine = FlutterEngine(ctx)
@@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
}
override fun onStopped() {
Log.d(TAG, "onStopped")
// called when the system has to stop this worker because constraints are
// no longer met or the system needs resources for more important tasks
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
@@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private fun stopEngine(result: Result?) {
if (result != null) {
Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result)
} else if (engine != null && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// stopped by system and this is the first time (content change constraints active)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
engine?.destroy()
engine = null
clearBackgroundNotification()
}
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
when (call.method) {
"initialized" ->
"initialized" -> {
timeBackupStarted = SystemClock.uptimeMillis()
backgroundChannel.invokeMethod(
"onAssetsChanged",
null,
@@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
override fun success(receivedResult: Any?) {
val success = receivedResult as Boolean
stopEngine(if(success) Result.success() else Result.retry())
if (!success && inputData.getInt(DATA_KEY_RETRIES, 0) == 0) {
// there was an error (e.g. server not available)
// replace the task without the content constraints to finish the backup as soon as possible
enqueueMoreWork(applicationContext,
immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
}
}
}
)
}
"updateNotification" -> {
val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String
val content = args.get(1) as String
if (isIgnoringBatteryOptimizations) {
setForegroundAsync(createForegroundInfo(title, content))
} else {
showBackgroundInfo(title, content)
}
}
"showError" -> {
@@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
showError(title, content, individualTag)
}
"clearErrorNotifications" -> clearErrorNotifications()
"hasContentChanged" -> {
val lastChange = applicationContext
.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getLong(SHARED_PREF_LAST_CHANGE, timeBackupStarted)
val hasContentChanged = lastChange > timeBackupStarted;
timeBackupStarted = SystemClock.uptimeMillis()
r.success(hasContentChanged)
}
else -> r.notImplemented()
}
}
@@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
notificationManager.cancel(NOTIFICATION_ERROR_ID)
}
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID)
}
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
@@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
companion object {
const val SHARED_PREF_NAME = "immichBackgroundService"
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_NOTIFICATION_TITLE = "notificationTitle"
const val SHARED_PREF_LAST_CHANGE = "lastChange"
private const val TASK_NAME = "immich/photoListener"
private const val DATA_KEY_UNMETERED = "unmetered"
private const val DATA_KEY_CHARGING = "charging"
private const val DATA_KEY_RETRIES = "retries"
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2
private const val ONE_MINUTE: Long = 60000
private const val ONE_MINUTE = 60000L
/**
* Enqueues the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param immediate whether to enqueue(replace) the worker without the content change constraint
* @param keepExisting if true, use `ExistingWorkPolicy.KEEP`, else `ExistingWorkPolicy.APPEND_OR_REPLACE`
* @param requireUnmeteredNetwork if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
* @param retries retry count (should be 0 unless an error occured and this is a retry)
* Enqueues the BackupWorker to run once the constraints are met
*/
fun startWork(context: Context,
immediate: Boolean = false,
keepExisting: Boolean = false,
requireUnmeteredNetwork: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
fun enqueueBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L) {
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
}
private fun enqueueMoreWork(context: Context,
immediate: Boolean = false,
keepExisting: Boolean = false,
requireUnmeteredNetwork: Boolean = false,
requireCharging: Boolean = false,
initialDelayInMs: Long = 0,
retries: Int = 0) {
if (!isEnabled(context)) {
return
/**
* Updates the constraints of an already enqueued BackupWorker
*/
fun updateBackupWorker(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
try {
val wm = WorkManager.getInstance(context)
val workInfoFuture = wm.getWorkInfosForUniqueWork(TASK_NAME_BACKUP)
val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS)
if (workInfoList != null) {
for (workInfo in workInfoList) {
if (workInfo.getState() == WorkInfo.State.ENQUEUED) {
val workRequest = buildWorkRequest(requireWifi, requireCharging)
wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest)
Log.d(TAG, "updateBackupWorker updated BackupWorker constraints")
return
}
}
}
Log.d(TAG, "updateBackupWorker: BackupWorker not enqueued")
} catch (e: Exception) {
Log.d(TAG, "updateBackupWorker failed: ${e}")
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireUnmeteredNetwork) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging);
if (!immediate) {
constraints
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
}
val inputData = Data.Builder()
.putBoolean(DATA_KEY_CHARGING, requireCharging)
.putBoolean(DATA_KEY_UNMETERED, requireUnmeteredNetwork)
.putInt(DATA_KEY_RETRIES, retries)
.build()
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints.build())
.setInputData(inputData)
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
ONE_MINUTE,
TimeUnit.MILLISECONDS)
.build()
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)
val op = WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME, policy, photoCheck)
val result = op.getResult().get()
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun stopWork(context: Context) {
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
Log.d(TAG, "stopWork: BackupWorker cancelled")
}
/**
@@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
return true
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
private fun buildWorkRequest(requireWifi: Boolean = false,
requireCharging: Boolean = false,
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
val constraints = Constraints.Builder()
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(requireCharging)
.build();
val work = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS)
.setInitialDelay(delayMilliseconds, TimeUnit.MILLISECONDS)
.build()
return work
}
private val flutterLoader = FlutterLoader()

View File

@@ -0,0 +1,137 @@
package app.alextran.immich
import android.content.Context
import android.os.SystemClock
import android.provider.MediaStore
import android.util.Log
import androidx.work.Constraints
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.Operation
import java.util.concurrent.TimeUnit
/**
* Worker executed by Android WorkManager observing content changes (new photos/videos)
*
* Immediately enqueues the BackupWorker when running.
* As this work is not triggered periodically, but on content change, the
* worker enqueues itself again after each run.
*/
class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
override fun doWork(): Result {
if (!isEnabled(applicationContext)) {
return Result.failure()
}
if (getTriggeredContentUris().size > 0) {
startBackupWorker(applicationContext, delayMilliseconds = 0)
}
enqueueObserverWorker(applicationContext, ExistingWorkPolicy.REPLACE)
return Result.success()
}
companion object {
const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled"
const val SHARED_PREF_REQUIRE_WIFI = "requireWifi"
const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging"
private const val TASK_NAME_OBSERVER = "immich/ContentObserver"
/**
* Enqueues the `ContentObserverWorker`.
*
* @param context Android Context
*/
fun enable(context: Context, immediate: Boolean = false) {
// migration to remove any old active background task
WorkManager.getInstance(context).cancelUniqueWork("immich/photoListener")
enqueueObserverWorker(context, ExistingWorkPolicy.KEEP)
Log.d(TAG, "enabled ContentObserverWorker")
if (immediate) {
startBackupWorker(context, delayMilliseconds = 5000)
}
}
/**
* Configures the `BackupWorker` to run when all constraints are met.
*
* @param context Android Context
* @param requireWifi if true, task only runs if connected to wifi
* @param requireCharging if true, task only runs if device is charging
*/
fun configureWork(context: Context,
requireWifi: Boolean = false,
requireCharging: Boolean = false) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(SHARED_PREF_SERVICE_ENABLED, true)
.putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi)
.putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging)
.apply()
BackupWorker.updateBackupWorker(context, requireWifi, requireCharging)
}
/**
* Stops the currently running worker (if any) and removes it from the work queue
*/
fun disable(context: Context) {
context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_OBSERVER)
Log.d(TAG, "disabled ContentObserverWorker")
}
/**
* Return true if the user has enabled the background backup service
*/
fun isEnabled(ctx: Context): Boolean {
return ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
}
/**
* Enqueue and replace the worker without the content trigger but with a short delay
*/
fun workManagerAppClearedWorkaround(context: Context) {
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setInitialDelay(500, TimeUnit.MILLISECONDS)
.build()
WorkManager
.getInstance(context)
.enqueueUniqueWork(TASK_NAME_OBSERVER, ExistingWorkPolicy.REPLACE, work)
.getResult()
.get()
Log.d(TAG, "workManagerAppClearedWorkaround")
}
private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) {
val constraints = Constraints.Builder()
.addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true)
.addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true)
.setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS)
.build()
val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_OBSERVER, policy, work)
}
private fun startBackupWorker(context: Context, delayMilliseconds: Long) {
val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
val requireWifi = sp.getBoolean(SHARED_PREF_REQUIRE_WIFI, true)
val requireCharging = sp.getBoolean(SHARED_PREF_REQUIRE_CHARGING, false)
BackupWorker.enqueueBackupWorker(context, requireWifi, requireCharging, delayMilliseconds)
sp.edit().putLong(BackupWorker.SHARED_PREF_LAST_CHANGE, SystemClock.uptimeMillis()).apply()
}
}
}
private const val TAG = "ContentObserverWorker"

View File

@@ -2,6 +2,8 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
class MainActivity: FlutterActivity() {
@@ -10,4 +12,9 @@ class MainActivity: FlutterActivity() {
flutterEngine.getPlugins().add(BackgroundServicePlugin())
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startService(Intent(getBaseContext(), AppClearedService::class.java));
}
}

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 38,
"android.injected.version.name" => "1.28.0",
"android.injected.version.code" => 41,
"android.injected.version.name" => "1.28.3",
}
)
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 @@
* Improve Android background service reliability

View File

@@ -0,0 +1 @@
* Fix background service cannot run in release build

View File

@@ -0,0 +1,2 @@
* Fixed oversize play button on video
* Fixed app crashing when swipe between assets

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

View File

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

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.27.0</string>
<string>1.28.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>52</string>
<string>55</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.28.0"
version_number: "1.28.3"
)
increment_build_number(
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.000199">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.499192">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.594905">
</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="4.207648">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.438506">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.391989">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="91.259106">
<testcase classname="fastlane.lanes" name="4: build_app" time="77.835137">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="102.092139">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="70.775758">
</testcase>

View File

@@ -12,6 +12,9 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
bool _zoomedIn = false;
static const int swipeThreshold = 100;
late CachedNetworkImageProvider fullProvider;
late CachedNetworkImageProvider previewProvider;
late CachedNetworkImageProvider thumbnailProvider;
@override
Widget build(BuildContext context) {
@@ -65,7 +68,10 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}
CachedNetworkImageProvider _authorizedImageProvider(
String url, String cacheKey, BaseCacheManager? cacheManager) {
String url,
String cacheKey,
BaseCacheManager? cacheManager,
) {
return CachedNetworkImageProvider(
url,
headers: {"Authorization": widget.authToken},
@@ -104,7 +110,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
}
void _loadImages() {
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
thumbnailProvider = _authorizedImageProvider(
widget.thumbnailUrl,
widget.cacheKey,
widget.thumbnailCacheManager,
@@ -121,7 +127,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
);
if (widget.previewUrl != null) {
CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
previewProvider = _authorizedImageProvider(
widget.previewUrl!,
"${widget.cacheKey}_previewStage",
widget.previewCacheManager,
@@ -133,7 +139,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
);
}
CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
fullProvider = _authorizedImageProvider(
widget.imageUrl,
"${widget.cacheKey}_fullStage",
widget.fullCacheManager,
@@ -150,6 +156,19 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
_loadImages();
super.initState();
}
@override
void dispose() async {
super.dispose();
await thumbnailProvider.evict();
await fullProvider.evict();
if (widget.previewUrl != null) {
await previewProvider.evict();
}
_imageProvider.evict();
}
}
class RemotePhotoView extends StatefulWidget {

View File

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

View File

@@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -33,7 +32,6 @@ class BackgroundService {
MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel');
bool _isForegroundInitialized = false;
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
bool _canceledBySystem = false;
@@ -43,32 +41,34 @@ class BackgroundService {
ReceivePort? _rp;
bool _errorGracePeriodExceeded = true;
bool get isForegroundInitialized {
return _isForegroundInitialized;
}
bool get isBackgroundInitialized {
return _isBackgroundInitialized;
}
Future<bool> _initialize() async {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
var result = await _foregroundChannel
.invokeMethod('initialize', [callback.toRawHandle()]);
_isForegroundInitialized = true;
return result;
}
/// Ensures that the background service is enqueued if enabled in settings
Future<bool> resumeServiceIfEnabled() async {
return await isBackgroundBackupEnabled() &&
await startService(keepExisting: true);
return await isBackgroundBackupEnabled() && await enableService();
}
/// Enqueues the background service
Future<bool> startService({
bool immediate = false,
bool keepExisting = false,
Future<bool> enableService({bool immediate = false}) async {
if (!Platform.isAndroid) {
return true;
}
try {
final callback = PluginUtilities.getCallbackHandle(_nativeEntry)!;
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel
.invokeMethod('enable', [callback.toRawHandle(), title, immediate]);
return ok;
} catch (error) {
return false;
}
}
/// Configures the background service
Future<bool> configureService({
bool requireUnmetered = true,
bool requireCharging = false,
}) async {
@@ -76,14 +76,9 @@ class BackgroundService {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
final String title =
"backup_background_service_default_notification".tr();
final bool ok = await _foregroundChannel.invokeMethod(
'start',
[immediate, keepExisting, requireUnmetered, requireCharging, title],
'configure',
[requireUnmetered, requireCharging],
);
return ok;
} catch (error) {
@@ -92,15 +87,12 @@ class BackgroundService {
}
/// Cancels the background service (if currently running) and removes it from work queue
Future<bool> stopService() async {
Future<bool> disableService() async {
if (!Platform.isAndroid) {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
final ok = await _foregroundChannel.invokeMethod('stop');
final ok = await _foregroundChannel.invokeMethod('disable');
return ok;
} catch (error) {
return false;
@@ -113,9 +105,6 @@ class BackgroundService {
return false;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel.invokeMethod("isEnabled");
} catch (error) {
return false;
@@ -128,9 +117,6 @@ class BackgroundService {
return true;
}
try {
if (!_isForegroundInitialized) {
await _initialize();
}
return await _foregroundChannel
.invokeMethod('isIgnoringBatteryOptimizations');
} catch (error) {
@@ -187,7 +173,8 @@ class BackgroundService {
}
} catch (error) {
debugPrint(
"[_clearErrorNotifications] failed to communicate with plugin");
"[_clearErrorNotifications] failed to communicate with plugin",
);
}
return false;
}
@@ -289,18 +276,11 @@ class BackgroundService {
try {
final bool hasAccess = await acquireLock();
if (!hasAccess) {
debugPrint("[_callHandler] could acquire lock, exiting");
debugPrint("[_callHandler] could not acquire lock, exiting");
return false;
}
await translationsLoaded;
final bool ok = await _onAssetsChanged();
if (ok) {
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
}
return ok;
} catch (error) {
debugPrint(error.toString());
@@ -343,6 +323,31 @@ class BackgroundService {
}
await PhotoManager.setIgnorePermissionCheck(true);
do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
return false;
}
// check for new assets added while performing backup
} while (true ==
await _backgroundChannel.invokeMethod<bool>("hasContentChanged"));
return true;
}
Future<bool> _runBackup(
BackupService backupService,
HiveBackupAlbums backupAlbumInfo,
) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) {
@@ -382,10 +387,6 @@ class BackgroundService {
);
if (ok) {
_clearErrorNotifications();
await box.put(
backupInfoKey,
backupAlbumInfo,
);
} else {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
@@ -447,6 +448,7 @@ class BackgroundService {
}
/// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point')
void _nativeEntry() {
WidgetsFlutterBinding.ensureInitialized();
BackgroundService backgroundService = BackgroundService();

View File

@@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo();
}
success &= await _backgroundService.enableService(immediate: true);
}
final bool success = await _backgroundService.stopService() &&
await _backgroundService.startService(
success &= success &&
await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
);
@@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await _backgroundService.stopService();
final bool success = await _backgroundService.disableService();
if (!success) {
state = state.copyWith(backgroundBackup: wasEnabled);
onError("backup_controller_page_background_configure_error");

View File

@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
).tr(),
),
actions: [
TextButton(
OutlinedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication),
child: Text(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(color: buttonTextColor),
).tr(),
),
TextButton(
child: Text(
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(color: buttonTextColor),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () {
Navigator.of(context).pop();
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
foregroundColor: Colors.grey[50],
backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14),
),
onPressed: () {

View File

@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
bool isEnableAutoBackup =
bool isEnableAutoBackup = backupState.backgroundBackup ||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);

View File

@@ -182,7 +182,7 @@ packages:
name: chewie
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.2"
version: "1.3.5"
clock:
dependency: transitive
description:
@@ -238,7 +238,7 @@ packages:
name: cupertino_icons
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
version: "1.0.5"
dart_style:
dependency: transitive
description:
@@ -839,7 +839,7 @@ packages:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.2"
version: "6.0.3"
pub_semver:
dependency: transitive
description:
@@ -1223,27 +1223,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
very_good_analysis:
dependency: transitive
description:
name: very_good_analysis
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
video_player:
dependency: "direct main"
description:
name: video_player
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.2"
version: "2.4.7"
video_player_android:
dependency: transitive
description:
name: video_player_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.3"
version: "2.3.9"
video_player_avfoundation:
dependency: transitive
description:
@@ -1271,7 +1264,7 @@ packages:
name: wakelock
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.1+2"
version: "0.6.2"
wakelock_macos:
dependency: transitive
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.28.0+38
version: 1.28.3+41
environment:
sdk: ">=2.17.0 <3.0.0"
@@ -26,7 +26,7 @@ dependencies:
flutter_launcher_icons: "^0.9.2"
fluttertoast: ^8.0.8
video_player: ^2.2.18
chewie: ^1.2.2
chewie: ^1.3.5
badges: ^2.0.2
photo_view: ^0.14.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0

View File

@@ -11,7 +11,6 @@ import { GetAlbumsDto } from './dto/get-albums.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AlbumResponseDto } from './response-dto/album-response.dto';
export interface IAlbumRepository {
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
@@ -51,9 +50,14 @@ export class AlbumRepository implements IAlbumRepository {
where: { sharedUserId: userId },
});
const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0);
let sharedAlbumCount = 0;
ownedAlbums.map((album) => {
if (album.sharedUsers?.length) {
sharedAlbumCount += 1;
}
});
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums);
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharedAlbumCount);
}
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
@@ -165,7 +169,7 @@ export class AlbumRepository implements IAlbumRepository {
}
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
let query = this.albumRepository.createQueryBuilder('album');
const query = this.albumRepository.createQueryBuilder('album');
const albums = await query
.where('album.ownerId = :ownerId', { ownerId: userId })
@@ -190,7 +194,7 @@ export class AlbumRepository implements IAlbumRepository {
}
async get(albumId: string): Promise<AlbumEntity | undefined> {
let query = this.albumRepository.createQueryBuilder('album');
const query = this.albumRepository.createQueryBuilder('album');
const album = await query
.where('album.id = :albumId', { albumId })

View File

@@ -11,7 +11,6 @@ import {
ParseUUIDPipe,
Put,
Query,
Header,
} from '@nestjs/common';
import { ParseMeUUIDPipe } from '../validation/parse-me-uuid-pipe';
import { AlbumService } from './album.service';

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { IsOptional } from 'class-validator';
export class UpdateAlbumDto {
@IsOptional()

View File

@@ -15,7 +15,6 @@ import {
HttpCode,
BadRequestException,
UploadedFile,
Header,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service';
@@ -34,7 +33,7 @@ import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto';

View File

@@ -1,9 +1,8 @@
import { AssetRepository, IAssetRepository } from './asset-repository';
import { IAssetRepository } from './asset-repository';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetService } from './asset.service';
import { Repository } from 'typeorm';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity } from '@app/database/entities/asset.entity';
describe('AssetService', () => {
let sui: AssetService;
@@ -15,39 +14,39 @@ describe('AssetService', () => {
email: 'auth@test.com',
});
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
createAssetDto.deviceAssetId = 'deviceAssetId';
createAssetDto.deviceId = 'deviceId';
createAssetDto.assetType = AssetType.OTHER;
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.isFavorite = false;
createAssetDto.duration = '0:00:00.000000';
// const _getCreateAssetDto = (): CreateAssetDto => {
// const createAssetDto = new CreateAssetDto();
// createAssetDto.deviceAssetId = 'deviceAssetId';
// createAssetDto.deviceId = 'deviceId';
// createAssetDto.assetType = AssetType.OTHER;
// createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
// createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
// createAssetDto.isFavorite = false;
// createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
};
const _getAsset = () => {
const assetEntity = new AssetEntity();
// return createAssetDto;
// };
// const _getAsset = () => {
// const assetEntity = new AssetEntity();
assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
assetEntity.deviceAssetId = '4967046344801';
assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
assetEntity.type = AssetType.VIDEO;
assetEntity.originalPath =
'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
assetEntity.resizePath = '';
assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
assetEntity.isFavorite = false;
assetEntity.mimeType = 'image/jpeg';
assetEntity.webpPath = '';
assetEntity.encodedVideoPath = '';
assetEntity.duration = '0:00:00.000000';
// assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
// assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
// assetEntity.deviceAssetId = '4967046344801';
// assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
// assetEntity.type = AssetType.VIDEO;
// assetEntity.originalPath =
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
// assetEntity.resizePath = '';
// assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
// assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
// assetEntity.isFavorite = false;
// assetEntity.mimeType = 'image/jpeg';
// assetEntity.webpPath = '';
// assetEntity.encodedVideoPath = '';
// assetEntity.duration = '0:00:00.000000';
return assetEntity;
};
// return assetEntity;
// };
beforeAll(() => {
assetRepositoryMock = {

View File

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

View File

@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional } from 'class-validator';
export enum GetAssetThumbnailFormatEnum {
JPEG = 'JPEG',

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
export class ServeFileDto {
@IsNotEmpty()

View File

@@ -1,12 +1,5 @@
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiCreatedResponse,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AuthService } from './auth.service';

View File

@@ -1,5 +1,5 @@
import { UserEntity } from '@app/database/entities/user.entity';
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
import { ApiResponseProperty } from '@nestjs/swagger';
export class LoginResponseDto {
@ApiResponseProperty()

View File

@@ -1,8 +1,6 @@
import { DeviceType } from '@app/database/entities/device-info.entity';
import { PartialType } from '@nestjs/mapped-types';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional } from 'class-validator';
import { CreateDeviceInfoDto } from './create-device-info.dto';
export class UpdateDeviceInfoDto {
@IsNotEmpty()

View File

@@ -1,13 +1,10 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { Controller, Get } from '@nestjs/common';
import { ServerInfoService } from './server-info.service';
import { serverVersion } from '../../constants/server_version.constant';
import { ApiTags } from '@nestjs/swagger';
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import { DataSource } from 'typeorm';
@ApiTags('Server Info')
@Controller('server-info')

View File

@@ -12,7 +12,6 @@ import {
UploadedFile,
Response,
Request,
StreamableFile,
ParseBoolPipe,
} from '@nestjs/common';
import { UserService } from './user.service';

View File

@@ -15,7 +15,6 @@ import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database';
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
@Module({
imports: [

View File

@@ -11,6 +11,6 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = {
major: 1,
minor: 28,
patch: 0,
build: 0,
patch: 3,
build: 41,
};

View File

@@ -1,4 +1,3 @@
import { dataSource } from '@app/database/config/database.config';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
@@ -7,6 +6,7 @@ import cookieParser from 'cookie-parser';
import { writeFileSync } from 'fs';
import path from 'path';
import { AppModule } from './app.module';
import { serverVersion } from './constants/server_version.constant';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
async function bootstrap() {
@@ -53,11 +53,17 @@ async function bootstrap() {
// Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' });
Logger.log('Running Immich Server in DEVELOPMENT environment', 'ImmichServer');
Logger.log(
`Running Immich Server in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichServer',
);
}
if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Server in PRODUCTION environment', 'ImmichServer');
Logger.log(
`Running Immich Server in PRODUCTION environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichServer',
);
}
});
}

View File

@@ -2,7 +2,6 @@ import { InjectQueue } from '@nestjs/bull/dist/decorators';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { randomUUID } from 'node:crypto';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
@Injectable()

View File

@@ -1,5 +1,6 @@
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { serverVersion } from 'apps/immich/src/constants/server_version.constant';
import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
import { MicroservicesModule } from './microservices.module';
@@ -10,11 +11,17 @@ async function bootstrap() {
await app.listen(3002, () => {
if (process.env.NODE_ENV == 'development') {
Logger.log('Running Immich Microservices in DEVELOPMENT environment', 'ImmichMicroservice');
Logger.log(
`Running Immich Microservices in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichMicroservice',
);
}
if (process.env.NODE_ENV == 'production') {
Logger.log('Running Immich Microservices in PRODUCTION environment', 'ImmichMicroservice');
Logger.log(
`Running Immich Microservices in PRODUCTION environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
'ImmichMicroservice',
);
}
});
}

View File

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

View File

@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import { IsNull, Repository } from 'typeorm';
import { FindOptionsWhere, IsNull, MoreThan, QueryFailedError, Repository } from 'typeorm';
// TODO: just temporary task to generate previous uploaded assets.
@Processor(generateChecksumQueueName)
@@ -17,15 +17,23 @@ export class GenerateChecksumProcessor {
@Process()
async generateChecksum() {
const pageSize = 200;
let hasNext = true;
let pageSize = 200;
let lastErrAssetId: string | undefined = undefined;
while (hasNext) {
const whereStat: FindOptionsWhere<AssetEntity> = {
checksum: IsNull(),
};
if (lastErrAssetId) {
whereStat.id = MoreThan(lastErrAssetId);
}
const assets = await this.assetRepository.find({
where: {
checksum: IsNull()
},
where: whereStat,
take: pageSize,
order: { id: 'ASC' }
});
if (!assets?.length) {
@@ -35,15 +43,24 @@ export class GenerateChecksumProcessor {
try {
await this.generateAssetChecksum(asset);
} catch (err: any) {
Logger.error(`Error generate checksum ${err}`);
lastErrAssetId = asset.id;
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
Logger.error(`${asset.originalPath} duplicated`);
} else {
Logger.error(`checksum generation ${err}`);
}
}
}
// break when reach to the last page
if (assets.length < pageSize) {
hasNext = false;
}
}
}
Logger.log(`checksum generation done!`);
}
private async generateAssetChecksum(asset: AssetEntity) {

View File

@@ -1,3 +1,4 @@
import { ImmichLogLevel } from '@app/common/constants/log-level.constant';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
@@ -16,12 +17,12 @@ import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Job } from 'bull';
import exifr from 'exifr';
import ffmpeg from 'fluent-ffmpeg';
import { readFile } from 'fs/promises';
import path from 'path';
import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository';
@@ -29,6 +30,7 @@ import { Repository } from 'typeorm/repository/Repository';
@Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor {
private geocodingClient?: GeocodeService;
private logLevel: ImmichLogLevel;
constructor(
@InjectRepository(AssetEntity)
@@ -39,12 +41,16 @@ export class MetadataExtractionProcessor {
@InjectRepository(SmartInfoEntity)
private smartInfoRepository: Repository<SmartInfoEntity>,
private configService: ConfigService,
) {
if (process.env.ENABLE_MAPBOX == 'true' && process.env.MAPBOX_KEY) {
this.geocodingClient = mapboxGeocoding({
accessToken: process.env.MAPBOX_KEY,
});
}
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
}
@Process(exifExtractionProcessorName)
@@ -140,6 +146,10 @@ export class MetadataExtractionProcessor {
await this.exifRepository.save(newExif);
} catch (e) {
Logger.error(`Error extracting EXIF ${String(e)}`, 'extractExif');
if (this.logLevel === ImmichLogLevel.VERBOSE) {
console.trace('Error extracting EXIF', e);
}
}
}
@@ -330,7 +340,7 @@ export class MetadataExtractionProcessor {
}
if (stream.r_frame_rate) {
let fpsParts = stream.r_frame_rate.split('/');
const fpsParts = stream.r_frame_rate.split('/');
if (fpsParts.length === 2) {
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));

View File

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

View File

@@ -16,5 +16,6 @@ export const immichAppConfig: ConfigModuleOptions = {
then: Joi.string().optional().allow(null, ''),
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 './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();

View File

@@ -129,6 +129,7 @@
"^@app/database(|/.*)$": "<rootDir>/libs/database/src/$1",
"@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
"@app/database/config": "<rootDir>/libs/database/src/config",
"@app/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
}
}

View File

@@ -16,5 +16,8 @@ module.exports = {
browser: true,
es2017: true,
node: true
},
globals: {
NodeJS: true
}
};

View File

@@ -6,6 +6,9 @@ node_modules
.env
.env.*
!.env.example
src/api/open-api
*.md
*.json
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml

View File

@@ -178,14 +178,17 @@ export default {
// A map from regular expressions to paths to transformers
transform: {
'\\.[jt]sx?$': 'babel-jest'
}
'\\.[jt]sx?$': 'babel-jest',
'^.+\\.svelte$': [
'svelte-jester',
{
preprocess: true
}
]
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
transformIgnorePatterns: ['/node_modules/(?!svelte-material-icons).*/', '\\.pnp\\.[^\\/]+$']
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,

338
web/package-lock.json generated
View File

@@ -25,6 +25,8 @@
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "next",
"@sveltejs/kit": "next",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/svelte": "^3.2.1",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1",
"@types/fluent-ffmpeg": "^2.1.20",
@@ -48,13 +50,19 @@
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-jester": "^2.3.2",
"svelte-preprocess": "^4.10.6",
"svelte-preprocess": "^4.10.7",
"tailwindcss": "^3.0.24",
"tslib": "^2.3.1",
"typescript": "^4.7.4",
"vite": "^3.0.0"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz",
"integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==",
"dev": true
},
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -2626,6 +2634,107 @@
}
}
},
"node_modules/@testing-library/dom": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.17.1.tgz",
"integrity": "sha512-KnH2MnJUzmFNPW6RIKfd+zf2Wue8mEKX0M3cpX6aKl5ZXrJM1/c/Pc8c2xDNYQCnJO48Sm5ITbMXgqTr3h4jxQ==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^4.2.0",
"aria-query": "^5.0.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.4.4",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/@testing-library/dom/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
"node_modules/@testing-library/jest-dom": {
"version": "5.16.5",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz",
"integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==",
"dev": true,
"dependencies": {
"@adobe/css-tools": "^4.0.1",
"@babel/runtime": "^7.9.2",
"@types/testing-library__jest-dom": "^5.9.1",
"aria-query": "^5.0.0",
"chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.5.6",
"lodash": "^4.17.15",
"redent": "^3.0.0"
},
"engines": {
"node": ">=8",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@testing-library/svelte": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-3.2.1.tgz",
"integrity": "sha512-qP5nMAx78zt+a3y9Sws9BNQYP30cOQ/LXDYuAj7wNtw86b7AtB7TFAz6/Av9hFsW3IJHPBBIGff6utVNyq+F1g==",
"dev": true,
"dependencies": {
"@testing-library/dom": "^8.1.0"
},
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"svelte": "3.x"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -2635,6 +2744,12 @@
"node": ">= 10"
}
},
"node_modules/@types/aria-query": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
"dev": true
},
"node_modules/@types/babel__core": {
"version": "7.1.19",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz",
@@ -2739,6 +2854,16 @@
"@types/istanbul-lib-report": "*"
}
},
"node_modules/@types/jest": {
"version": "29.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.0.0.tgz",
"integrity": "sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q==",
"dev": true,
"dependencies": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/jsdom": {
"version": "20.0.0",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz",
@@ -2823,6 +2948,15 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
"node_modules/@types/testing-library__jest-dom": {
"version": "5.14.5",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz",
"integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==",
"dev": true,
"dependencies": {
"@types/jest": "*"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
@@ -3260,6 +3394,15 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"node_modules/aria-query": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.2.tgz",
"integrity": "sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q==",
"dev": true,
"engines": {
"node": ">=6.0"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -3866,6 +4009,12 @@
"node": ">= 8"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -4133,6 +4282,12 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.14",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz",
"integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==",
"dev": true
},
"node_modules/domexception": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -5601,6 +5756,15 @@
"node": ">=0.8.19"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -6660,6 +6824,15 @@
"node": ">=10"
}
},
"node_modules/lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
"integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==",
"dev": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.26.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz",
@@ -7621,6 +7794,19 @@
"node": ">=8.10.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -9091,6 +9277,12 @@
}
},
"dependencies": {
"@adobe/css-tools": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz",
"integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==",
"dev": true
},
"@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -10964,12 +11156,97 @@
"svelte-hmr": "^0.14.12"
}
},
"@testing-library/dom": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.17.1.tgz",
"integrity": "sha512-KnH2MnJUzmFNPW6RIKfd+zf2Wue8mEKX0M3cpX6aKl5ZXrJM1/c/Pc8c2xDNYQCnJO48Sm5ITbMXgqTr3h4jxQ==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^4.2.0",
"aria-query": "^5.0.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.4.4",
"pretty-format": "^27.0.2"
},
"dependencies": {
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true
},
"pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"requires": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
}
}
},
"@testing-library/jest-dom": {
"version": "5.16.5",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz",
"integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==",
"dev": true,
"requires": {
"@adobe/css-tools": "^4.0.1",
"@babel/runtime": "^7.9.2",
"@types/testing-library__jest-dom": "^5.9.1",
"aria-query": "^5.0.0",
"chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.5.6",
"lodash": "^4.17.15",
"redent": "^3.0.0"
},
"dependencies": {
"chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
}
}
},
"@testing-library/svelte": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-3.2.1.tgz",
"integrity": "sha512-qP5nMAx78zt+a3y9Sws9BNQYP30cOQ/LXDYuAj7wNtw86b7AtB7TFAz6/Av9hFsW3IJHPBBIGff6utVNyq+F1g==",
"dev": true,
"requires": {
"@testing-library/dom": "^8.1.0"
}
},
"@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
"dev": true
},
"@types/aria-query": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
"dev": true
},
"@types/babel__core": {
"version": "7.1.19",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz",
@@ -11074,6 +11351,16 @@
"@types/istanbul-lib-report": "*"
}
},
"@types/jest": {
"version": "29.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.0.0.tgz",
"integrity": "sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q==",
"dev": true,
"requires": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"@types/jsdom": {
"version": "20.0.0",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.0.tgz",
@@ -11157,6 +11444,15 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
"@types/testing-library__jest-dom": {
"version": "5.14.5",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz",
"integrity": "sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ==",
"dev": true,
"requires": {
"@types/jest": "*"
}
},
"@types/tough-cookie": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
@@ -11451,6 +11747,12 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
"aria-query": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.2.tgz",
"integrity": "sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q==",
"dev": true
},
"array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -11908,6 +12210,12 @@
"which": "^2.0.1"
}
},
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true
},
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -12111,6 +12419,12 @@
"esutils": "^2.0.2"
}
},
"dom-accessibility-api": {
"version": "0.5.14",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz",
"integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==",
"dev": true
},
"domexception": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
@@ -13102,6 +13416,12 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true
},
"indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -13909,6 +14229,12 @@
"yallist": "^4.0.0"
}
},
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
"integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==",
"dev": true
},
"magic-string": {
"version": "0.26.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.2.tgz",
@@ -14562,6 +14888,16 @@
"picomatch": "^2.2.1"
}
},
"redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"requires": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
}
},
"regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",

View File

@@ -6,10 +6,14 @@
"build": "vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
"format": "prettier --write --plugin-search-dir=. .",
"check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
"check:watch": "npm run check -- --watch",
"check:code": "npm run format && npm run lint && npm run check",
"check:all": "npm run check:code && npm test",
"lint": "eslint . --max-warnings 0",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --check --plugin-search-dir=. .",
"format:fix": "prettier --write --plugin-search-dir=. .",
"test": "jest",
"test:watch": "npm test -- --watch"
},
@@ -20,6 +24,8 @@
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-node": "next",
"@sveltejs/kit": "next",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/svelte": "^3.2.1",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.4.1",
"@types/fluent-ffmpeg": "^2.1.20",
@@ -43,7 +49,7 @@
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-jester": "^2.3.2",
"svelte-preprocess": "^4.10.6",
"svelte-preprocess": "^4.10.7",
"tailwindcss": "^3.0.24",
"tslib": "^2.3.1",
"typescript": "^4.7.4",

View File

@@ -1,5 +1,4 @@
import { AssetCountByTimeGroupResponseDto } from '@api';
let _basePath = '/api';
const _basePath = '/api';
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);

View File

@@ -6,86 +6,93 @@
@tailwind utilities;
:root {
font-family: 'Work Sans', sans-serif;
/* --immich-icon-button-hover-color: #d3d3d3; */
font-family: 'Work Sans', sans-serif;
/* --immich-icon-button-hover-color: #d3d3d3; */
}
html {
height: 100%;
width: 100%;
height: 100%;
width: 100%;
}
html::-webkit-scrollbar {
width: 8px;
width: 8px;
}
/* Track */
html::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 16px;
background: #f1f1f1;
border-radius: 16px;
}
/* Handle */
html::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
}
/* Handle on hover */
html::-webkit-scrollbar-thumb:hover {
background: #4250afad;
border-radius: 16px;
background: #4250afad;
border-radius: 16px;
}
body {
/* min-height: 100vh; */
margin: 0;
background-color: #f6f8fe;
color: #5f6368;
/* min-height: 100vh; */
margin: 0;
background-color: #f6f8fe;
color: #5f6368;
}
input:focus-visible {
outline-offset: 0px !important;
outline: none !important;
outline-offset: 0px !important;
outline: none !important;
}
@layer utilities {
.immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm ;
}
.immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm;
}
.immich-form-label {
@apply font-medium text-sm text-gray-500;
}
.immich-form-label {
@apply font-medium text-sm text-gray-500;
}
.immich-btn-primary {
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
}
.immich-btn-primary {
@apply bg-immich-primary text-gray-100 border rounded-xl py-2 px-4 transition-all duration-150 hover:bg-immich-primary hover:shadow-lg text-sm font-medium;
}
.immich-text-button {
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
}
.immich-text-button {
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
}
/* width */
.immich-scrollbar::-webkit-scrollbar {
width: 8px;
}
/* width */
.immich-scrollbar::-webkit-scrollbar {
width: 8px;
}
/* Track */
.immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 16px;
}
/* Track */
.immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 16px;
}
/* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
}
/* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408);
border-radius: 16px;
}
/* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad;
border-radius: 16px;
}
/* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad;
border-radius: 16px;
}
/* Hidden scrollbar */
/* width */
.scrollbar-hidden::-webkit-scrollbar {
display: none;
scrollbar-width: none;
}
}

View File

@@ -0,0 +1,8 @@
const createObjectURLMock = jest.fn();
Object.defineProperty(URL, 'createObjectURL', {
writable: true,
value: createObjectURLMock
});
export { createObjectURLMock };

View File

@@ -0,0 +1,144 @@
import { jest, describe, it } from '@jest/globals';
import { render, RenderResult, waitFor, fireEvent } from '@testing-library/svelte';
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
import { api, ThumbnailFormat } from '@api';
import { albumFactory } from '@test-data';
import AlbumCard from '../album-card.svelte';
import '@testing-library/jest-dom';
jest.mock('@api');
const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>;
describe('AlbumCard component', () => {
let sut: RenderResult<AlbumCard>;
it.each([
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 0 }),
count: 0,
shared: false
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 0 }),
count: 0,
shared: true
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: false, assetCount: 5 }),
count: 5,
shared: false
},
{
album: albumFactory.build({ albumThumbnailAssetId: null, shared: true, assetCount: 2 }),
count: 2,
shared: true
}
])(
'shows album data without thumbnail with count $count - shared: $shared',
async ({ album, count, shared }) => {
sut = render(AlbumCard, { album });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
const detailsText = `${count} items` + (shared ? ' . Shared' : '');
// TODO: is this a bug?
expect(albumImgElement).toHaveAttribute('src', '/api/asset/thumbnail/null?format=WEBP');
expect(albumImgElement).toHaveAttribute('alt', album.id);
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', 'no-thumbnail.png'));
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).not.toHaveBeenCalled();
expect(albumNameElement).toHaveTextContent(album.albumName);
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
}
);
it('shows album data and and loads the thumbnail image when available', async () => {
const thumbnailBlob = new Blob();
const thumbnailUrl = 'blob:thumbnailUrlOne';
apiMock.assetApi.getAssetThumbnail.mockResolvedValue({
data: thumbnailBlob,
config: {},
headers: {},
status: 200,
statusText: ''
});
createObjectURLMock.mockReturnValueOnce(thumbnailUrl);
const album = albumFactory.build({
albumThumbnailAssetId: 'thumbnailIdOne',
shared: false,
albumName: 'some album name'
});
sut = render(AlbumCard, { album });
const albumImgElement = sut.getByTestId('album-image');
const albumNameElement = sut.getByTestId('album-name');
const albumDetailsElement = sut.getByTestId('album-details');
// TODO: is this expected?
expect(albumImgElement).toHaveAttribute(
'src',
'/api/asset/thumbnail/thumbnailIdOne?format=WEBP'
);
expect(albumImgElement).toHaveAttribute('alt', album.id);
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', thumbnailUrl));
expect(albumImgElement).toHaveAttribute('alt', album.id);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledTimes(1);
expect(apiMock.assetApi.getAssetThumbnail).toHaveBeenCalledWith(
'thumbnailIdOne',
ThumbnailFormat.Jpeg,
{ responseType: 'blob' }
);
expect(createObjectURLMock).toHaveBeenCalledWith(thumbnailBlob);
expect(albumNameElement).toHaveTextContent('some album name');
expect(albumDetailsElement).toHaveTextContent('0 items');
});
describe('with rendered component - no thumbnail', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
beforeEach(async () => {
sut = render(AlbumCard, { album });
const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src', 'no-thumbnail.png'));
});
it('dispatches custom "click" event with the album in context', async () => {
const onClickHandler = jest.fn();
sut.component.$on('click', onClickHandler);
const albumCardElement = sut.getByTestId('album-card');
await fireEvent.click(albumCardElement);
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: album }));
});
it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
const onClickHandler = jest.fn();
sut.component.$on('showalbumcontextmenu', onClickHandler);
const contextMenuBtnParent = sut.getByTestId('context-button-parent');
await fireEvent(
contextMenuBtnParent,
new MouseEvent('click', {
clientX: 123,
clientY: 456
})
);
expect(onClickHandler).toHaveBeenCalledTimes(1);
expect(onClickHandler).toHaveBeenCalledWith(
expect.objectContaining({ detail: { x: 123, y: 456 } })
);
});
});
});

View File

@@ -15,12 +15,11 @@
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import { fly } from 'svelte/transition';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
export let album: AlbumResponseDto;
let imageData: string = `/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=${ThumbnailFormat.Webp}`;
let imageData = `/api/asset/thumbnail/${album.albumThumbnailAssetId}?format=${ThumbnailFormat.Webp}`;
const dispatchClick = createEventDispatcher<OnClick>();
const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>();
@@ -29,7 +28,7 @@
return;
}
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Jpeg, {
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Jpeg, {
responseType: 'blob'
});
@@ -53,11 +52,13 @@
<div
class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative"
on:click={() => dispatchClick('click', album)}
data-testid="album-card"
>
<div
id={`icon-${album.id}`}
class="absolute top-2 right-2"
on:click|stopPropagation|preventDefault={showAlbumContextMenu}
data-testid="context-button-parent"
>
<CircleIconButton
logo={DotsVertical}
@@ -72,15 +73,16 @@
src={imageData}
alt={album.id}
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
data-testid="album-image"
/>
</div>
<div class="mt-4">
<p class="text-sm font-medium text-gray-800">
<p class="text-sm font-medium text-gray-800" data-testid="album-name">
{album.albumName}
</p>
<span class="text-xs flex gap-2">
<span class="text-xs flex gap-2" data-testid="album-details">
<p>{album.assetCount} items</p>
{#if album.shared}

View File

@@ -11,7 +11,6 @@
import CircleAvatar from '../shared-components/circle-avatar.svelte';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import AssetSelection from './asset-selection.svelte';
import _ from 'lodash-es';
import UserSelectionModal from './user-selection-modal.svelte';
import ShareInfoModal from './share-info-modal.svelte';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
@@ -27,12 +26,16 @@
NotificationType
} from '../shared-components/notification/notification';
import { browser } from '$app/env';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
export let album: AlbumResponseDto;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
let isShowAssetViewer = false;
let isShowAssetSelection = false;
$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
$: {
if (browser) {
if (isShowAssetSelection) {
@@ -53,8 +56,7 @@
let currentViewAssetIndex = 0;
let viewWidth: number;
let thumbnailSize: number = 300;
let border = '';
let thumbnailSize = 300;
let backUrl = '/albums';
let currentAlbumName = '';
let currentUser: UserResponseDto;

View File

@@ -2,9 +2,7 @@
import { createEventDispatcher } from 'svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
const dispatch = createEventDispatcher();

View File

@@ -18,7 +18,7 @@
$: {
appearsInAlbums = [];
api.albumApi.getAllAlbums(undefined, asset.id).then(result => {
api.albumApi.getAllAlbums(undefined, asset.id).then((result) => {
appearsInAlbums = result.data;
});
}
@@ -29,12 +29,14 @@
let isShowDetail = false;
let appearsInAlbums: AlbumResponseDto[] = [];
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
onMount(() => {
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
document.addEventListener('keydown', onKeyboardPress);
});
onDestroy(() => {
document.removeEventListener('keydown', (e) => {});
document.removeEventListener('keydown', onKeyboardPress);
});
const handleKeyboardPress = (key: string) => {

View File

@@ -7,12 +7,17 @@
import moment from 'moment';
import { createEventDispatcher, onMount } from 'svelte';
import { browser } from '$app/env';
import { env } from '$env/dynamic/public';
import { AssetResponseDto, AlbumResponseDto } from '@api';
type Leaflet = typeof import('leaflet');
type LeafletMap = import('leaflet').Map;
type LeafletMarker = import('leaflet').Marker;
// Map Property
let map: any;
let leaflet: any;
let marker: any;
let map: LeafletMap;
let leaflet: Leaflet;
let marker: LeafletMarker;
export let asset: AssetResponseDto;
$: if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
@@ -26,12 +31,18 @@
if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
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);
}
}
});
async function drawMap(lat: number, lon: number) {
if (!leaflet) {
// @ts-ignore
leaflet = await import('leaflet');
}
@@ -123,11 +134,7 @@
<p>{moment(asset.exifInfo.dateTimeOriginal).format('MMM DD, YYYY')}</p>
<div class="flex gap-2 text-sm">
<p>
{moment(
asset.exifInfo.dateTimeOriginal
.toString()
.slice(0, asset.exifInfo.dateTimeOriginal.toString().length - 1)
).format('ddd, hh:mm A')}
{moment(asset.exifInfo.dateTimeOriginal).format('ddd, hh:mm A')}
</p>
<p>GMT{moment(asset.exifInfo.dateTimeOriginal).format('Z')}</p>
</div>

View File

@@ -10,7 +10,7 @@
export let root: HTMLElement | null = null;
let intersecting = false;
let container: any;
let container: HTMLDivElement;
const dispatch = createEventDispatcher();
onMount(() => {

View File

@@ -33,7 +33,9 @@
const assetData = URL.createObjectURL(data);
return assetData;
} catch (e) {}
} catch {
// Do nothing
}
};
</script>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { createEventDispatcher, onMount } from 'svelte';
import { onMount } from 'svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api, AssetResponseDto, getFileUrl } from '@api';
@@ -9,8 +9,6 @@
let asset: AssetResponseDto;
const dispatch = createEventDispatcher();
let videoPlayerNode: HTMLVideoElement;
let isVideoLoading = true;
let videoUrl: string;

View File

@@ -5,8 +5,8 @@
let error: string;
let success: string;
let password: string = '';
let confirmPassowrd: string = '';
let password = '';
let confirmPassowrd = '';
let canRegister = false;

View File

@@ -6,8 +6,8 @@
let error: string;
let success: string;
let password: string = '';
let confirmPassowrd: string = '';
let password = '';
let confirmPassowrd = '';
let changeChagePassword = false;

View File

@@ -1,122 +1,121 @@
<script lang="ts">
import { api } from '@api';
import { createEventDispatcher } from 'svelte';
import { api } from '@api';
import { createEventDispatcher } from 'svelte';
let error: string;
let success: string;
let error: string;
let success: string;
let password = '';
let confirmPassowrd = '';
let password = '';
let confirmPassowrd = '';
let canCreateUser = false;
let canCreateUser = false;
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
canCreateUser = false;
} else {
error = '';
canCreateUser = true;
}
}
const dispatch = createEventDispatcher();
$: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
error = 'Password does not match';
canCreateUser = false;
} else {
error = '';
canCreateUser = true;
}
}
const dispatch = createEventDispatcher();
async function registerUser(event: SubmitEvent) {
if (canCreateUser) {
error = '';
async function registerUser(event: SubmitEvent) {
if (canCreateUser) {
error = '';
const formElement = event.target as HTMLFormElement;
const formElement = event.target as HTMLFormElement;
const form = new FormData(formElement);
const form = new FormData(formElement);
const email = form.get('email');
const password = form.get('password');
const firstName = form.get('firstName');
const lastName = form.get('lastName');
const email = form.get('email');
const password = form.get('password');
const firstName = form.get('firstName');
const lastName = form.get('lastName');
const {status} = await api.userApi.createUser({
email: String(email),
password: String(password),
firstName: String(firstName),
lastName: String(lastName)
});
const { status } = await api.userApi.createUser({
email: String(email),
password: String(password),
firstName: String(firstName),
lastName: String(lastName)
});
if (status === 201) {
success = 'New user created';
if (status === 201) {
success = 'New user created';
dispatch('user-created');
return;
} else {
error = 'Error create user account';
}
}
}
dispatch('user-created');
return;
} else {
error = 'Error create user account';
}
}
}
</script>
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo"/>
<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
Please provide your user with the password, they will have to change it on their first sign
in.
</p>
</div>
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
<h1 class="text-2xl text-immich-primary font-medium">Create new user</h1>
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
Please provide your user with the password, they will have to change it on their first sign
in.
</p>
</div>
<form on:submit|preventDefault={registerUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required/>
</div>
<form on:submit|preventDefault={registerUser} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
required
bind:value={password}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
required
bind:value={password}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
</div>
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Create
</button
>
</div>
</form>
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 bg-immich-primary hover:bg-immich-primary/75 px-6 py-3 text-white rounded-full shadow-md w-full font-medium"
>Create
</button>
</div>
</form>
</div>

View File

@@ -14,7 +14,6 @@
const dispatch = createEventDispatcher();
// eslint-disable-next-line no-undef
const editUser = async (event: SubmitEvent) => {
try {
const formElement = event.target as HTMLFormElement;
@@ -25,8 +24,8 @@
const { status } = await api.userApi.updateUser({
id: user.id,
firstName: firstName!.toString(),
lastName: lastName!.toString()
firstName: firstName?.toString(),
lastName: lastName?.toString()
});
if (status == 200) {

View File

@@ -4,8 +4,8 @@
import { createEventDispatcher } from 'svelte';
let error: string;
let email: string = '';
let password: string = '';
let email = '';
let password = '';
const dispatch = createEventDispatcher();

View File

@@ -7,7 +7,6 @@
import lodash from 'lodash-es';
import moment from 'moment';
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
import {
assetInteractionStore,
assetsInAlbumStoreState,
@@ -22,7 +21,7 @@
let isMouseOverGroup = false;
let actualBucketHeight: number;
let hoveredDateGroup: string = '';
let hoveredDateGroup = '';
$: assetsGroupByDate = lodash
.chain(assets)
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))

View File

@@ -3,7 +3,7 @@
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
import { api, TimeGroupEnum } from '@api';
import { api, AssetCountByTimeBucketResponseDto, TimeGroupEnum } from '@api';
import AssetDateGroup from './asset-date-group.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -12,16 +12,23 @@
isViewingAssetStoreState,
viewingAssetStoreState
} from '$lib/stores/asset-interaction.store';
import Scrollbar, {
OnScrollbarClickDetail,
OnScrollbarDragDetail
} from '../shared-components/scrollbar/scrollbar.svelte';
export let isAlbumSelectionMode = false;
let viewportHeight = 0;
let viewportWidth = 0;
let assetGridElement: HTMLElement;
export let isAlbumSelectionMode = false;
let bucketInfo: AssetCountByTimeBucketResponseDto;
onMount(async () => {
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
timeGroup: TimeGroupEnum.Month
});
bucketInfo = assetCountByTimebucket;
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
@@ -60,14 +67,46 @@
const navigateToNextAsset = () => {
assetInteractionStore.navigateAsset('next');
};
let lastScrollPosition = 0;
let animationTick = false;
const handleTimelineScroll = () => {
if (!animationTick) {
window.requestAnimationFrame(() => {
lastScrollPosition = assetGridElement?.scrollTop;
animationTick = false;
});
animationTick = true;
}
};
const handleScrollbarClick = (e: OnScrollbarClickDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
const handleScrollbarDrag = (e: OnScrollbarDragDetail) => {
assetGridElement.scrollTop = e.scrollTo;
};
</script>
{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
<Scrollbar
scrollbarHeight={viewportHeight}
scrollTop={lastScrollPosition}
on:onscrollbarclick={(e) => handleScrollbarClick(e.detail)}
on:onscrollbardrag={(e) => handleScrollbarDrag(e.detail)}
/>
{/if}
<section
id="asset-grid"
class="overflow-y-auto pl-4"
class="overflow-y-auto pl-4 scrollbar-hidden"
bind:clientHeight={viewportHeight}
bind:clientWidth={viewportWidth}
bind:this={assetGridElement}
on:scroll={handleTimelineScroll}
>
{#if assetGridElement}
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
@@ -117,5 +156,6 @@
<style>
#asset-grid {
contain: layout;
scrollbar-width: none;
}
</style>

View File

@@ -1,8 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createEventDispatcher } from 'svelte';
import { page } from '$app/stores';
import FullScreenModal from './full-screen-modal.svelte';
export let localVersion: string;
export let remoteVersion: string;

View File

@@ -22,7 +22,7 @@
onDestroy(() => {
if (browser) {
window.onscroll = function () {};
window.onscroll = null;
}
});
</script>

View File

@@ -5,7 +5,7 @@
export let user: UserResponseDto;
// Avatar Size In Pixel
export let size: number = 48;
export let size = 48;
const dispatch = createEventDispatcher();

View File

@@ -4,10 +4,12 @@
*/
import { createEventDispatcher } from 'svelte';
// TODO: why any here?
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let logo: any;
export let backgroundColor: string = 'transparent';
export let hoverColor: string = '#e2e7e9';
export let logoColor: string = '#5f6368';
export let backgroundColor = 'transparent';
export let hoverColor = '#e2e7e9';
export let logoColor = '#5f6368';
export let size = '24';
export let title = '';
let iconButton: HTMLButtonElement;

View File

@@ -6,15 +6,13 @@
/**
* x coordiante of the context menu.
* @type {number}
*/
export let x: number = 0;
export let x = 0;
/**
* x coordiante of the context menu.
* @type {number}
*/
export let y: number = 0;
export let y = 0;
const dispatch = createEventDispatcher();

View File

@@ -12,21 +12,23 @@
const dispatch = createEventDispatcher();
const onScroll = () => {
if (window.pageYOffset > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50';
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
}
};
onMount(() => {
if (browser) {
document.addEventListener('scroll', (e) => {
if (window.pageYOffset > 80) {
appBarBorder = 'border border-gray-200 bg-gray-50';
} else {
appBarBorder = 'bg-immich-bg border border-transparent';
}
});
document.addEventListener('scroll', onScroll);
}
});
onDestroy(() => {
if (browser) {
document.removeEventListener('scroll', (e) => {});
document.removeEventListener('scroll', onScroll);
}
});
</script>

View File

@@ -6,7 +6,7 @@
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
import LoadingSpinner from './loading-spinner.svelte';
import { api, AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
import { AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
const dispatch = createEventDispatcher();
@@ -14,14 +14,14 @@
export let groupIndex = 0;
export let thumbnailSize: number | undefined = undefined;
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
export let selected: boolean = false;
export let disabled: boolean = false;
export let selected = false;
export let disabled = false;
let imageData: string;
let mouseOver: boolean = false;
let mouseOver = false;
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
let mouseOverIcon: boolean = false;
let mouseOverIcon = false;
let videoPlayerNode: HTMLVideoElement;
let isThumbnailVideoPlaying = false;
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
@@ -136,7 +136,7 @@
<div
style:width={`${thumbnailSize}px`}
style:height={`${thumbnailSize}px`}
class={`bg-gray-100 relative ${getSize()} ${
class={`bg-gray-100 relative select-none ${getSize()} ${
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
}`}
on:mouseenter={handleMouseOverThumbnail}

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { createEventDispatcher, onMount } from 'svelte';
import { fade, fly, slide } from 'svelte/transition';
import { fade, fly } from 'svelte/transition';
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
import { clickOutside } from '../../utils/click-outside';
import { api, UserResponseDto } from '@api';

View File

@@ -0,0 +1,39 @@
import { jest, describe, it } from '@jest/globals';
import { render, cleanup, RenderResult } from '@testing-library/svelte';
import { NotificationType } from '../notification';
import NotificationCard from '../notification-card.svelte';
import '@testing-library/jest-dom';
describe('NotificationCard component', () => {
let sut: RenderResult<NotificationCard>;
it('disposes timeout if already removed from the DOM', () => {
jest.spyOn(window, 'clearTimeout');
sut = render(NotificationCard, {
notificationInfo: {
id: 1234,
message: 'Notification message',
timeout: 1000,
type: NotificationType.Info
}
});
cleanup();
expect(window.clearTimeout).toHaveBeenCalledTimes(1);
});
it('shows message and title', () => {
sut = render(NotificationCard, {
notificationInfo: {
id: 1234,
message: 'Notification message',
timeout: 1000,
type: NotificationType.Info
}
});
expect(sut.getByTestId('title')).toHaveTextContent('Info');
expect(sut.getByTestId('message')).toHaveTextContent('Notification message');
});
});

View File

@@ -0,0 +1,44 @@
import { jest, describe, it } from '@jest/globals';
import { render, RenderResult, waitFor } from '@testing-library/svelte';
import { notificationController, NotificationType } from '../notification';
import { get } from 'svelte/store';
import NotificationList from '../notification-list.svelte';
import '@testing-library/jest-dom';
function _getNotificationListElement(
sut: RenderResult<NotificationList>
): HTMLAnchorElement | null {
return sut.container.querySelector('#notification-list');
}
describe('NotificationList component', () => {
const sut: RenderResult<NotificationList> = render(NotificationList);
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
expect(_getNotificationListElement(sut)).not.toBeInTheDocument();
notificationController.show({
message: 'Notification',
type: NotificationType.Info,
timeout: 3000
});
await waitFor(() => expect(_getNotificationListElement(sut)).toBeInTheDocument());
expect(_getNotificationListElement(sut)?.children).toHaveLength(1);
jest.advanceTimersByTime(3000);
// due to some weirdness in svelte (or testing-library) need to check if it has been removed from the store to make sure it works.
expect(get(notificationController.notificationList)).toHaveLength(0);
await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
});
});

View File

@@ -48,10 +48,14 @@
}
};
let removeNotificationTimeout: NodeJS.Timeout | undefined = undefined;
onMount(() => {
setTimeout(() => {
removeNotificationTimeout = setTimeout(() => {
notificationController.removeNotificationById(notificationInfo.id);
}, notificationInfo.timeout);
return () => clearTimeout(removeNotificationTimeout);
});
</script>
@@ -63,8 +67,10 @@
>
<div class="flex gap-2 place-items-center">
<svelte:component this={icon} color={primaryColor()} size="20" />
<h2 style:color={primaryColor()} class="font-medium">{notificationInfo.type.toString()}</h2>
<h2 style:color={primaryColor()} class="font-medium" data-testid="title">
{notificationInfo.type.toString()}
</h2>
</div>
<p class="text-sm pl-[28px] pr-[16px]">{notificationInfo.message}</p>
<p class="text-sm pl-[28px] pr-[16px]" data-testid="message">{notificationInfo.message}</p>
</div>

View File

@@ -1,25 +1,21 @@
<script lang="ts">
import { ImmichNotification, notificationController } from './notification';
import { notificationController } from './notification';
import { fade } from 'svelte/transition';
import NotificationCard from './notification-card.svelte';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
let notificationList: ImmichNotification[] = [];
notificationController.notificationList.subscribe((list) => {
notificationList = list;
});
const { notificationList } = notificationController;
</script>
{#if notificationList.length > 0}
{#if $notificationList.length > 0}
<section
transition:fade={{ duration: 250 }}
id="notification-list"
class="absolute right-5 top-[80px] z-[99999999]"
>
{#each notificationList as notificationInfo (notificationInfo.id)}
{#each $notificationList as notificationInfo (notificationInfo.id)}
<div animate:flip={{ duration: 250, easing: quintOut }}>
<NotificationCard {notificationInfo} />
</div>

View File

@@ -3,13 +3,10 @@
/**
* Usage: <div use:portal={'css selector'}> or <div use:portal={document.body}>
*
* @param {HTMLElement} el
* @param {HTMLElement|string} target DOM Element or CSS Selector
*/
export function portal(el: any, target: any = 'body') {
export function portal(el: HTMLElement, target: HTMLElement | string = 'body') {
let targetEl;
async function update(newTarget: any) {
async function update(newTarget: HTMLElement | string) {
target = newTarget;
if (typeof target === 'string') {
targetEl = document.querySelector(target);

View File

@@ -1,76 +1,111 @@
<script lang="ts" context="module">
type OnScrollbarClick = {
onscrollbarclick: OnScrollbarClickDetail;
};
export type OnScrollbarClickDetail = {
scrollTo: number;
};
type OnScrollbarDrag = {
onscrollbardrag: OnScrollbarDragDetail;
};
export type OnScrollbarDragDetail = {
scrollTo: number;
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
import { assetGridState } from '$lib/stores/assets.store';
import { createEventDispatcher } from 'svelte';
import { SegmentScrollbarLayout } from './segment-scrollbar-layout';
export let scrollTop = 0;
export let viewportWidth = 0;
export let scrollbarHeight = 0;
let timelineHeight = 0;
$: timelineHeight = $assetGridState.timelineHeight;
$: viewportWidth = $assetGridState.viewportWidth;
$: timelineScrolltop = (scrollbarPosition / scrollbarHeight) * timelineHeight;
let segmentScrollbarLayout: SegmentScrollbarLayout[] = [];
let isHover = false;
let isDragging = false;
let hoveredDate: Date;
let currentMouseYLocation: number = 0;
let currentMouseYLocation = 0;
let scrollbarPosition = 0;
let animationTick = false;
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
$: offset = $isAlbumAssetSelectionOpen ? 100 : 71;
const dispatchClick = createEventDispatcher<OnScrollbarClick>();
const dispatchDrag = createEventDispatcher<OnScrollbarDrag>();
$: {
scrollbarPosition = (scrollTop / timelineHeight) * scrollbarHeight;
}
$: {
// let result: SegmentScrollbarLayout[] = [];
// for (const [i, segment] of assetStoreState.entries()) {
// let segmentLayout = new SegmentScrollbarLayout();
// segmentLayout.count = segmentData.groups[i].count;
// segmentLayout.height =
// segment.assets.length == 0
// ? getSegmentHeight(segmentData.groups[i].count)
// : Math.round((segment.segmentHeight / timelineHeight) * scrollbarHeight);
// segmentLayout.timeGroup = segment.segmentDate;
// result.push(segmentLayout);
// }
// segmentScrollbarLayout = result;
let result: SegmentScrollbarLayout[] = [];
for (const bucket of $assetGridState.buckets) {
let segmentLayout = new SegmentScrollbarLayout();
segmentLayout.count = bucket.assets.length;
segmentLayout.height = (bucket.bucketHeight / timelineHeight) * scrollbarHeight;
segmentLayout.timeGroup = bucket.bucketDate;
result.push(segmentLayout);
}
segmentScrollbarLayout = result;
}
onMount(() => {
// segmentScrollbarLayout = getLayoutDistance();
return () => {};
});
const getSegmentHeight = (groupCount: number) => {
// if (segmentData.groups.length > 0) {
// const percentage = (groupCount * 100) / segmentData.totalAssets;
// return Math.round((percentage * scrollbarHeight) / 100);
// } else {
// return 0;
// }
};
const getLayoutDistance = () => {
// let result: SegmentScrollbarLayout[] = [];
// for (const segment of segmentData.groups) {
// let segmentLayout = new SegmentScrollbarLayout();
// segmentLayout.count = segment.count;
// segmentLayout.height = getSegmentHeight(segment.count);
// segmentLayout.timeGroup = segment.timeGroup;
// result.push(segmentLayout);
// }
// return result;
};
const handleMouseMove = (e: MouseEvent, currentDate: Date) => {
currentMouseYLocation = e.clientY - 71 - 30;
currentMouseYLocation = e.clientY - offset - 30;
hoveredDate = new Date(currentDate.toISOString().slice(0, -1));
};
const handleMouseDown = (e: MouseEvent) => {
isDragging = true;
scrollbarPosition = e.clientY - offset;
};
const handleMouseUp = (e: MouseEvent) => {
isDragging = false;
scrollbarPosition = e.clientY - offset;
dispatchClick('onscrollbarclick', { scrollTo: timelineScrolltop });
};
const handleMouseDrag = (e: MouseEvent) => {
if (isDragging) {
if (!animationTick) {
window.requestAnimationFrame(() => {
const dy = e.clientY - scrollbarPosition - offset;
scrollbarPosition += dy;
dispatchDrag('onscrollbardrag', { scrollTo: timelineScrolltop });
animationTick = false;
});
animationTick = true;
}
}
};
</script>
<div
id="immich-scubbable-scrollbar"
class="fixed right-0 w-[60px] h-full bg-immich-bg z-[9999] hover:cursor-row-resize"
id="immich-scrubbable-scrollbar"
class="fixed right-0 bg-immich-bg z-10 hover:cursor-row-resize select-none"
style:width={isDragging ? '100vw' : '60px'}
style:background-color={isDragging ? 'transparent' : 'transparent'}
on:mouseenter={() => (isHover = true)}
on:mouseleave={() => (isHover = false)}
on:mouseleave={() => {
isHover = false;
isDragging = false;
}}
on:mouseup={handleMouseUp}
on:mousemove={handleMouseDrag}
on:mousedown={handleMouseDown}
style:height={scrollbarHeight + 'px'}
>
{#if isHover}
<div
@@ -83,29 +118,33 @@
{/if}
<!-- Scroll Position Indicator Line -->
<div
class="absolute right-0 w-10 h-[2px] bg-immich-primary"
style:top={scrollbarPosition + 'px'}
/>
{#if !isDragging}
<div
class="absolute right-0 w-10 h-[2px] bg-immich-primary"
style:top={scrollbarPosition + 'px'}
/>
{/if}
<!-- Time Segment -->
{#each segmentScrollbarLayout as segment, index (segment.timeGroup)}
{@const groupDate = new Date(segment.timeGroup)}
<div
class="relative "
id="time-segment"
class="relative"
style:height={segment.height + 'px'}
aria-label={segment.timeGroup + ' ' + segment.count}
on:mousemove={(e) => handleMouseMove(e, groupDate)}
>
{#if new Date(segmentScrollbarLayout[index - 1]?.timeGroup).getFullYear() !== groupDate.getFullYear()}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 pr-3 z-10 text-xs font-medium"
>
{groupDate.getFullYear()}
</div>
{:else if segment.count > 5}
{#if segment.height > 8}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 pr-5 z-10 text-xs font-medium"
>
{groupDate.getFullYear()}
</div>
{/if}
{:else if segment.height > 5}
<div
aria-label={segment.timeGroup + ' ' + segment.count}
class="absolute right-0 rounded-full h-[4px] w-[4px] mr-3 bg-gray-300 block"
@@ -116,7 +155,8 @@
</div>
<style>
#immich-scubbable-scrollbar {
#immich-scrubbable-scrollbar,
#time-segment {
contain: layout;
}
</style>

View File

@@ -1,5 +1,7 @@
<script lang="ts">
export let title: string;
// TODO: why `any` here? There should be a expected type for this
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let logo: any;
export let actionType: AdminSideBarSelection | AppSideBarSelection;
export let isSelected: boolean;

Some files were not shown because too many files have changed in this diff Show More