Compare commits
30 Commits
v1.27.0_37
...
v1.28.2_40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31739aca02 | ||
|
|
8f2e7b6f65 | ||
|
|
4ed647c43d | ||
|
|
f88ff4fb5c | ||
|
|
cc4881d633 | ||
|
|
d856b35afc | ||
|
|
b6d025da09 | ||
|
|
cc79ff1ca3 | ||
|
|
131aa2b6be | ||
|
|
02a6b73122 | ||
|
|
d87366c095 | ||
|
|
4f7a3afbfc | ||
|
|
6725954b70 | ||
|
|
4fe535e5e8 | ||
|
|
aed94bfc4c | ||
|
|
de996c0a81 | ||
|
|
1a39aa4da5 | ||
|
|
1f4ba73da7 | ||
|
|
836b174d33 | ||
|
|
853a65aef1 | ||
|
|
566039b93f | ||
|
|
18a7ff8726 | ||
|
|
6ffdf167fe | ||
|
|
6b702b13e4 | ||
|
|
f476bd985b | ||
|
|
92c4f0598b | ||
|
|
a337402124 | ||
|
|
209e6332b3 | ||
|
|
645bd8a109 | ||
|
|
9a471d80f7 |
21
.github/workflows/test.yml
vendored
21
.github/workflows/test.yml
vendored
@@ -13,18 +13,29 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Run Immich Server 2E2 Test
|
- 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
|
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:
|
server-unit-tests:
|
||||||
name: Run unit test suites
|
name: Run server unit test suites and checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Run tests
|
- 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
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -27,6 +27,7 @@
|
|||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Screenshots](#screenshots)
|
- [Screenshots](#screenshots)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
|
- [Update](#update)
|
||||||
- [Mobile App](#-mobile-app)
|
- [Mobile App](#-mobile-app)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Support](#support)
|
- [Support](#support)
|
||||||
@@ -97,6 +98,8 @@ There are several services that compose Immich:
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
|
NOTE: When using a reverse proxy in front of Immich (such as NGINX), the reverse proxy might require extra configuration to allow large files to be uploaded (such as client_max_body_size in the case of NGINX).
|
||||||
|
|
||||||
## Testing One-step installation (not recommended for production)
|
## Testing One-step installation (not recommended for production)
|
||||||
|
|
||||||
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
> ⚠️ *This installation method is for evaluating Immich before futher customization to meet the users' needs.*
|
||||||
@@ -170,6 +173,14 @@ wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/.en
|
|||||||
|
|
||||||
<br/>
|
<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
|
# Mobile app
|
||||||
|
|
||||||
| F-Droid | Google Play | iOS |
|
| F-Droid | Google Play | iOS |
|
||||||
|
|||||||
32
dev-setup.md
Normal file
32
dev-setup.md
Normal 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`
|
||||||
25
install.sh
25
install.sh
@@ -6,10 +6,6 @@ RED='\033[0;31m'
|
|||||||
GREEN='\032[0;31m'
|
GREEN='\032[0;31m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
machine_has() {
|
|
||||||
type "$1" >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
create_immich_directory() {
|
create_immich_directory() {
|
||||||
echo "Creating Immich directory..."
|
echo "Creating Immich directory..."
|
||||||
mkdir -p ./immich-app/immich-data
|
mkdir -p ./immich-app/immich-data
|
||||||
@@ -45,18 +41,21 @@ populate_upload_location() {
|
|||||||
start_docker_compose() {
|
start_docker_compose() {
|
||||||
echo "Starting Immich's docker containers"
|
echo "Starting Immich's docker containers"
|
||||||
|
|
||||||
if machine_has "docker compose"; then {
|
if docker compose &> /dev/null; then
|
||||||
docker compose up --remove-orphans -d
|
docker_bin="docker compose"
|
||||||
|
elif docker-compose &> /dev/null; then
|
||||||
show_friendly_message
|
docker_bin="docker-compose"
|
||||||
exit 0
|
else
|
||||||
}; fi
|
echo 'Cannot find `docker compose` or `docker-compose`.'
|
||||||
|
exit 1
|
||||||
if machine_has "docker-compose"; then
|
fi
|
||||||
docker-compose up --remove-orphans -d
|
|
||||||
|
|
||||||
|
if $docker_bin up --remove-orphans -d; then
|
||||||
show_friendly_message
|
show_friendly_message
|
||||||
exit 0
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Could not start. Check for errors above."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId "app.alextran.immich"
|
applicationId "app.alextran.immich"
|
||||||
minSdkVersion 21
|
minSdkVersion 23
|
||||||
targetSdkVersion flutter.targetSdkVersion
|
targetSdkVersion flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
<service android:name=".AppClearedService" android:stopWithTask="false" />
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data android:name="flutterEmbedding" android:value="2" />
|
<meta-data android:name="flutterEmbedding" android:value="2" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
import android.content.Context
|
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.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
@@ -44,30 +39,30 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val ctx = context!!
|
val ctx = context!!
|
||||||
when(call.method) {
|
when(call.method) {
|
||||||
"initialize" -> { // needs to be called prior to any other method
|
"enable" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
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)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"start" -> {
|
"configure" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val immediate = args.get(0) as Boolean
|
val requireUnmeteredNetwork = args.get(0) as Boolean
|
||||||
val keepExisting = args.get(1) as Boolean
|
val requireCharging = args.get(1) as Boolean
|
||||||
val requireUnmeteredNetwork = args.get(2) as Boolean
|
ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging)
|
||||||
val requireCharging = args.get(3) as Boolean
|
result.success(true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
"stop" -> {
|
"disable" -> {
|
||||||
|
ContentObserverWorker.disable(ctx)
|
||||||
BackupWorker.stopWork(ctx)
|
BackupWorker.stopWork(ctx)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"isEnabled" -> {
|
"isEnabled" -> {
|
||||||
result.success(BackupWorker.isEnabled(ctx))
|
result.success(ContentObserverWorker.isEnabled(ctx))
|
||||||
}
|
}
|
||||||
"isIgnoringBatteryOptimizations" -> {
|
"isIgnoringBatteryOptimizations" -> {
|
||||||
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
|
result.success(BackupWorker.isIgnoringBatteryOptimizations(ctx))
|
||||||
|
|||||||
@@ -8,17 +8,12 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.os.SystemClock
|
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 android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.concurrent.futures.ResolvableFuture
|
import androidx.concurrent.futures.ResolvableFuture
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.Data
|
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.ListenableWorker
|
import androidx.work.ListenableWorker
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
@@ -26,6 +21,7 @@ import androidx.work.WorkerParameters
|
|||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkInfo
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.embedding.engine.dart.DartExecutor
|
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
|
* Starts the Dart runtime/engine and calls `_nativeEntry` function in
|
||||||
* `background.service.dart` to run the actual backup logic.
|
* `background.service.dart` to run the actual backup logic.
|
||||||
* Called by Android WorkManager when all constraints for the work are met,
|
* 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.
|
* i.e. battery is not low and optionally Wifi and charging are active.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ctx, params), MethodChannel.MethodCallHandler {
|
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 lateinit var backgroundChannel: MethodChannel
|
||||||
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
|
||||||
|
private var timeBackupStarted: Long = 0L
|
||||||
|
|
||||||
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
override fun startWork(): ListenableFuture<ListenableWorker.Result> {
|
||||||
|
|
||||||
|
Log.d(TAG, "startWork")
|
||||||
|
|
||||||
val ctx = applicationContext
|
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()) {
|
if (!flutterLoader.initialized()) {
|
||||||
flutterLoader.startInitialization(ctx)
|
flutterLoader.startInitialization(ctx)
|
||||||
@@ -73,14 +61,16 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
// Create a Notification channel if necessary
|
// Create a Notification channel if necessary
|
||||||
createChannel()
|
createChannel()
|
||||||
}
|
}
|
||||||
|
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
|
||||||
if (isIgnoringBatteryOptimizations) {
|
if (isIgnoringBatteryOptimizations) {
|
||||||
// normal background services can only up to 10 minutes
|
// normal background services can only up to 10 minutes
|
||||||
// foreground services are allowed to run indefinitely
|
// foreground services are allowed to run indefinitely
|
||||||
// requires battery optimizations to be disabled (either manually by the user
|
// requires battery optimizations to be disabled (either manually by the user
|
||||||
// or by the system learning that immich is important to 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))
|
setForegroundAsync(createForegroundInfo(title))
|
||||||
|
} else {
|
||||||
|
showBackgroundInfo(title)
|
||||||
}
|
}
|
||||||
engine = FlutterEngine(ctx)
|
engine = FlutterEngine(ctx)
|
||||||
|
|
||||||
@@ -115,6 +105,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onStopped() {
|
override fun onStopped() {
|
||||||
|
Log.d(TAG, "onStopped")
|
||||||
// called when the system has to stop this worker because constraints are
|
// called when the system has to stop this worker because constraints are
|
||||||
// no longer met or the system needs resources for more important tasks
|
// no longer met or the system needs resources for more important tasks
|
||||||
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
|
||||||
@@ -130,24 +121,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
|
|
||||||
private fun stopEngine(result: Result?) {
|
private fun stopEngine(result: Result?) {
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
|
Log.d(TAG, "stopEngine result=${result}")
|
||||||
resolvableFuture.set(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?.destroy()
|
||||||
engine = null
|
engine = null
|
||||||
|
clearBackgroundNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, r: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"initialized" ->
|
"initialized" -> {
|
||||||
|
timeBackupStarted = SystemClock.uptimeMillis()
|
||||||
backgroundChannel.invokeMethod(
|
backgroundChannel.invokeMethod(
|
||||||
"onAssetsChanged",
|
"onAssetsChanged",
|
||||||
null,
|
null,
|
||||||
@@ -163,25 +148,18 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
override fun success(receivedResult: Any?) {
|
override fun success(receivedResult: Any?) {
|
||||||
val success = receivedResult as Boolean
|
val success = receivedResult as Boolean
|
||||||
stopEngine(if(success) Result.success() else Result.retry())
|
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" -> {
|
"updateNotification" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
val title = args.get(0) as String
|
val title = args.get(0) as String
|
||||||
val content = args.get(1) as String
|
val content = args.get(1) as String
|
||||||
if (isIgnoringBatteryOptimizations) {
|
if (isIgnoringBatteryOptimizations) {
|
||||||
setForegroundAsync(createForegroundInfo(title, content))
|
setForegroundAsync(createForegroundInfo(title, content))
|
||||||
|
} else {
|
||||||
|
showBackgroundInfo(title, content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"showError" -> {
|
"showError" -> {
|
||||||
@@ -192,6 +170,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
showError(title, content, individualTag)
|
showError(title, content, individualTag)
|
||||||
}
|
}
|
||||||
"clearErrorNotifications" -> clearErrorNotifications()
|
"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()
|
else -> r.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +197,22 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
notificationManager.cancel(NOTIFICATION_ERROR_ID)
|
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 {
|
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
@@ -233,89 +235,61 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
companion object {
|
companion object {
|
||||||
const val SHARED_PREF_NAME = "immichBackgroundService"
|
const val SHARED_PREF_NAME = "immichBackgroundService"
|
||||||
const val SHARED_PREF_CALLBACK_KEY = "callbackDispatcherHandle"
|
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_NOTIFICATION_TITLE = "notificationTitle"
|
||||||
|
const val SHARED_PREF_LAST_CHANGE = "lastChange"
|
||||||
|
|
||||||
private const val TASK_NAME = "immich/photoListener"
|
private const val TASK_NAME_BACKUP = "immich/BackupWorker"
|
||||||
private const val DATA_KEY_UNMETERED = "unmetered"
|
|
||||||
private const val DATA_KEY_CHARGING = "charging"
|
|
||||||
private const val DATA_KEY_RETRIES = "retries"
|
|
||||||
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
|
||||||
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
|
||||||
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
private const val NOTIFICATION_ERROR_ID = 2
|
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.
|
* Enqueues the BackupWorker to run once the 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)
|
|
||||||
*/
|
*/
|
||||||
fun startWork(context: Context,
|
fun enqueueBackupWorker(context: Context,
|
||||||
immediate: Boolean = false,
|
requireWifi: Boolean = false,
|
||||||
keepExisting: Boolean = false,
|
requireCharging: Boolean = false,
|
||||||
requireUnmeteredNetwork: Boolean = false,
|
delayMilliseconds: Long = 0L) {
|
||||||
requireCharging: Boolean = false) {
|
val workRequest = buildWorkRequest(requireWifi, requireCharging, delayMilliseconds)
|
||||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
WorkManager.getInstance(context).enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.KEEP, workRequest)
|
||||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, true).apply()
|
Log.d(TAG, "enqueueBackupWorker: BackupWorker enqueued")
|
||||||
enqueueMoreWork(context, immediate, keepExisting, requireUnmeteredNetwork, requireCharging)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueMoreWork(context: Context,
|
/**
|
||||||
immediate: Boolean = false,
|
* Updates the constraints of an already enqueued BackupWorker
|
||||||
keepExisting: Boolean = false,
|
*/
|
||||||
requireUnmeteredNetwork: Boolean = false,
|
fun updateBackupWorker(context: Context,
|
||||||
requireCharging: Boolean = false,
|
requireWifi: Boolean = false,
|
||||||
initialDelayInMs: Long = 0,
|
requireCharging: Boolean = false) {
|
||||||
retries: Int = 0) {
|
try {
|
||||||
if (!isEnabled(context)) {
|
val wm = WorkManager.getInstance(context)
|
||||||
return
|
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
|
* Stops the currently running worker (if any) and removes it from the work queue
|
||||||
*/
|
*/
|
||||||
fun stopWork(context: Context) {
|
fun stopWork(context: Context) {
|
||||||
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME_BACKUP)
|
||||||
.edit().putBoolean(SHARED_PREF_SERVICE_ENABLED, false).apply()
|
Log.d(TAG, "stopWork: BackupWorker cancelled")
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(TASK_NAME)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,12 +304,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun buildWorkRequest(requireWifi: Boolean = false,
|
||||||
* Return true if the user has enabled the background backup service
|
requireCharging: Boolean = false,
|
||||||
*/
|
delayMilliseconds: Long = 0L): OneTimeWorkRequest {
|
||||||
fun isEnabled(ctx: Context): Boolean {
|
val constraints = Constraints.Builder()
|
||||||
return ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
.setRequiredNetworkType(if (requireWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||||
.getBoolean(SHARED_PREF_SERVICE_ENABLED, false)
|
.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()
|
private val flutterLoader = FlutterLoader()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -2,6 +2,8 @@ package app.alextran.immich
|
|||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
|
|
||||||
@@ -10,4 +12,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
flutterEngine.getPlugins().add(BackgroundServicePlugin())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
startService(Intent(getBaseContext(), AppClearedService::class.java));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 37,
|
"android.injected.version.code" => 40,
|
||||||
"android.injected.version.name" => "1.27.0",
|
"android.injected.version.name" => "1.28.2",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* Fixed remove empty translations
|
||||||
|
* Fixed search page crashes the app on some Android models
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Improve Android background service reliability
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* Fix background service cannot run in release build
|
||||||
@@ -12,8 +12,6 @@
|
|||||||
"album_viewer_appbar_share_leave": "Album verlassen",
|
"album_viewer_appbar_share_leave": "Album verlassen",
|
||||||
"album_viewer_appbar_share_remove": "Entferne vom Album",
|
"album_viewer_appbar_share_remove": "Entferne vom Album",
|
||||||
"album_viewer_page_share_add_users": "Nutzer hinzufügen",
|
"album_viewer_page_share_add_users": "Nutzer hinzufügen",
|
||||||
"asset_list_settings_subtitle": "",
|
|
||||||
"asset_list_settings_title": "",
|
|
||||||
"backup_album_selection_page_albums_device": "Alben auf dem Gerät ({})",
|
"backup_album_selection_page_albums_device": "Alben auf dem Gerät ({})",
|
||||||
"backup_album_selection_page_albums_tap": "Tippen um einzuschließen, doppelt tippen um zu entfernen",
|
"backup_album_selection_page_albums_tap": "Tippen um einzuschließen, doppelt tippen um zu entfernen",
|
||||||
"backup_album_selection_page_assets_scatter": "Elemente können sich über mehrere Alben verteilen. Daher können diese vor der Sicherung eingeschlossen oder ausgeschlossen werden",
|
"backup_album_selection_page_assets_scatter": "Elemente können sich über mehrere Alben verteilen. Daher können diese vor der Sicherung eingeschlossen oder ausgeschlossen werden",
|
||||||
@@ -21,26 +19,7 @@
|
|||||||
"backup_album_selection_page_selection_info": "Auswahl",
|
"backup_album_selection_page_selection_info": "Auswahl",
|
||||||
"backup_album_selection_page_total_assets": "Elemente",
|
"backup_album_selection_page_total_assets": "Elemente",
|
||||||
"backup_all": "Alle",
|
"backup_all": "Alle",
|
||||||
"backup_background_service_backup_failed_message": "",
|
|
||||||
"backup_background_service_connection_failed_message": "",
|
|
||||||
"backup_background_service_current_upload_notification": "",
|
|
||||||
"backup_background_service_default_notification": "",
|
|
||||||
"backup_background_service_error_title": "",
|
|
||||||
"backup_background_service_in_progress_notification": "",
|
|
||||||
"backup_background_service_upload_failure_notification": "",
|
|
||||||
"backup_controller_page_albums": "Gesicherte Alben",
|
"backup_controller_page_albums": "Gesicherte Alben",
|
||||||
"backup_controller_page_background_battery_info_link": "",
|
|
||||||
"backup_controller_page_background_battery_info_message": "",
|
|
||||||
"backup_controller_page_background_battery_info_ok": "",
|
|
||||||
"backup_controller_page_background_battery_info_title": "",
|
|
||||||
"backup_controller_page_background_charging": "",
|
|
||||||
"backup_controller_page_background_configure_error": "",
|
|
||||||
"backup_controller_page_background_description": "",
|
|
||||||
"backup_controller_page_background_is_off": "",
|
|
||||||
"backup_controller_page_background_is_on": "",
|
|
||||||
"backup_controller_page_background_turn_off": "",
|
|
||||||
"backup_controller_page_background_turn_on": "",
|
|
||||||
"backup_controller_page_background_wifi": "",
|
|
||||||
"backup_controller_page_backup": "Sicherung",
|
"backup_controller_page_backup": "Sicherung",
|
||||||
"backup_controller_page_backup_selected": "Ausgewählt: ",
|
"backup_controller_page_backup_selected": "Ausgewählt: ",
|
||||||
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
|
"backup_controller_page_backup_sub": "Gesicherte Fotos und Videos",
|
||||||
@@ -69,19 +48,6 @@
|
|||||||
"backup_controller_page_uploading_file_info": "Informationen",
|
"backup_controller_page_uploading_file_info": "Informationen",
|
||||||
"backup_err_only_album": "Das einzige Album kann nicht entfernt werden",
|
"backup_err_only_album": "Das einzige Album kann nicht entfernt werden",
|
||||||
"backup_info_card_assets": "Elemente",
|
"backup_info_card_assets": "Elemente",
|
||||||
"cache_settings_album_thumbnails": "",
|
|
||||||
"cache_settings_clear_cache_button": "",
|
|
||||||
"cache_settings_clear_cache_button_title": "",
|
|
||||||
"cache_settings_image_cache_size": "",
|
|
||||||
"cache_settings_statistics_album": "",
|
|
||||||
"cache_settings_statistics_assets": "",
|
|
||||||
"cache_settings_statistics_full": "",
|
|
||||||
"cache_settings_statistics_shared": "",
|
|
||||||
"cache_settings_statistics_thumbnail": "",
|
|
||||||
"cache_settings_statistics_title": "",
|
|
||||||
"cache_settings_subtitle": "",
|
|
||||||
"cache_settings_thumbnail_size": "",
|
|
||||||
"cache_settings_title": "",
|
|
||||||
"control_bottom_app_bar_delete": "Löschen",
|
"control_bottom_app_bar_delete": "Löschen",
|
||||||
"control_bottom_app_bar_share": "Teilen",
|
"control_bottom_app_bar_share": "Teilen",
|
||||||
"create_album_page_untitled": "Unbenannt",
|
"create_album_page_untitled": "Unbenannt",
|
||||||
@@ -127,13 +93,6 @@
|
|||||||
"select_additional_user_for_sharing_page_suggestions": "Vorschläge",
|
"select_additional_user_for_sharing_page_suggestions": "Vorschläge",
|
||||||
"select_user_for_sharing_page_err_album": "Album konnte nicht erstellt werden",
|
"select_user_for_sharing_page_err_album": "Album konnte nicht erstellt werden",
|
||||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||||
"setting_notifications_notify_failures_grace_period": "",
|
|
||||||
"setting_notifications_notify_hours": "",
|
|
||||||
"setting_notifications_notify_immediately": "",
|
|
||||||
"setting_notifications_notify_minutes": "",
|
|
||||||
"setting_notifications_notify_never": "",
|
|
||||||
"setting_notifications_subtitle": "",
|
|
||||||
"setting_notifications_title": "",
|
|
||||||
"setting_pages_app_bar_settings": "Einstellungen",
|
"setting_pages_app_bar_settings": "Einstellungen",
|
||||||
"share_add": "Hinzufügen",
|
"share_add": "Hinzufügen",
|
||||||
"share_add_photos": "Fotos hinzufügen",
|
"share_add_photos": "Fotos hinzufügen",
|
||||||
@@ -150,8 +109,6 @@
|
|||||||
"tab_controller_nav_photos": "Fotos",
|
"tab_controller_nav_photos": "Fotos",
|
||||||
"tab_controller_nav_search": "Suche",
|
"tab_controller_nav_search": "Suche",
|
||||||
"tab_controller_nav_sharing": "Teilen",
|
"tab_controller_nav_sharing": "Teilen",
|
||||||
"theme_setting_asset_list_storage_indicator_title": "",
|
|
||||||
"theme_setting_asset_list_tiles_per_row_title": "",
|
|
||||||
"theme_setting_dark_mode_switch": "Dunkler Modus",
|
"theme_setting_dark_mode_switch": "Dunkler Modus",
|
||||||
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
"theme_setting_image_viewer_quality_subtitle": "Einstellen der Qualität des Detailbildbetrachters",
|
||||||
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
"theme_setting_image_viewer_quality_title": "Qualität des Bildbetrachters",
|
||||||
|
|||||||
@@ -21,12 +21,8 @@
|
|||||||
"backup_controller_page_backup_selected": "Seleccionado:",
|
"backup_controller_page_backup_selected": "Seleccionado:",
|
||||||
"backup_controller_page_backup_sub": "Copia de seguridad de fotos y vídeos",
|
"backup_controller_page_backup_sub": "Copia de seguridad de fotos y vídeos",
|
||||||
"backup_controller_page_cancel": "Cancelar",
|
"backup_controller_page_cancel": "Cancelar",
|
||||||
"backup_controller_page_created": "",
|
|
||||||
"backup_controller_page_desc_backup": "Active la copia de seguridad para cargar automáticamente los nuevos activos al servidor.",
|
"backup_controller_page_desc_backup": "Active la copia de seguridad para cargar automáticamente los nuevos activos al servidor.",
|
||||||
"backup_controller_page_excluded": "Excluido:",
|
"backup_controller_page_excluded": "Excluido:",
|
||||||
"backup_controller_page_failed": "",
|
|
||||||
"backup_controller_page_filename": "",
|
|
||||||
"backup_controller_page_id": "",
|
|
||||||
"backup_controller_page_info": "Información de la Copia de Seguridad",
|
"backup_controller_page_info": "Información de la Copia de Seguridad",
|
||||||
"backup_controller_page_none_selected": "Ninguno seleccionado",
|
"backup_controller_page_none_selected": "Ninguno seleccionado",
|
||||||
"backup_controller_page_remainder": "Remanente",
|
"backup_controller_page_remainder": "Remanente",
|
||||||
@@ -42,7 +38,6 @@
|
|||||||
"backup_controller_page_total_sub": "Todas las fotos y vídeos únicos de los álbumes seleccionados",
|
"backup_controller_page_total_sub": "Todas las fotos y vídeos únicos de los álbumes seleccionados",
|
||||||
"backup_controller_page_turn_off": "Apagar la copia de seguridad",
|
"backup_controller_page_turn_off": "Apagar la copia de seguridad",
|
||||||
"backup_controller_page_turn_on": "Activar la copia de seguridad",
|
"backup_controller_page_turn_on": "Activar la copia de seguridad",
|
||||||
"backup_controller_page_uploading_file_info": "",
|
|
||||||
"backup_err_only_album": "No se puede eliminar el único álbum",
|
"backup_err_only_album": "No se puede eliminar el único álbum",
|
||||||
"backup_info_card_assets": "activos",
|
"backup_info_card_assets": "activos",
|
||||||
"control_bottom_app_bar_delete": "Eliminar",
|
"control_bottom_app_bar_delete": "Eliminar",
|
||||||
@@ -67,7 +62,6 @@
|
|||||||
"login_form_err_invalid_email": "Correo electrónico no válido",
|
"login_form_err_invalid_email": "Correo electrónico no válido",
|
||||||
"login_form_err_leading_whitespace": "Espacio en blanco inicial",
|
"login_form_err_leading_whitespace": "Espacio en blanco inicial",
|
||||||
"login_form_err_trailing_whitespace": "Espacio en blanco al final",
|
"login_form_err_trailing_whitespace": "Espacio en blanco al final",
|
||||||
"login_form_failed_login": "",
|
|
||||||
"login_form_label_email": "Correo",
|
"login_form_label_email": "Correo",
|
||||||
"login_form_label_password": "Contraseña",
|
"login_form_label_password": "Contraseña",
|
||||||
"login_form_password_hint": "contraseña",
|
"login_form_password_hint": "contraseña",
|
||||||
@@ -76,14 +70,12 @@
|
|||||||
"profile_drawer_client_server_up_to_date": "El Cliente y el Servidor están actualizados",
|
"profile_drawer_client_server_up_to_date": "El Cliente y el Servidor están actualizados",
|
||||||
"profile_drawer_sign_out": "Cerrar Sesión",
|
"profile_drawer_sign_out": "Cerrar Sesión",
|
||||||
"search_bar_hint": "Busca tus fotos",
|
"search_bar_hint": "Busca tus fotos",
|
||||||
"search_page_no_objects": "",
|
|
||||||
"search_page_no_places": "No hay información de lugares disponibles",
|
"search_page_no_places": "No hay información de lugares disponibles",
|
||||||
"search_page_places": "Lugares",
|
"search_page_places": "Lugares",
|
||||||
"search_page_things": "Cosas",
|
"search_page_things": "Cosas",
|
||||||
"search_result_page_new_search_hint": "Nueva Busqueda",
|
"search_result_page_new_search_hint": "Nueva Busqueda",
|
||||||
"select_additional_user_for_sharing_page_suggestions": "Sugerencias",
|
"select_additional_user_for_sharing_page_suggestions": "Sugerencias",
|
||||||
"select_user_for_sharing_page_err_album": "Fallo al crear el álbum",
|
"select_user_for_sharing_page_err_album": "Fallo al crear el álbum",
|
||||||
"select_user_for_sharing_page_share_suggestions": "",
|
|
||||||
"share_add": "Añadir",
|
"share_add": "Añadir",
|
||||||
"share_add_photos": "Añadir fotos",
|
"share_add_photos": "Añadir fotos",
|
||||||
"share_add_title": "Añadir un título",
|
"share_add_title": "Añadir un título",
|
||||||
|
|||||||
@@ -49,9 +49,6 @@
|
|||||||
"create_shared_album_page_share": "Jaa",
|
"create_shared_album_page_share": "Jaa",
|
||||||
"create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA",
|
"create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA",
|
||||||
"create_shared_album_page_share_select_photos": "Valitse kuvat",
|
"create_shared_album_page_share_select_photos": "Valitse kuvat",
|
||||||
"daily_title_text_date": "",
|
|
||||||
"daily_title_text_date_year": "",
|
|
||||||
"date_format": "",
|
|
||||||
"delete_dialog_alert": "Nämä kohteet poistetaan pysyvästi Immich:stä ja laitteeltasi",
|
"delete_dialog_alert": "Nämä kohteet poistetaan pysyvästi Immich:stä ja laitteeltasi",
|
||||||
"delete_dialog_cancel": "Peruuta",
|
"delete_dialog_cancel": "Peruuta",
|
||||||
"delete_dialog_ok": "Poista",
|
"delete_dialog_ok": "Poista",
|
||||||
@@ -72,7 +69,6 @@
|
|||||||
"login_form_label_password": "Salasana",
|
"login_form_label_password": "Salasana",
|
||||||
"login_form_password_hint": "salasana",
|
"login_form_password_hint": "salasana",
|
||||||
"login_form_save_login": "Pysy kirjautuneena",
|
"login_form_save_login": "Pysy kirjautuneena",
|
||||||
"monthly_title_text_date_format": "",
|
|
||||||
"profile_drawer_client_server_up_to_date": "Asiakassovellus ja palvelin ovat ajan tasalla",
|
"profile_drawer_client_server_up_to_date": "Asiakassovellus ja palvelin ovat ajan tasalla",
|
||||||
"profile_drawer_sign_out": "Kirjaudu ulos",
|
"profile_drawer_sign_out": "Kirjaudu ulos",
|
||||||
"search_bar_hint": "Etsi kuvia",
|
"search_bar_hint": "Etsi kuvia",
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
"album_viewer_appbar_share_leave": "Quitter l'album",
|
"album_viewer_appbar_share_leave": "Quitter l'album",
|
||||||
"album_viewer_appbar_share_remove": "Retirer de l'album",
|
"album_viewer_appbar_share_remove": "Retirer de l'album",
|
||||||
"album_viewer_page_share_add_users": "Ajouter des utilisateurs",
|
"album_viewer_page_share_add_users": "Ajouter des utilisateurs",
|
||||||
"asset_list_settings_subtitle": "",
|
|
||||||
"asset_list_settings_title": "",
|
|
||||||
"backup_album_selection_page_albums_device": "Albums sur l'appareil ({})",
|
"backup_album_selection_page_albums_device": "Albums sur l'appareil ({})",
|
||||||
"backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure",
|
"backup_album_selection_page_albums_tap": "Tapez pour inclure, tapez deux fois pour exclure",
|
||||||
"backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.",
|
"backup_album_selection_page_assets_scatter": "Les éléments peuvent être répartis sur plusieurs albums. De ce fait, les albums peuvent être inclus ou exclus pendant le processus de sauvegarde.",
|
||||||
@@ -21,26 +19,7 @@
|
|||||||
"backup_album_selection_page_selection_info": "Informations sur la sélection",
|
"backup_album_selection_page_selection_info": "Informations sur la sélection",
|
||||||
"backup_album_selection_page_total_assets": "Total des éléments uniques",
|
"backup_album_selection_page_total_assets": "Total des éléments uniques",
|
||||||
"backup_all": "Tout",
|
"backup_all": "Tout",
|
||||||
"backup_background_service_backup_failed_message": "",
|
|
||||||
"backup_background_service_connection_failed_message": "",
|
|
||||||
"backup_background_service_current_upload_notification": "",
|
|
||||||
"backup_background_service_default_notification": "",
|
|
||||||
"backup_background_service_error_title": "",
|
|
||||||
"backup_background_service_in_progress_notification": "",
|
|
||||||
"backup_background_service_upload_failure_notification": "",
|
|
||||||
"backup_controller_page_albums": "Sauvegarder les albums",
|
"backup_controller_page_albums": "Sauvegarder les albums",
|
||||||
"backup_controller_page_background_battery_info_link": "",
|
|
||||||
"backup_controller_page_background_battery_info_message": "",
|
|
||||||
"backup_controller_page_background_battery_info_ok": "",
|
|
||||||
"backup_controller_page_background_battery_info_title": "",
|
|
||||||
"backup_controller_page_background_charging": "",
|
|
||||||
"backup_controller_page_background_configure_error": "",
|
|
||||||
"backup_controller_page_background_description": "",
|
|
||||||
"backup_controller_page_background_is_off": "",
|
|
||||||
"backup_controller_page_background_is_on": "",
|
|
||||||
"backup_controller_page_background_turn_off": "",
|
|
||||||
"backup_controller_page_background_turn_on": "",
|
|
||||||
"backup_controller_page_background_wifi": "",
|
|
||||||
"backup_controller_page_backup": "Sauvegardé",
|
"backup_controller_page_backup": "Sauvegardé",
|
||||||
"backup_controller_page_backup_selected": "Sélectionné : ",
|
"backup_controller_page_backup_selected": "Sélectionné : ",
|
||||||
"backup_controller_page_backup_sub": "Photos et vidéos sauvegardées",
|
"backup_controller_page_backup_sub": "Photos et vidéos sauvegardées",
|
||||||
@@ -69,19 +48,6 @@
|
|||||||
"backup_controller_page_uploading_file_info": "Envoi d'informations sur le fichier",
|
"backup_controller_page_uploading_file_info": "Envoi d'informations sur le fichier",
|
||||||
"backup_err_only_album": "Impossible de retirer le seul album",
|
"backup_err_only_album": "Impossible de retirer le seul album",
|
||||||
"backup_info_card_assets": "éléments",
|
"backup_info_card_assets": "éléments",
|
||||||
"cache_settings_album_thumbnails": "",
|
|
||||||
"cache_settings_clear_cache_button": "",
|
|
||||||
"cache_settings_clear_cache_button_title": "",
|
|
||||||
"cache_settings_image_cache_size": "",
|
|
||||||
"cache_settings_statistics_album": "",
|
|
||||||
"cache_settings_statistics_assets": "",
|
|
||||||
"cache_settings_statistics_full": "",
|
|
||||||
"cache_settings_statistics_shared": "",
|
|
||||||
"cache_settings_statistics_thumbnail": "",
|
|
||||||
"cache_settings_statistics_title": "",
|
|
||||||
"cache_settings_subtitle": "",
|
|
||||||
"cache_settings_thumbnail_size": "",
|
|
||||||
"cache_settings_title": "",
|
|
||||||
"control_bottom_app_bar_delete": "Supprimer",
|
"control_bottom_app_bar_delete": "Supprimer",
|
||||||
"control_bottom_app_bar_share": "Partager",
|
"control_bottom_app_bar_share": "Partager",
|
||||||
"create_album_page_untitled": "Sans titre",
|
"create_album_page_untitled": "Sans titre",
|
||||||
@@ -127,14 +93,6 @@
|
|||||||
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
|
"select_additional_user_for_sharing_page_suggestions": "Suggestions",
|
||||||
"select_user_for_sharing_page_err_album": "Échec de la création de l'album",
|
"select_user_for_sharing_page_err_album": "Échec de la création de l'album",
|
||||||
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
"select_user_for_sharing_page_share_suggestions": "Suggestions",
|
||||||
"setting_notifications_notify_failures_grace_period": "",
|
|
||||||
"setting_notifications_notify_hours": "",
|
|
||||||
"setting_notifications_notify_immediately": "",
|
|
||||||
"setting_notifications_notify_minutes": "",
|
|
||||||
"setting_notifications_notify_never": "",
|
|
||||||
"setting_notifications_subtitle": "",
|
|
||||||
"setting_notifications_title": "",
|
|
||||||
"setting_pages_app_bar_settings": "",
|
|
||||||
"share_add": "Ajouter",
|
"share_add": "Ajouter",
|
||||||
"share_add_photos": "Ajouter des photos",
|
"share_add_photos": "Ajouter des photos",
|
||||||
"share_add_title": "Ajouter un titre",
|
"share_add_title": "Ajouter un titre",
|
||||||
@@ -150,16 +108,6 @@
|
|||||||
"tab_controller_nav_photos": "Photos",
|
"tab_controller_nav_photos": "Photos",
|
||||||
"tab_controller_nav_search": "Recherche",
|
"tab_controller_nav_search": "Recherche",
|
||||||
"tab_controller_nav_sharing": "Partage",
|
"tab_controller_nav_sharing": "Partage",
|
||||||
"theme_setting_asset_list_storage_indicator_title": "",
|
|
||||||
"theme_setting_asset_list_tiles_per_row_title": "",
|
|
||||||
"theme_setting_dark_mode_switch": "",
|
|
||||||
"theme_setting_image_viewer_quality_subtitle": "",
|
|
||||||
"theme_setting_image_viewer_quality_title": "",
|
|
||||||
"theme_setting_system_theme_switch": "",
|
|
||||||
"theme_setting_theme_subtitle": "",
|
|
||||||
"theme_setting_theme_title": "",
|
|
||||||
"theme_setting_three_stage_loading_subtitle": "",
|
|
||||||
"theme_setting_three_stage_loading_title": "",
|
|
||||||
"version_announcement_overlay_ack": "Confirmer",
|
"version_announcement_overlay_ack": "Confirmer",
|
||||||
"version_announcement_overlay_release_notes": "notes de mise à jour",
|
"version_announcement_overlay_release_notes": "notes de mise à jour",
|
||||||
"version_announcement_overlay_text_1": "Bonjour, une nouvelle version de",
|
"version_announcement_overlay_text_1": "Bonjour, une nouvelle version de",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.27.0"
|
version_number: "1.28.2"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
|||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
import 'dart:ui' show IsolateNameServer, PluginUtilities;
|
||||||
import 'package:cancellation_token_http/http.dart';
|
import 'package:cancellation_token_http/http.dart';
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -33,7 +32,6 @@ class BackgroundService {
|
|||||||
MethodChannel('immich/foregroundChannel');
|
MethodChannel('immich/foregroundChannel');
|
||||||
static const MethodChannel _backgroundChannel =
|
static const MethodChannel _backgroundChannel =
|
||||||
MethodChannel('immich/backgroundChannel');
|
MethodChannel('immich/backgroundChannel');
|
||||||
bool _isForegroundInitialized = false;
|
|
||||||
bool _isBackgroundInitialized = false;
|
bool _isBackgroundInitialized = false;
|
||||||
CancellationToken? _cancellationToken;
|
CancellationToken? _cancellationToken;
|
||||||
bool _canceledBySystem = false;
|
bool _canceledBySystem = false;
|
||||||
@@ -43,32 +41,34 @@ class BackgroundService {
|
|||||||
ReceivePort? _rp;
|
ReceivePort? _rp;
|
||||||
bool _errorGracePeriodExceeded = true;
|
bool _errorGracePeriodExceeded = true;
|
||||||
|
|
||||||
bool get isForegroundInitialized {
|
|
||||||
return _isForegroundInitialized;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isBackgroundInitialized {
|
bool get isBackgroundInitialized {
|
||||||
return _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
|
/// Ensures that the background service is enqueued if enabled in settings
|
||||||
Future<bool> resumeServiceIfEnabled() async {
|
Future<bool> resumeServiceIfEnabled() async {
|
||||||
return await isBackgroundBackupEnabled() &&
|
return await isBackgroundBackupEnabled() && await enableService();
|
||||||
await startService(keepExisting: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues the background service
|
/// Enqueues the background service
|
||||||
Future<bool> startService({
|
Future<bool> enableService({bool immediate = false}) async {
|
||||||
bool immediate = false,
|
if (!Platform.isAndroid) {
|
||||||
bool keepExisting = false,
|
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 requireUnmetered = true,
|
||||||
bool requireCharging = false,
|
bool requireCharging = false,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -76,14 +76,9 @@ class BackgroundService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
|
||||||
await _initialize();
|
|
||||||
}
|
|
||||||
final String title =
|
|
||||||
"backup_background_service_default_notification".tr();
|
|
||||||
final bool ok = await _foregroundChannel.invokeMethod(
|
final bool ok = await _foregroundChannel.invokeMethod(
|
||||||
'start',
|
'configure',
|
||||||
[immediate, keepExisting, requireUnmetered, requireCharging, title],
|
[requireUnmetered, requireCharging],
|
||||||
);
|
);
|
||||||
return ok;
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -92,15 +87,12 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cancels the background service (if currently running) and removes it from work queue
|
/// Cancels the background service (if currently running) and removes it from work queue
|
||||||
Future<bool> stopService() async {
|
Future<bool> disableService() async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
final ok = await _foregroundChannel.invokeMethod('disable');
|
||||||
await _initialize();
|
|
||||||
}
|
|
||||||
final ok = await _foregroundChannel.invokeMethod('stop');
|
|
||||||
return ok;
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
@@ -113,9 +105,6 @@ class BackgroundService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
|
||||||
await _initialize();
|
|
||||||
}
|
|
||||||
return await _foregroundChannel.invokeMethod("isEnabled");
|
return await _foregroundChannel.invokeMethod("isEnabled");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
@@ -128,9 +117,6 @@ class BackgroundService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!_isForegroundInitialized) {
|
|
||||||
await _initialize();
|
|
||||||
}
|
|
||||||
return await _foregroundChannel
|
return await _foregroundChannel
|
||||||
.invokeMethod('isIgnoringBatteryOptimizations');
|
.invokeMethod('isIgnoringBatteryOptimizations');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -187,7 +173,8 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"[_clearErrorNotifications] failed to communicate with plugin");
|
"[_clearErrorNotifications] failed to communicate with plugin",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -289,18 +276,11 @@ class BackgroundService {
|
|||||||
try {
|
try {
|
||||||
final bool hasAccess = await acquireLock();
|
final bool hasAccess = await acquireLock();
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
debugPrint("[_callHandler] could acquire lock, exiting");
|
debugPrint("[_callHandler] could not acquire lock, exiting");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await translationsLoaded;
|
await translationsLoaded;
|
||||||
final bool ok = await _onAssetsChanged();
|
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;
|
return ok;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint(error.toString());
|
debugPrint(error.toString());
|
||||||
@@ -343,6 +323,31 @@ class BackgroundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await PhotoManager.setIgnorePermissionCheck(true);
|
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();
|
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
|
||||||
|
|
||||||
if (_canceledBySystem) {
|
if (_canceledBySystem) {
|
||||||
@@ -382,10 +387,6 @@ class BackgroundService {
|
|||||||
);
|
);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
_clearErrorNotifications();
|
_clearErrorNotifications();
|
||||||
await box.put(
|
|
||||||
backupInfoKey,
|
|
||||||
backupAlbumInfo,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
_showErrorNotification(
|
_showErrorNotification(
|
||||||
title: "backup_background_service_error_title".tr(),
|
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
|
/// entry point called by Kotlin/Java code; needs to be a top-level function
|
||||||
|
@pragma('vm:entry-point')
|
||||||
void _nativeEntry() {
|
void _nativeEntry() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
BackgroundService backgroundService = BackgroundService();
|
BackgroundService backgroundService = BackgroundService();
|
||||||
|
|||||||
@@ -131,13 +131,15 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (state.backgroundBackup) {
|
if (state.backgroundBackup) {
|
||||||
|
bool success = true;
|
||||||
if (!wasEnabled) {
|
if (!wasEnabled) {
|
||||||
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
|
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
|
||||||
onBatteryInfo();
|
onBatteryInfo();
|
||||||
}
|
}
|
||||||
|
success &= await _backgroundService.enableService(immediate: true);
|
||||||
}
|
}
|
||||||
final bool success = await _backgroundService.stopService() &&
|
success &= success &&
|
||||||
await _backgroundService.startService(
|
await _backgroundService.configureService(
|
||||||
requireUnmetered: state.backupRequireWifi,
|
requireUnmetered: state.backupRequireWifi,
|
||||||
requireCharging: state.backupRequireCharging,
|
requireCharging: state.backupRequireCharging,
|
||||||
);
|
);
|
||||||
@@ -155,7 +157,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||||||
onError("backup_controller_page_background_configure_error");
|
onError("backup_controller_page_background_configure_error");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final bool success = await _backgroundService.stopService();
|
final bool success = await _backgroundService.disableService();
|
||||||
if (!success) {
|
if (!success) {
|
||||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||||
onError("backup_controller_page_background_configure_error");
|
onError("backup_controller_page_background_configure_error");
|
||||||
|
|||||||
@@ -173,19 +173,19 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
OutlinedButton(
|
||||||
onPressed: () => launchUrl(
|
onPressed: () => launchUrl(
|
||||||
Uri.parse('https://dontkillmyapp.com'),
|
Uri.parse('https://dontkillmyapp.com'),
|
||||||
mode: LaunchMode.externalApplication),
|
mode: LaunchMode.externalApplication,
|
||||||
child: Text(
|
),
|
||||||
|
child: const Text(
|
||||||
"backup_controller_page_background_battery_info_link",
|
"backup_controller_page_background_battery_info_link",
|
||||||
style: TextStyle(color: buttonTextColor),
|
|
||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
TextButton(
|
ElevatedButton(
|
||||||
child: Text(
|
child: const Text(
|
||||||
'backup_controller_page_background_battery_info_ok',
|
'backup_controller_page_background_battery_info_ok',
|
||||||
style: TextStyle(color: buttonTextColor),
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
).tr(),
|
).tr(),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
@@ -636,8 +636,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
|||||||
backupState.backupProgress == BackUpProgressEnum.inProgress
|
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
? ElevatedButton(
|
? ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
primary: Colors.red[300],
|
foregroundColor: Colors.grey[50],
|
||||||
onPrimary: Colors.grey[50],
|
backgroundColor: Colors.red[300],
|
||||||
// padding: const EdgeInsets.all(14),
|
// padding: const EdgeInsets.all(14),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final BackUpState backupState = ref.watch(backupProvider);
|
final BackUpState backupState = ref.watch(backupProvider);
|
||||||
bool isEnableAutoBackup =
|
bool isEnableAutoBackup = backupState.backgroundBackup ||
|
||||||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||||
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ doc/AddAssetsDto.md
|
|||||||
doc/AddUsersDto.md
|
doc/AddUsersDto.md
|
||||||
doc/AdminSignupResponseDto.md
|
doc/AdminSignupResponseDto.md
|
||||||
doc/AlbumApi.md
|
doc/AlbumApi.md
|
||||||
|
doc/AlbumCountResponseDto.md
|
||||||
doc/AlbumResponseDto.md
|
doc/AlbumResponseDto.md
|
||||||
doc/AssetApi.md
|
doc/AssetApi.md
|
||||||
doc/AssetCountByTimeBucket.md
|
doc/AssetCountByTimeBucket.md
|
||||||
doc/AssetCountByTimeBucketResponseDto.md
|
doc/AssetCountByTimeBucketResponseDto.md
|
||||||
|
doc/AssetCountByUserIdResponseDto.md
|
||||||
doc/AssetFileUploadResponseDto.md
|
doc/AssetFileUploadResponseDto.md
|
||||||
doc/AssetResponseDto.md
|
doc/AssetResponseDto.md
|
||||||
doc/AssetTypeEnum.md
|
doc/AssetTypeEnum.md
|
||||||
@@ -70,9 +72,11 @@ lib/auth/oauth.dart
|
|||||||
lib/model/add_assets_dto.dart
|
lib/model/add_assets_dto.dart
|
||||||
lib/model/add_users_dto.dart
|
lib/model/add_users_dto.dart
|
||||||
lib/model/admin_signup_response_dto.dart
|
lib/model/admin_signup_response_dto.dart
|
||||||
|
lib/model/album_count_response_dto.dart
|
||||||
lib/model/album_response_dto.dart
|
lib/model/album_response_dto.dart
|
||||||
lib/model/asset_count_by_time_bucket.dart
|
lib/model/asset_count_by_time_bucket.dart
|
||||||
lib/model/asset_count_by_time_bucket_response_dto.dart
|
lib/model/asset_count_by_time_bucket_response_dto.dart
|
||||||
|
lib/model/asset_count_by_user_id_response_dto.dart
|
||||||
lib/model/asset_file_upload_response_dto.dart
|
lib/model/asset_file_upload_response_dto.dart
|
||||||
lib/model/asset_response_dto.dart
|
lib/model/asset_response_dto.dart
|
||||||
lib/model/asset_type_enum.dart
|
lib/model/asset_type_enum.dart
|
||||||
@@ -111,3 +115,4 @@ lib/model/user_count_response_dto.dart
|
|||||||
lib/model/user_response_dto.dart
|
lib/model/user_response_dto.dart
|
||||||
lib/model/validate_access_token_response_dto.dart
|
lib/model/validate_access_token_response_dto.dart
|
||||||
pubspec.yaml
|
pubspec.yaml
|
||||||
|
test/asset_count_by_user_id_response_dto_test.dart
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ Class | Method | HTTP request | Description
|
|||||||
*AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
|
*AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
|
||||||
*AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album |
|
*AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album |
|
||||||
*AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
|
*AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
|
||||||
|
*AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
|
||||||
*AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
|
*AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
|
||||||
*AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album |
|
*AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album |
|
||||||
*AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
|
*AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
|
||||||
@@ -81,6 +82,7 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
|
*AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
|
||||||
*AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
*AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
||||||
*AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
*AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
||||||
|
*AssetApi* | [**getAssetCountByUserId**](doc//AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
|
||||||
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||||
*AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
|
*AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
|
||||||
*AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
*AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
||||||
@@ -113,9 +115,11 @@ Class | Method | HTTP request | Description
|
|||||||
- [AddAssetsDto](doc//AddAssetsDto.md)
|
- [AddAssetsDto](doc//AddAssetsDto.md)
|
||||||
- [AddUsersDto](doc//AddUsersDto.md)
|
- [AddUsersDto](doc//AddUsersDto.md)
|
||||||
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
|
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
|
||||||
|
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
|
||||||
- [AlbumResponseDto](doc//AlbumResponseDto.md)
|
- [AlbumResponseDto](doc//AlbumResponseDto.md)
|
||||||
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
|
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
|
||||||
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
|
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
|
||||||
|
- [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
|
||||||
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
||||||
- [AssetResponseDto](doc//AssetResponseDto.md)
|
- [AssetResponseDto](doc//AssetResponseDto.md)
|
||||||
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
- [AssetTypeEnum](doc//AssetTypeEnum.md)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Method | HTTP request | Description
|
|||||||
[**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
|
[**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users |
|
||||||
[**createAlbum**](AlbumApi.md#createalbum) | **POST** /album |
|
[**createAlbum**](AlbumApi.md#createalbum) | **POST** /album |
|
||||||
[**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
|
[**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} |
|
||||||
|
[**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id |
|
||||||
[**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
|
[**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{albumId} |
|
||||||
[**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album |
|
[**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album |
|
||||||
[**removeAssetFromAlbum**](AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
|
[**removeAssetFromAlbum**](AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{albumId}/assets |
|
||||||
@@ -211,6 +212,49 @@ void (empty response body)
|
|||||||
|
|
||||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **getAlbumCountByUserId**
|
||||||
|
> AlbumCountResponseDto getAlbumCountByUserId()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = AlbumApi();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getAlbumCountByUserId();
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling AlbumApi->getAlbumCountByUserId: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
This endpoint does not need any parameter.
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**AlbumCountResponseDto**](AlbumCountResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
# **getAlbumInfo**
|
# **getAlbumInfo**
|
||||||
> AlbumResponseDto getAlbumInfo(albumId)
|
> AlbumResponseDto getAlbumInfo(albumId)
|
||||||
|
|
||||||
|
|||||||
17
mobile/openapi/doc/AlbumCountResponseDto.md
Normal file
17
mobile/openapi/doc/AlbumCountResponseDto.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# openapi.model.AlbumCountResponseDto
|
||||||
|
|
||||||
|
## Load the model package
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**owned** | **int** | |
|
||||||
|
**shared** | **int** | |
|
||||||
|
**sharing** | **int** | |
|
||||||
|
|
||||||
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ Method | HTTP request | Description
|
|||||||
[**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
|
[**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} |
|
||||||
[**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
[**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
|
||||||
[**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
[**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
|
||||||
|
[**getAssetCountByUserId**](AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
|
||||||
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||||
[**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
|
[**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{assetId} |
|
||||||
[**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
[**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
|
||||||
@@ -363,6 +364,49 @@ Name | Type | Description | Notes
|
|||||||
|
|
||||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **getAssetCountByUserId**
|
||||||
|
> AssetCountByUserIdResponseDto getAssetCountByUserId()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = AssetApi();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getAssetCountByUserId();
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling AssetApi->getAssetCountByUserId: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
This endpoint does not need any parameter.
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
# **getAssetSearchTerms**
|
# **getAssetSearchTerms**
|
||||||
> List<String> getAssetSearchTerms()
|
> List<String> getAssetSearchTerms()
|
||||||
|
|
||||||
|
|||||||
16
mobile/openapi/doc/AssetCountByUserIdResponseDto.md
Normal file
16
mobile/openapi/doc/AssetCountByUserIdResponseDto.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# openapi.model.AssetCountByUserIdResponseDto
|
||||||
|
|
||||||
|
## Load the model package
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Properties
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**photos** | **int** | |
|
||||||
|
**videos** | **int** | |
|
||||||
|
|
||||||
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,9 +37,11 @@ part 'api/user_api.dart';
|
|||||||
part 'model/add_assets_dto.dart';
|
part 'model/add_assets_dto.dart';
|
||||||
part 'model/add_users_dto.dart';
|
part 'model/add_users_dto.dart';
|
||||||
part 'model/admin_signup_response_dto.dart';
|
part 'model/admin_signup_response_dto.dart';
|
||||||
|
part 'model/album_count_response_dto.dart';
|
||||||
part 'model/album_response_dto.dart';
|
part 'model/album_response_dto.dart';
|
||||||
part 'model/asset_count_by_time_bucket.dart';
|
part 'model/asset_count_by_time_bucket.dart';
|
||||||
part 'model/asset_count_by_time_bucket_response_dto.dart';
|
part 'model/asset_count_by_time_bucket_response_dto.dart';
|
||||||
|
part 'model/asset_count_by_user_id_response_dto.dart';
|
||||||
part 'model/asset_file_upload_response_dto.dart';
|
part 'model/asset_file_upload_response_dto.dart';
|
||||||
part 'model/asset_response_dto.dart';
|
part 'model/asset_response_dto.dart';
|
||||||
part 'model/asset_type_enum.dart';
|
part 'model/asset_type_enum.dart';
|
||||||
|
|||||||
@@ -207,6 +207,47 @@ class AlbumApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /album/count-by-user-id' operation and returns the [Response].
|
||||||
|
Future<Response> getAlbumCountByUserIdWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/album/count-by-user-id';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AlbumCountResponseDto?> getAlbumCountByUserId() async {
|
||||||
|
final response = await getAlbumCountByUserIdWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumCountResponseDto',) as AlbumCountResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /album/{albumId}' operation and returns the [Response].
|
/// Performs an HTTP 'GET /album/{albumId}' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -395,6 +395,47 @@ class AssetApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /asset/count-by-user-id' operation and returns the [Response].
|
||||||
|
Future<Response> getAssetCountByUserIdWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/asset/count-by-user-id';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AssetCountByUserIdResponseDto?> getAssetCountByUserId() async {
|
||||||
|
final response = await getAssetCountByUserIdWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /asset/search-terms' operation and returns the [Response].
|
/// Performs an HTTP 'GET /asset/search-terms' operation and returns the [Response].
|
||||||
Future<Response> getAssetSearchTermsWithHttpInfo() async {
|
Future<Response> getAssetSearchTermsWithHttpInfo() async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
|
|||||||
@@ -198,12 +198,16 @@ class ApiClient {
|
|||||||
return AddUsersDto.fromJson(value);
|
return AddUsersDto.fromJson(value);
|
||||||
case 'AdminSignupResponseDto':
|
case 'AdminSignupResponseDto':
|
||||||
return AdminSignupResponseDto.fromJson(value);
|
return AdminSignupResponseDto.fromJson(value);
|
||||||
|
case 'AlbumCountResponseDto':
|
||||||
|
return AlbumCountResponseDto.fromJson(value);
|
||||||
case 'AlbumResponseDto':
|
case 'AlbumResponseDto':
|
||||||
return AlbumResponseDto.fromJson(value);
|
return AlbumResponseDto.fromJson(value);
|
||||||
case 'AssetCountByTimeBucket':
|
case 'AssetCountByTimeBucket':
|
||||||
return AssetCountByTimeBucket.fromJson(value);
|
return AssetCountByTimeBucket.fromJson(value);
|
||||||
case 'AssetCountByTimeBucketResponseDto':
|
case 'AssetCountByTimeBucketResponseDto':
|
||||||
return AssetCountByTimeBucketResponseDto.fromJson(value);
|
return AssetCountByTimeBucketResponseDto.fromJson(value);
|
||||||
|
case 'AssetCountByUserIdResponseDto':
|
||||||
|
return AssetCountByUserIdResponseDto.fromJson(value);
|
||||||
case 'AssetFileUploadResponseDto':
|
case 'AssetFileUploadResponseDto':
|
||||||
return AssetFileUploadResponseDto.fromJson(value);
|
return AssetFileUploadResponseDto.fromJson(value);
|
||||||
case 'AssetResponseDto':
|
case 'AssetResponseDto':
|
||||||
|
|||||||
127
mobile/openapi/lib/model/album_count_response_dto.dart
Normal file
127
mobile/openapi/lib/model/album_count_response_dto.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AlbumCountResponseDto {
|
||||||
|
/// Returns a new [AlbumCountResponseDto] instance.
|
||||||
|
AlbumCountResponseDto({
|
||||||
|
required this.owned,
|
||||||
|
required this.shared,
|
||||||
|
required this.sharing,
|
||||||
|
});
|
||||||
|
|
||||||
|
int owned;
|
||||||
|
|
||||||
|
int shared;
|
||||||
|
|
||||||
|
int sharing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto &&
|
||||||
|
other.owned == owned &&
|
||||||
|
other.shared == shared &&
|
||||||
|
other.sharing == sharing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(owned.hashCode) +
|
||||||
|
(shared.hashCode) +
|
||||||
|
(sharing.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AlbumCountResponseDto[owned=$owned, shared=$shared, sharing=$sharing]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final _json = <String, dynamic>{};
|
||||||
|
_json[r'owned'] = owned;
|
||||||
|
_json[r'shared'] = shared;
|
||||||
|
_json[r'sharing'] = sharing;
|
||||||
|
return _json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AlbumCountResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AlbumCountResponseDto? fromJson(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
// Ensure that the map contains the required keys.
|
||||||
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
|
// Note 2: this code is stripped in release mode!
|
||||||
|
assert(() {
|
||||||
|
requiredKeys.forEach((key) {
|
||||||
|
assert(json.containsKey(key), 'Required key "AlbumCountResponseDto[$key]" is missing from JSON.');
|
||||||
|
assert(json[key] != null, 'Required key "AlbumCountResponseDto[$key]" has a null value in JSON.');
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
|
||||||
|
return AlbumCountResponseDto(
|
||||||
|
owned: mapValueOfType<int>(json, r'owned')!,
|
||||||
|
shared: mapValueOfType<int>(json, r'shared')!,
|
||||||
|
sharing: mapValueOfType<int>(json, r'sharing')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AlbumCountResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AlbumCountResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AlbumCountResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AlbumCountResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AlbumCountResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumCountResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AlbumCountResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AlbumCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AlbumCountResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AlbumCountResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'owned',
|
||||||
|
'shared',
|
||||||
|
'sharing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AssetCountByUserIdResponseDto {
|
||||||
|
/// Returns a new [AssetCountByUserIdResponseDto] instance.
|
||||||
|
AssetCountByUserIdResponseDto({
|
||||||
|
required this.photos,
|
||||||
|
required this.videos,
|
||||||
|
});
|
||||||
|
|
||||||
|
int photos;
|
||||||
|
|
||||||
|
int videos;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AssetCountByUserIdResponseDto &&
|
||||||
|
other.photos == photos &&
|
||||||
|
other.videos == videos;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(photos.hashCode) +
|
||||||
|
(videos.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AssetCountByUserIdResponseDto[photos=$photos, videos=$videos]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final _json = <String, dynamic>{};
|
||||||
|
_json[r'photos'] = photos;
|
||||||
|
_json[r'videos'] = videos;
|
||||||
|
return _json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AssetCountByUserIdResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AssetCountByUserIdResponseDto? fromJson(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
// Ensure that the map contains the required keys.
|
||||||
|
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||||
|
// Note 2: this code is stripped in release mode!
|
||||||
|
assert(() {
|
||||||
|
requiredKeys.forEach((key) {
|
||||||
|
assert(json.containsKey(key), 'Required key "AssetCountByUserIdResponseDto[$key]" is missing from JSON.');
|
||||||
|
assert(json[key] != null, 'Required key "AssetCountByUserIdResponseDto[$key]" has a null value in JSON.');
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
|
||||||
|
return AssetCountByUserIdResponseDto(
|
||||||
|
photos: mapValueOfType<int>(json, r'photos')!,
|
||||||
|
videos: mapValueOfType<int>(json, r'videos')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AssetCountByUserIdResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AssetCountByUserIdResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AssetCountByUserIdResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AssetCountByUserIdResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AssetCountByUserIdResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AssetCountByUserIdResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AssetCountByUserIdResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AssetCountByUserIdResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AssetCountByUserIdResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AssetCountByUserIdResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'photos',
|
||||||
|
'videos',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
37
mobile/openapi/test/album_count_response_dto_test.dart
Normal file
37
mobile/openapi/test/album_count_response_dto_test.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// tests for AlbumCountResponseDto
|
||||||
|
void main() {
|
||||||
|
// final instance = AlbumCountResponseDto();
|
||||||
|
|
||||||
|
group('test AlbumCountResponseDto', () {
|
||||||
|
// int owned
|
||||||
|
test('to test the property `owned`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// int shared
|
||||||
|
test('to test the property `shared`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// int sharing
|
||||||
|
test('to test the property `sharing`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.12
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
// tests for AssetCountByUserIdResponseDto
|
||||||
|
void main() {
|
||||||
|
// final instance = AssetCountByUserIdResponseDto();
|
||||||
|
|
||||||
|
group('test AssetCountByUserIdResponseDto', () {
|
||||||
|
// int photos
|
||||||
|
test('to test the property `photos`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// int videos
|
||||||
|
test('to test the property `videos`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.27.0+37
|
version: 1.28.2+40
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CreateAlbumDto } from './dto/create-album.dto';
|
|||||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||||
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
import { RemoveAssetsDto } from './dto/remove-assets.dto';
|
||||||
import { UpdateAlbumDto } from './dto/update-album.dto';
|
import { UpdateAlbumDto } from './dto/update-album.dto';
|
||||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||||
|
|
||||||
export interface IAlbumRepository {
|
export interface IAlbumRepository {
|
||||||
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity>;
|
||||||
@@ -23,6 +23,7 @@ export interface IAlbumRepository {
|
|||||||
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
|
addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AlbumEntity>;
|
||||||
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
|
||||||
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
|
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
|
||||||
|
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
|
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
|
||||||
@@ -42,6 +43,18 @@ export class AlbumRepository implements IAlbumRepository {
|
|||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getCountByUserId(userId: string): Promise<AlbumCountResponseDto> {
|
||||||
|
const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] });
|
||||||
|
|
||||||
|
const sharedAlbums = await this.userAlbumRepository.count({
|
||||||
|
where: { sharedUserId: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sharingAlbums = ownedAlbums.map((album) => album.sharedUsers?.length || 0).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
return new AlbumCountResponseDto(ownedAlbums.length, sharedAlbums, sharingAlbums);
|
||||||
|
}
|
||||||
|
|
||||||
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
|
async create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise<AlbumEntity> {
|
||||||
return this.dataSource.transaction(async (transactionalEntityManager) => {
|
return this.dataSource.transaction(async (transactionalEntityManager) => {
|
||||||
// Create album entity
|
// Create album entity
|
||||||
@@ -151,32 +164,32 @@ export class AlbumRepository implements IAlbumRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
|
async getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]> {
|
||||||
let query = this.albumRepository.createQueryBuilder('album');
|
const query = this.albumRepository.createQueryBuilder('album');
|
||||||
|
|
||||||
const albums = await query
|
const albums = await query
|
||||||
.where('album.ownerId = :ownerId', { ownerId: userId })
|
.where('album.ownerId = :ownerId', { ownerId: userId })
|
||||||
.andWhere((qb) => {
|
.andWhere((qb) => {
|
||||||
// shared with userId
|
// shared with userId
|
||||||
const subQuery = qb
|
const subQuery = qb
|
||||||
.subQuery()
|
.subQuery()
|
||||||
.select('assetAlbum.albumId')
|
.select('assetAlbum.albumId')
|
||||||
.from(AssetAlbumEntity, 'assetAlbum')
|
.from(AssetAlbumEntity, 'assetAlbum')
|
||||||
.where('assetAlbum.assetId = :assetId', {assetId: assetId})
|
.where('assetAlbum.assetId = :assetId', { assetId: assetId })
|
||||||
.getQuery();
|
.getQuery();
|
||||||
return `album.id IN ${subQuery}`;
|
return `album.id IN ${subQuery}`;
|
||||||
})
|
})
|
||||||
.leftJoinAndSelect('album.assets', 'assets')
|
.leftJoinAndSelect('album.assets', 'assets')
|
||||||
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
|
.leftJoinAndSelect('assets.assetInfo', 'assetInfo')
|
||||||
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
|
.leftJoinAndSelect('album.sharedUsers', 'sharedUser')
|
||||||
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
.leftJoinAndSelect('sharedUser.userInfo', 'userInfo')
|
||||||
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
|
.orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC')
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
return albums;
|
return albums;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(albumId: string): Promise<AlbumEntity | undefined> {
|
async get(albumId: string): Promise<AlbumEntity | undefined> {
|
||||||
let query = this.albumRepository.createQueryBuilder('album');
|
const query = this.albumRepository.createQueryBuilder('album');
|
||||||
|
|
||||||
const album = await query
|
const album = await query
|
||||||
.where('album.id = :albumId', { albumId })
|
.where('album.id = :albumId', { albumId })
|
||||||
@@ -228,9 +241,10 @@ export class AlbumRepository implements IAlbumRepository {
|
|||||||
|
|
||||||
// TODO: No need to return boolean if using a singe delete query
|
// TODO: No need to return boolean if using a singe delete query
|
||||||
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
|
if (deleteAssetCount == removeAssetsDto.assetIds.length) {
|
||||||
const retAlbum = await this.get(album.id) as AlbumEntity;
|
const retAlbum = (await this.get(album.id)) as AlbumEntity;
|
||||||
|
|
||||||
if (retAlbum?.assets?.length === 0) { // is empty album
|
if (retAlbum?.assets?.length === 0) {
|
||||||
|
// is empty album
|
||||||
await this.albumRepository.update(album.id, { albumThumbnailAssetId: null });
|
await this.albumRepository.update(album.id, { albumThumbnailAssetId: null });
|
||||||
retAlbum.albumThumbnailAssetId = null;
|
retAlbum.albumThumbnailAssetId = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
|
|||||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||||
|
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||||
|
|
||||||
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
|
// TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe.
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -33,6 +34,11 @@ import { AlbumResponseDto } from './response-dto/album-response.dto';
|
|||||||
export class AlbumController {
|
export class AlbumController {
|
||||||
constructor(private readonly albumService: AlbumService) {}
|
constructor(private readonly albumService: AlbumService) {}
|
||||||
|
|
||||||
|
@Get('count-by-user-id')
|
||||||
|
async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
|
||||||
|
return this.albumService.getAlbumCountByUserId(authUser);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
|
async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) {
|
||||||
return this.albumService.create(authUser, createAlbumDto);
|
return this.albumService.create(authUser, createAlbumDto);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
|||||||
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||||
import { AlbumEntity } from '@app/database/entities/album.entity';
|
import { AlbumEntity } from '@app/database/entities/album.entity';
|
||||||
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
import { AlbumResponseDto } from './response-dto/album-response.dto';
|
||||||
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
|
|
||||||
|
|
||||||
describe('Album service', () => {
|
describe('Album service', () => {
|
||||||
let sut: AlbumService;
|
let sut: AlbumService;
|
||||||
@@ -116,7 +115,8 @@ describe('Album service', () => {
|
|||||||
removeAssets: jest.fn(),
|
removeAssets: jest.fn(),
|
||||||
removeUser: jest.fn(),
|
removeUser: jest.fn(),
|
||||||
updateAlbum: jest.fn(),
|
updateAlbum: jest.fn(),
|
||||||
getListByAssetId: jest.fn()
|
getListByAssetId: jest.fn(),
|
||||||
|
getCountByUserId: jest.fn(),
|
||||||
};
|
};
|
||||||
sut = new AlbumService(albumRepositoryMock);
|
sut = new AlbumService(albumRepositoryMock);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { UpdateAlbumDto } from './dto/update-album.dto';
|
|||||||
import { GetAlbumsDto } from './dto/get-albums.dto';
|
import { GetAlbumsDto } from './dto/get-albums.dto';
|
||||||
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
|
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
|
||||||
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
import { ALBUM_REPOSITORY, IAlbumRepository } from './album-repository';
|
||||||
|
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AlbumService {
|
export class AlbumService {
|
||||||
@@ -118,4 +119,8 @@ export class AlbumService {
|
|||||||
const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
|
const updatedAlbum = await this._albumRepository.updateAlbum(album, updateAlbumDto);
|
||||||
return mapAlbum(updatedAlbum);
|
return mapAlbum(updatedAlbum);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAlbumCountByUserId(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
|
||||||
|
return this._albumRepository.getCountByUserId(authUser.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
import { IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAlbumDto {
|
export class UpdateAlbumDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class AlbumCountResponseDto {
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
owned!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
shared!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
sharing!: number;
|
||||||
|
|
||||||
|
constructor(owned: number, shared: number, sharing: number) {
|
||||||
|
this.owned = owned;
|
||||||
|
this.shared = shared;
|
||||||
|
this.sharing = sharing;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { CuratedObjectsResponseDto } from './response-dto/curated-objects-respon
|
|||||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||||
|
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||||
|
|
||||||
export interface IAssetRepository {
|
export interface IAssetRepository {
|
||||||
create(
|
create(
|
||||||
@@ -25,6 +26,7 @@ export interface IAssetRepository {
|
|||||||
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
|
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
|
||||||
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
|
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
|
||||||
getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
|
getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
|
||||||
|
getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto>;
|
||||||
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
||||||
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,28 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getAssetCountByUserId(userId: string): Promise<AssetCountByUserIdResponseDto> {
|
||||||
|
// Get asset count by AssetType
|
||||||
|
const res = await this.assetRepository
|
||||||
|
.createQueryBuilder('asset')
|
||||||
|
.select(`COUNT(asset.id)`, 'count')
|
||||||
|
.addSelect(`asset.type`, 'type')
|
||||||
|
.where('"userId" = :userId', { userId: userId })
|
||||||
|
.groupBy('asset.type')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const assetCountByUserId = new AssetCountByUserIdResponseDto(0, 0);
|
||||||
|
res.map((item) => {
|
||||||
|
if (item.type === 'IMAGE') {
|
||||||
|
assetCountByUserId.photos = item.count;
|
||||||
|
} else if (item.type === 'VIDEO') {
|
||||||
|
assetCountByUserId.videos = item.count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return assetCountByUserId;
|
||||||
|
}
|
||||||
|
|
||||||
async getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
|
async getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]> {
|
||||||
// Get asset entity from a list of time buckets
|
// Get asset entity from a list of time buckets
|
||||||
return await this.assetRepository
|
return await this.assetRepository
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
Header,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
import { AssetService } from './asset.service';
|
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 { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
|
||||||
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
|
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
|
||||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
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 { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||||
import { AssetResponseDto } from './response-dto/asset-response.dto';
|
import { AssetResponseDto } from './response-dto/asset-response.dto';
|
||||||
@@ -48,6 +47,7 @@ import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by
|
|||||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||||
import { QueryFailedError } from 'typeorm';
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -78,7 +78,13 @@ export class AssetController {
|
|||||||
const checksum = await this.assetService.calculateChecksum(file.path);
|
const checksum = await this.assetService.calculateChecksum(file.path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype, checksum);
|
const savedAsset = await this.assetService.createUserAsset(
|
||||||
|
authUser,
|
||||||
|
assetInfo,
|
||||||
|
file.path,
|
||||||
|
file.mimetype,
|
||||||
|
checksum,
|
||||||
|
);
|
||||||
|
|
||||||
if (!savedAsset) {
|
if (!savedAsset) {
|
||||||
await this.backgroundTaskService.deleteFileOnDisk([
|
await this.backgroundTaskService.deleteFileOnDisk([
|
||||||
@@ -104,7 +110,7 @@ export class AssetController {
|
|||||||
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
|
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
|
||||||
|
|
||||||
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
|
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
|
||||||
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum)
|
const existedAsset = await this.assetService.getAssetByChecksum(authUser.id, checksum);
|
||||||
return new AssetFileUploadResponseDto(existedAsset.id);
|
return new AssetFileUploadResponseDto(existedAsset.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +178,10 @@ export class AssetController {
|
|||||||
return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto);
|
return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('/count-by-user-id')
|
||||||
|
async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
||||||
|
return this.assetService.getAssetCountByUserId(authUser);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Get all AssetEntity belong to the user
|
* Get all AssetEntity belong to the user
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { AssetRepository, IAssetRepository } from './asset-repository';
|
import { IAssetRepository } from './asset-repository';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||||
import { AssetService } from './asset.service';
|
import { AssetService } from './asset.service';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
|
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
|
||||||
|
|
||||||
describe('AssetService', () => {
|
describe('AssetService', () => {
|
||||||
let sui: AssetService;
|
let sui: AssetService;
|
||||||
@@ -15,39 +14,39 @@ describe('AssetService', () => {
|
|||||||
email: 'auth@test.com',
|
email: 'auth@test.com',
|
||||||
});
|
});
|
||||||
|
|
||||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
// const _getCreateAssetDto = (): CreateAssetDto => {
|
||||||
const createAssetDto = new CreateAssetDto();
|
// const createAssetDto = new CreateAssetDto();
|
||||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
// createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||||
createAssetDto.deviceId = 'deviceId';
|
// createAssetDto.deviceId = 'deviceId';
|
||||||
createAssetDto.assetType = AssetType.OTHER;
|
// createAssetDto.assetType = AssetType.OTHER;
|
||||||
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
// createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||||
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
// createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||||
createAssetDto.isFavorite = false;
|
// createAssetDto.isFavorite = false;
|
||||||
createAssetDto.duration = '0:00:00.000000';
|
// createAssetDto.duration = '0:00:00.000000';
|
||||||
|
|
||||||
return createAssetDto;
|
// return createAssetDto;
|
||||||
};
|
// };
|
||||||
const _getAsset = () => {
|
// const _getAsset = () => {
|
||||||
const assetEntity = new AssetEntity();
|
// const assetEntity = new AssetEntity();
|
||||||
|
|
||||||
assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
|
// assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
|
||||||
assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
|
// assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
|
||||||
assetEntity.deviceAssetId = '4967046344801';
|
// assetEntity.deviceAssetId = '4967046344801';
|
||||||
assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
|
// assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
|
||||||
assetEntity.type = AssetType.VIDEO;
|
// assetEntity.type = AssetType.VIDEO;
|
||||||
assetEntity.originalPath =
|
// assetEntity.originalPath =
|
||||||
'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
|
// 'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
|
||||||
assetEntity.resizePath = '';
|
// assetEntity.resizePath = '';
|
||||||
assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
|
// assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
|
||||||
assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
|
// assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||||
assetEntity.isFavorite = false;
|
// assetEntity.isFavorite = false;
|
||||||
assetEntity.mimeType = 'image/jpeg';
|
// assetEntity.mimeType = 'image/jpeg';
|
||||||
assetEntity.webpPath = '';
|
// assetEntity.webpPath = '';
|
||||||
assetEntity.encodedVideoPath = '';
|
// assetEntity.encodedVideoPath = '';
|
||||||
assetEntity.duration = '0:00:00.000000';
|
// assetEntity.duration = '0:00:00.000000';
|
||||||
|
|
||||||
return assetEntity;
|
// return assetEntity;
|
||||||
};
|
// };
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
assetRepositoryMock = {
|
assetRepositoryMock = {
|
||||||
@@ -61,6 +60,7 @@ describe('AssetService', () => {
|
|||||||
getSearchPropertiesByUserId: jest.fn(),
|
getSearchPropertiesByUserId: jest.fn(),
|
||||||
getAssetByTimeBucket: jest.fn(),
|
getAssetByTimeBucket: jest.fn(),
|
||||||
getAssetByChecksum: jest.fn(),
|
getAssetByChecksum: jest.fn(),
|
||||||
|
getAssetCountByUserId: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sui = new AssetService(assetRepositoryMock, a);
|
sui = new AssetService(assetRepositoryMock, a);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from './response-dto/asset-count-by-time-group-response.dto';
|
} from './response-dto/asset-count-by-time-group-response.dto';
|
||||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||||
|
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||||
|
|
||||||
const fileInfo = promisify(stat);
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
@@ -479,4 +480,8 @@ export class AssetService {
|
|||||||
fileReadStream.pipe(sha1Hash);
|
fileReadStream.pipe(sha1Hash);
|
||||||
return deferred;
|
return deferred;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
|
||||||
|
return this._assetRepository.getAssetCountByUserId(authUser.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { IsOptional } from 'class-validator';
|
||||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export enum GetAssetThumbnailFormatEnum {
|
export enum GetAssetThumbnailFormatEnum {
|
||||||
JPEG = 'JPEG',
|
JPEG = 'JPEG',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
|
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class ServeFileDto {
|
export class ServeFileDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class AssetCountByUserIdResponseDto {
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
photos!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
videos!: number;
|
||||||
|
|
||||||
|
constructor(photos: number, videos: number) {
|
||||||
|
this.photos = photos;
|
||||||
|
this.videos = videos;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
|
import { Body, Controller, Post, Res, UseGuards, ValidationPipe } from '@nestjs/common';
|
||||||
import {
|
import { ApiBadRequestResponse, ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
ApiBadRequestResponse,
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiBody,
|
|
||||||
ApiCreatedResponse,
|
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { UserEntity } from '@app/database/entities/user.entity';
|
import { UserEntity } from '@app/database/entities/user.entity';
|
||||||
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
|
import { ApiResponseProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class LoginResponseDto {
|
export class LoginResponseDto {
|
||||||
@ApiResponseProperty()
|
@ApiResponseProperty()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { DeviceType } from '@app/database/entities/device-info.entity';
|
import { DeviceType } from '@app/database/entities/device-info.entity';
|
||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
import { CreateDeviceInfoDto } from './create-device-info.dto';
|
|
||||||
|
|
||||||
export class UpdateDeviceInfoDto {
|
export class UpdateDeviceInfoDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
|
|
||||||
import { ServerInfoService } from './server-info.service';
|
import { ServerInfoService } from './server-info.service';
|
||||||
import { serverVersion } from '../../constants/server_version.constant';
|
import { serverVersion } from '../../constants/server_version.constant';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
|
import { ServerPingResponse } from './response-dto/server-ping-response.dto';
|
||||||
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
|
import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
|
||||||
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
|
|
||||||
@ApiTags('Server Info')
|
@ApiTags('Server Info')
|
||||||
@Controller('server-info')
|
@Controller('server-info')
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
UploadedFile,
|
UploadedFile,
|
||||||
Response,
|
Response,
|
||||||
Request,
|
Request,
|
||||||
StreamableFile,
|
|
||||||
ParseBoolPipe,
|
ParseBoolPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { AppController } from './app.controller';
|
|||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
|
||||||
import { DatabaseModule } from '@app/database';
|
import { DatabaseModule } from '@app/database';
|
||||||
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
|||||||
|
|
||||||
export const serverVersion: IServerVersion = {
|
export const serverVersion: IServerVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 27,
|
minor: 28,
|
||||||
patch: 0,
|
patch: 2,
|
||||||
build: 0,
|
build: 40,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { dataSource } from '@app/database/config/database.config';
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
@@ -7,6 +6,7 @@ import cookieParser from 'cookie-parser';
|
|||||||
import { writeFileSync } from 'fs';
|
import { writeFileSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { serverVersion } from './constants/server_version.constant';
|
||||||
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
|
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@@ -53,11 +53,17 @@ async function bootstrap() {
|
|||||||
// Generate API Documentation only in development mode
|
// Generate API Documentation only in development mode
|
||||||
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
|
const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
|
||||||
writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' });
|
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') {
|
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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { InjectQueue } from '@nestjs/bull/dist/decorators';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Queue } from 'bull';
|
import { Queue } from 'bull';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
|
||||||
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
|
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
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 { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
|
||||||
import { MicroservicesModule } from './microservices.module';
|
import { MicroservicesModule } from './microservices.module';
|
||||||
|
|
||||||
@@ -10,11 +11,17 @@ async function bootstrap() {
|
|||||||
|
|
||||||
await app.listen(3002, () => {
|
await app.listen(3002, () => {
|
||||||
if (process.env.NODE_ENV == 'development') {
|
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') {
|
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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
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.
|
// TODO: just temporary task to generate previous uploaded assets.
|
||||||
@Processor(generateChecksumQueueName)
|
@Processor(generateChecksumQueueName)
|
||||||
@@ -17,15 +17,23 @@ export class GenerateChecksumProcessor {
|
|||||||
|
|
||||||
@Process()
|
@Process()
|
||||||
async generateChecksum() {
|
async generateChecksum() {
|
||||||
|
const pageSize = 200;
|
||||||
let hasNext = true;
|
let hasNext = true;
|
||||||
let pageSize = 200;
|
let lastErrAssetId: string | undefined = undefined;
|
||||||
|
|
||||||
while (hasNext) {
|
while (hasNext) {
|
||||||
|
const whereStat: FindOptionsWhere<AssetEntity> = {
|
||||||
|
checksum: IsNull(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lastErrAssetId) {
|
||||||
|
whereStat.id = MoreThan(lastErrAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
const assets = await this.assetRepository.find({
|
const assets = await this.assetRepository.find({
|
||||||
where: {
|
where: whereStat,
|
||||||
checksum: IsNull()
|
|
||||||
},
|
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
order: { id: 'ASC' }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!assets?.length) {
|
if (!assets?.length) {
|
||||||
@@ -35,15 +43,24 @@ export class GenerateChecksumProcessor {
|
|||||||
try {
|
try {
|
||||||
await this.generateAssetChecksum(asset);
|
await this.generateAssetChecksum(asset);
|
||||||
} catch (err: any) {
|
} 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) {
|
if (assets.length < pageSize) {
|
||||||
hasNext = false;
|
hasNext = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.log(`checksum generation done!`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateAssetChecksum(asset: AssetEntity) {
|
private async generateAssetChecksum(asset: AssetEntity) {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import axios from 'axios';
|
|||||||
import { Job } from 'bull';
|
import { Job } from 'bull';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import { readFile } from 'fs/promises';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { Repository } from 'typeorm/repository/Repository';
|
import { Repository } from 'typeorm/repository/Repository';
|
||||||
@@ -330,7 +329,7 @@ export class MetadataExtractionProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stream.r_frame_rate) {
|
if (stream.r_frame_rate) {
|
||||||
let fpsParts = stream.r_frame_rate.split('/');
|
const fpsParts = stream.r_frame_rate.split('/');
|
||||||
|
|
||||||
if (fpsParts.length === 2) {
|
if (fpsParts.length === 2) {
|
||||||
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
newExif.fps = Math.round(parseInt(fpsParts[0]) / parseInt(fpsParts[1]));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,8 @@ module.exports = {
|
|||||||
browser: true,
|
browser: true,
|
||||||
es2017: true,
|
es2017: true,
|
||||||
node: true
|
node: true
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
NodeJS: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
src/api/open-api
|
||||||
|
*.md
|
||||||
|
*.json
|
||||||
|
|
||||||
# Ignore files for PNPM, NPM and YARN
|
# Ignore files for PNPM, NPM and YARN
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
3
web/babel.config.cjs
Normal file
3
web/babel.config.cjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript']
|
||||||
|
};
|
||||||
204
web/jest.config.mjs
Normal file
204
web/jest.config.mjs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* For a detailed explanation regarding each configuration property, visit:
|
||||||
|
* https://jestjs.io/docs/configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// All imported modules in your tests should be mocked automatically
|
||||||
|
// automock: false,
|
||||||
|
|
||||||
|
// Stop running tests after `n` failures
|
||||||
|
// bail: 0,
|
||||||
|
|
||||||
|
// The directory where Jest should store its cached dependency information
|
||||||
|
// cacheDirectory: "/private/var/folders/6n/31wm28711gzbt3gzsxhzxx500000gn/T/jest_dx",
|
||||||
|
|
||||||
|
// Automatically clear mock calls, instances, contexts and results before every test
|
||||||
|
clearMocks: true,
|
||||||
|
|
||||||
|
// Indicates whether the coverage information should be collected while executing the test
|
||||||
|
// collectCoverage: false,
|
||||||
|
|
||||||
|
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||||
|
// collectCoverageFrom: undefined,
|
||||||
|
|
||||||
|
// The directory where Jest should output its coverage files
|
||||||
|
// coverageDirectory: undefined,
|
||||||
|
|
||||||
|
// An array of regexp pattern strings used to skip coverage collection
|
||||||
|
// coveragePathIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Indicates which provider should be used to instrument code for coverage
|
||||||
|
coverageProvider: 'v8',
|
||||||
|
|
||||||
|
// A list of reporter names that Jest uses when writing coverage reports
|
||||||
|
// coverageReporters: [
|
||||||
|
// "json",
|
||||||
|
// "text",
|
||||||
|
// "lcov",
|
||||||
|
// "clover"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An object that configures minimum threshold enforcement for coverage results
|
||||||
|
// coverageThreshold: undefined,
|
||||||
|
|
||||||
|
// A path to a custom dependency extractor
|
||||||
|
// dependencyExtractor: undefined,
|
||||||
|
|
||||||
|
// Make calling deprecated APIs throw helpful error messages
|
||||||
|
// errorOnDeprecated: false,
|
||||||
|
|
||||||
|
// The default configuration for fake timers
|
||||||
|
// fakeTimers: {
|
||||||
|
// "enableGlobally": false
|
||||||
|
// },
|
||||||
|
|
||||||
|
// Force coverage collection from ignored files using an array of glob patterns
|
||||||
|
// forceCoverageMatch: [],
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once before all test suites
|
||||||
|
// globalSetup: undefined,
|
||||||
|
|
||||||
|
// A path to a module which exports an async function that is triggered once after all test suites
|
||||||
|
// globalTeardown: undefined,
|
||||||
|
|
||||||
|
// A set of global variables that need to be available in all test environments
|
||||||
|
// globals: {},
|
||||||
|
|
||||||
|
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||||
|
// maxWorkers: "50%",
|
||||||
|
|
||||||
|
// An array of directory names to be searched recursively up from the requiring module's location
|
||||||
|
// moduleDirectories: [
|
||||||
|
// "node_modules"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of file extensions your modules use
|
||||||
|
// moduleFileExtensions: [
|
||||||
|
// "js",
|
||||||
|
// "mjs",
|
||||||
|
// "cjs",
|
||||||
|
// "jsx",
|
||||||
|
// "ts",
|
||||||
|
// "tsx",
|
||||||
|
// "json",
|
||||||
|
// "node"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^\\$lib(.*)$': '<rootDir>/src/lib$1',
|
||||||
|
'^\\@api(.*)$': '<rootDir>/src/api$1',
|
||||||
|
'^\\@test-data(.*)$': '<rootDir>/src/test-data$1'
|
||||||
|
},
|
||||||
|
|
||||||
|
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||||
|
// modulePathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Activates notifications for test results
|
||||||
|
// notify: false,
|
||||||
|
|
||||||
|
// An enum that specifies notification mode. Requires { notify: true }
|
||||||
|
// notifyMode: "failure-change",
|
||||||
|
|
||||||
|
// A preset that is used as a base for Jest's configuration
|
||||||
|
// preset: undefined,
|
||||||
|
|
||||||
|
// Run tests from one or more projects
|
||||||
|
// projects: undefined,
|
||||||
|
|
||||||
|
// Use this configuration option to add custom reporters to Jest
|
||||||
|
// reporters: undefined,
|
||||||
|
|
||||||
|
// Automatically reset mock state before every test
|
||||||
|
// resetMocks: false,
|
||||||
|
|
||||||
|
// Reset the module registry before running each individual test
|
||||||
|
// resetModules: false,
|
||||||
|
|
||||||
|
// A path to a custom resolver
|
||||||
|
// resolver: undefined,
|
||||||
|
|
||||||
|
// Automatically restore mock state and implementation before every test
|
||||||
|
// restoreMocks: false,
|
||||||
|
|
||||||
|
// The root directory that Jest should scan for tests and modules within
|
||||||
|
// rootDir: undefined,
|
||||||
|
|
||||||
|
// A list of paths to directories that Jest should use to search for files in
|
||||||
|
// roots: [
|
||||||
|
// "<rootDir>"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Allows you to use a custom runner instead of Jest's default test runner
|
||||||
|
// runner: "jest-runner",
|
||||||
|
|
||||||
|
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||||
|
// setupFiles: [],
|
||||||
|
|
||||||
|
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||||
|
// setupFilesAfterEnv: [],
|
||||||
|
|
||||||
|
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||||
|
// slowTestThreshold: 5,
|
||||||
|
|
||||||
|
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||||
|
// snapshotSerializers: [],
|
||||||
|
|
||||||
|
// The test environment that will be used for testing
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
|
||||||
|
// Options that will be passed to the testEnvironment
|
||||||
|
// testEnvironmentOptions: {},
|
||||||
|
|
||||||
|
// Adds a location field to test results
|
||||||
|
// testLocationInResults: false,
|
||||||
|
|
||||||
|
// The glob patterns Jest uses to detect test files
|
||||||
|
// testMatch: [
|
||||||
|
// "**/__tests__/**/*.[jt]s?(x)",
|
||||||
|
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||||
|
// testPathIgnorePatterns: [
|
||||||
|
// "/node_modules/"
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||||
|
// testRegex: [],
|
||||||
|
|
||||||
|
// This option allows the use of a custom results processor
|
||||||
|
// testResultsProcessor: undefined,
|
||||||
|
|
||||||
|
// This option allows use of a custom test runner
|
||||||
|
// testRunner: "jest-circus/runner",
|
||||||
|
|
||||||
|
// A map from regular expressions to paths to transformers
|
||||||
|
transform: {
|
||||||
|
'\\.[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/(?!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,
|
||||||
|
|
||||||
|
// Indicates whether each individual test should be reported during the run
|
||||||
|
// verbose: undefined,
|
||||||
|
|
||||||
|
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||||
|
// watchPathIgnorePatterns: [],
|
||||||
|
|
||||||
|
// Whether to use watchman for file crawling
|
||||||
|
// watchman: true,
|
||||||
|
};
|
||||||
8915
web/package-lock.json
generated
8915
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,28 +6,26 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"package": "svelte-kit package",
|
"package": "svelte-kit package",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
||||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "npm run check -- --watch",
|
||||||
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
|
"check:code": "npm run format && npm run lint && npm run check",
|
||||||
"format": "prettier --write --plugin-search-dir=. ."
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/preset-env": "^7.19.0",
|
||||||
|
"@babel/preset-typescript": "^7.18.6",
|
||||||
|
"@faker-js/faker": "^7.5.0",
|
||||||
"@sveltejs/adapter-auto": "next",
|
"@sveltejs/adapter-auto": "next",
|
||||||
"@sveltejs/kit": "next",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.27.0",
|
|
||||||
"@typescript-eslint/parser": "^5.27.0",
|
|
||||||
"eslint": "^8.16.0",
|
|
||||||
"eslint-config-prettier": "^8.3.0",
|
|
||||||
"eslint-plugin-svelte3": "^4.0.0",
|
|
||||||
"prettier": "^2.6.2",
|
|
||||||
"prettier-plugin-svelte": "^2.7.0",
|
|
||||||
"svelte": "^3.44.0",
|
|
||||||
"svelte-check": "^2.7.1",
|
|
||||||
"svelte-preprocess": "^4.10.6",
|
|
||||||
"tslib": "^2.3.1",
|
|
||||||
"typescript": "^4.7.4",
|
|
||||||
"vite": "^3.0.0",
|
|
||||||
"@sveltejs/adapter-node": "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/bcrypt": "^5.0.0",
|
||||||
"@types/cookie": "^0.4.1",
|
"@types/cookie": "^0.4.1",
|
||||||
"@types/fluent-ffmpeg": "^2.1.20",
|
"@types/fluent-ffmpeg": "^2.1.20",
|
||||||
@@ -35,9 +33,27 @@
|
|||||||
"@types/lodash": "^4.14.182",
|
"@types/lodash": "^4.14.182",
|
||||||
"@types/lodash-es": "^4.17.6",
|
"@types/lodash-es": "^4.17.6",
|
||||||
"@types/socket.io-client": "^3.0.0",
|
"@types/socket.io-client": "^3.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.27.0",
|
||||||
|
"@typescript-eslint/parser": "^5.27.0",
|
||||||
"autoprefixer": "^10.4.7",
|
"autoprefixer": "^10.4.7",
|
||||||
|
"babel-jest": "^29.0.2",
|
||||||
|
"eslint": "^8.16.0",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-plugin-svelte3": "^4.0.0",
|
||||||
|
"factory.ts": "^1.2.0",
|
||||||
|
"jest": "^29.0.2",
|
||||||
|
"jest-environment-jsdom": "^29.0.2",
|
||||||
"postcss": "^8.4.13",
|
"postcss": "^8.4.13",
|
||||||
"tailwindcss": "^3.0.24"
|
"prettier": "^2.6.2",
|
||||||
|
"prettier-plugin-svelte": "^2.7.0",
|
||||||
|
"svelte": "^3.44.0",
|
||||||
|
"svelte-check": "^2.7.1",
|
||||||
|
"svelte-jester": "^2.3.2",
|
||||||
|
"svelte-preprocess": "^4.10.7",
|
||||||
|
"tailwindcss": "^3.0.24",
|
||||||
|
"tslib": "^2.3.1",
|
||||||
|
"typescript": "^4.7.4",
|
||||||
|
"vite": "^3.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -84,6 +84,31 @@ export interface AdminSignupResponseDto {
|
|||||||
*/
|
*/
|
||||||
'createdAt': string;
|
'createdAt': string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface AlbumCountResponseDto
|
||||||
|
*/
|
||||||
|
export interface AlbumCountResponseDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof AlbumCountResponseDto
|
||||||
|
*/
|
||||||
|
'owned': number;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof AlbumCountResponseDto
|
||||||
|
*/
|
||||||
|
'shared': number;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof AlbumCountResponseDto
|
||||||
|
*/
|
||||||
|
'sharing': number;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@@ -183,6 +208,25 @@ export interface AssetCountByTimeBucketResponseDto {
|
|||||||
*/
|
*/
|
||||||
'buckets': Array<AssetCountByTimeBucket>;
|
'buckets': Array<AssetCountByTimeBucket>;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface AssetCountByUserIdResponseDto
|
||||||
|
*/
|
||||||
|
export interface AssetCountByUserIdResponseDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof AssetCountByUserIdResponseDto
|
||||||
|
*/
|
||||||
|
'photos': number;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof AssetCountByUserIdResponseDto
|
||||||
|
*/
|
||||||
|
'videos': number;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@@ -1408,6 +1452,39 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAlbumCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/album/count-by-user-id`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
@@ -1676,6 +1753,15 @@ export const AlbumApiFp = function(configuration?: Configuration) {
|
|||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(albumId, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(albumId, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getAlbumCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AlbumCountResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getAlbumCountByUserId(options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} albumId
|
* @param {string} albumId
|
||||||
@@ -1778,6 +1864,14 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
|
|||||||
deleteAlbum(albumId: string, options?: any): AxiosPromise<void> {
|
deleteAlbum(albumId: string, options?: any): AxiosPromise<void> {
|
||||||
return localVarFp.deleteAlbum(albumId, options).then((request) => request(axios, basePath));
|
return localVarFp.deleteAlbum(albumId, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAlbumCountByUserId(options?: any): AxiosPromise<AlbumCountResponseDto> {
|
||||||
|
return localVarFp.getAlbumCountByUserId(options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} albumId
|
* @param {string} albumId
|
||||||
@@ -1883,6 +1977,16 @@ export class AlbumApi extends BaseAPI {
|
|||||||
return AlbumApiFp(this.configuration).deleteAlbum(albumId, options).then((request) => request(this.axios, this.basePath));
|
return AlbumApiFp(this.configuration).deleteAlbum(albumId, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof AlbumApi
|
||||||
|
*/
|
||||||
|
public getAlbumCountByUserId(options?: AxiosRequestConfig) {
|
||||||
|
return AlbumApiFp(this.configuration).getAlbumCountByUserId(options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} albumId
|
* @param {string} albumId
|
||||||
@@ -2236,6 +2340,39 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
|||||||
options: localVarRequestOptions,
|
options: localVarRequestOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/asset/count-by-user-id`;
|
||||||
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
|
let baseOptions;
|
||||||
|
if (configuration) {
|
||||||
|
baseOptions = configuration.baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||||
|
const localVarHeaderParameter = {} as any;
|
||||||
|
const localVarQueryParameter = {} as any;
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@@ -2640,6 +2777,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
|
|||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByUserIdResponseDto>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByUserId(options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@@ -2800,6 +2946,14 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
|
|||||||
getAssetCountByTimeBucket(getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, options?: any): AxiosPromise<AssetCountByTimeBucketResponseDto> {
|
getAssetCountByTimeBucket(getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto, options?: any): AxiosPromise<AssetCountByTimeBucketResponseDto> {
|
||||||
return localVarFp.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(axios, basePath));
|
return localVarFp.getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getAssetCountByUserId(options?: any): AxiosPromise<AssetCountByUserIdResponseDto> {
|
||||||
|
return localVarFp.getAssetCountByUserId(options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@@ -2966,6 +3120,16 @@ export class AssetApi extends BaseAPI {
|
|||||||
return AssetApiFp(this.configuration).getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(this.axios, this.basePath));
|
return AssetApiFp(this.configuration).getAssetCountByTimeBucket(getAssetCountByTimeBucketDto, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof AssetApi
|
||||||
|
*/
|
||||||
|
public getAssetCountByUserId(options?: AxiosRequestConfig) {
|
||||||
|
return AssetApiFp(this.configuration).getAssetCountByUserId(options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { AssetCountByTimeGroupResponseDto } from '@api';
|
const _basePath = '/api';
|
||||||
let _basePath = '/api';
|
|
||||||
|
|
||||||
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
|
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
|
||||||
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);
|
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);
|
||||||
|
|||||||
103
web/src/app.css
103
web/src/app.css
@@ -6,86 +6,93 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Work Sans', sans-serif;
|
font-family: 'Work Sans', sans-serif;
|
||||||
/* --immich-icon-button-hover-color: #d3d3d3; */
|
/* --immich-icon-button-hover-color: #d3d3d3; */
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html::-webkit-scrollbar {
|
html::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Track */
|
/* Track */
|
||||||
html::-webkit-scrollbar-track {
|
html::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Handle */
|
/* Handle */
|
||||||
html::-webkit-scrollbar-thumb {
|
html::-webkit-scrollbar-thumb {
|
||||||
background: rgba(85, 86, 87, 0.408);
|
background: rgba(85, 86, 87, 0.408);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Handle on hover */
|
/* Handle on hover */
|
||||||
html::-webkit-scrollbar-thumb:hover {
|
html::-webkit-scrollbar-thumb:hover {
|
||||||
background: #4250afad;
|
background: #4250afad;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
/* min-height: 100vh; */
|
/* min-height: 100vh; */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: #f6f8fe;
|
background-color: #f6f8fe;
|
||||||
color: #5f6368;
|
color: #5f6368;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus-visible {
|
input:focus-visible {
|
||||||
outline-offset: 0px !important;
|
outline-offset: 0px !important;
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.immich-form-input {
|
.immich-form-input {
|
||||||
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm ;
|
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.immich-form-label {
|
.immich-form-label {
|
||||||
@apply font-medium text-sm text-gray-500;
|
@apply font-medium text-sm text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.immich-btn-primary {
|
.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;
|
@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 {
|
.immich-text-button {
|
||||||
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
|
@apply flex place-items-center place-content-center gap-2 hover:bg-immich-primary/5 p-2 rounded-lg font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* width */
|
/* width */
|
||||||
.immich-scrollbar::-webkit-scrollbar {
|
.immich-scrollbar::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Track */
|
/* Track */
|
||||||
.immich-scrollbar::-webkit-scrollbar-track {
|
.immich-scrollbar::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Handle */
|
/* Handle */
|
||||||
.immich-scrollbar::-webkit-scrollbar-thumb {
|
.immich-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: rgba(85, 86, 87, 0.408);
|
background: rgba(85, 86, 87, 0.408);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Handle on hover */
|
/* Handle on hover */
|
||||||
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
.immich-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: #4250afad;
|
background: #4250afad;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hidden scrollbar */
|
||||||
|
/* width */
|
||||||
|
.scrollbar-hidden::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
web/src/lib/__mocks__/jsdom-url.mock.ts
Normal file
8
web/src/lib/__mocks__/jsdom-url.mock.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const createObjectURLMock = jest.fn();
|
||||||
|
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
writable: true,
|
||||||
|
value: createObjectURLMock
|
||||||
|
});
|
||||||
|
|
||||||
|
export { createObjectURLMock };
|
||||||
144
web/src/lib/components/album-page/__tests__/album-card.spec.ts
Normal file
144
web/src/lib/components/album-page/__tests__/album-card.spec.ts
Normal 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 } })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,34 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
type OnShowContextMenu = {
|
||||||
|
showalbumcontextmenu: OnShowContextMenuDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OnClick = {
|
||||||
|
click: OnClickDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnShowContextMenuDetail = { x: number; y: number };
|
||||||
|
export type OnClickDetail = AlbumResponseDto;
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
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 dispatch = createEventDispatcher();
|
const dispatchClick = createEventDispatcher<OnClick>();
|
||||||
|
const dispatchShowContextMenu = createEventDispatcher<OnShowContextMenu>();
|
||||||
|
|
||||||
const loadHighQualityThumbnail = async (thubmnailId: string | null) => {
|
const loadHighQualityThumbnail = async (thubmnailId: string | null) => {
|
||||||
if (thubmnailId == null) {
|
if (thubmnailId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId!, ThumbnailFormat.Jpeg, {
|
const { data } = await api.assetApi.getAssetThumbnail(thubmnailId, ThumbnailFormat.Jpeg, {
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,7 +38,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showAlbumContextMenu = (e: MouseEvent) => {
|
const showAlbumContextMenu = (e: MouseEvent) => {
|
||||||
dispatch('showalbumcontextmenu', {
|
dispatchShowContextMenu('showalbumcontextmenu', {
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY
|
y: e.clientY
|
||||||
});
|
});
|
||||||
@@ -38,12 +51,14 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative"
|
class="h-[339px] w-[275px] hover:cursor-pointer mt-4 relative"
|
||||||
on:click={() => dispatch('click', album)}
|
on:click={() => dispatchClick('click', album)}
|
||||||
|
data-testid="album-card"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
id={`icon-${album.id}`}
|
id={`icon-${album.id}`}
|
||||||
class="absolute top-2 right-2"
|
class="absolute top-2 right-2"
|
||||||
on:click|stopPropagation|preventDefault={showAlbumContextMenu}
|
on:click|stopPropagation|preventDefault={showAlbumContextMenu}
|
||||||
|
data-testid="context-button-parent"
|
||||||
>
|
>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
logo={DotsVertical}
|
logo={DotsVertical}
|
||||||
@@ -58,15 +73,16 @@
|
|||||||
src={imageData}
|
src={imageData}
|
||||||
alt={album.id}
|
alt={album.id}
|
||||||
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
|
class={`object-cover h-full w-full transition-all z-0 rounded-xl duration-300 hover:shadow-lg`}
|
||||||
|
data-testid="album-image"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<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}
|
{album.albumName}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<span class="text-xs flex gap-2">
|
<span class="text-xs flex gap-2" data-testid="album-details">
|
||||||
<p>{album.assetCount} items</p>
|
<p>{album.assetCount} items</p>
|
||||||
|
|
||||||
{#if album.shared}
|
{#if album.shared}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
import CircleAvatar from '../shared-components/circle-avatar.svelte';
|
||||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||||
import AssetSelection from './asset-selection.svelte';
|
import AssetSelection from './asset-selection.svelte';
|
||||||
import _ from 'lodash-es';
|
|
||||||
import UserSelectionModal from './user-selection-modal.svelte';
|
import UserSelectionModal from './user-selection-modal.svelte';
|
||||||
import ShareInfoModal from './share-info-modal.svelte';
|
import ShareInfoModal from './share-info-modal.svelte';
|
||||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||||
@@ -27,12 +26,16 @@
|
|||||||
NotificationType
|
NotificationType
|
||||||
} from '../shared-components/notification/notification';
|
} from '../shared-components/notification/notification';
|
||||||
import { browser } from '$app/env';
|
import { browser } from '$app/env';
|
||||||
|
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
export let album: AlbumResponseDto;
|
||||||
|
const { isAlbumAssetSelectionOpen } = albumAssetSelectionStore;
|
||||||
|
|
||||||
let isShowAssetViewer = false;
|
let isShowAssetViewer = false;
|
||||||
|
|
||||||
let isShowAssetSelection = false;
|
let isShowAssetSelection = false;
|
||||||
|
|
||||||
|
$: $isAlbumAssetSelectionOpen = isShowAssetSelection;
|
||||||
$: {
|
$: {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
if (isShowAssetSelection) {
|
if (isShowAssetSelection) {
|
||||||
@@ -53,8 +56,7 @@
|
|||||||
let currentViewAssetIndex = 0;
|
let currentViewAssetIndex = 0;
|
||||||
|
|
||||||
let viewWidth: number;
|
let viewWidth: number;
|
||||||
let thumbnailSize: number = 300;
|
let thumbnailSize = 300;
|
||||||
let border = '';
|
|
||||||
let backUrl = '/albums';
|
let backUrl = '/albums';
|
||||||
let currentAlbumName = '';
|
let currentAlbumName = '';
|
||||||
let currentUser: UserResponseDto;
|
let currentUser: UserResponseDto;
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
import ArrowLeft from 'svelte-material-icons/ArrowLeft.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 CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
|
||||||
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
|
|
||||||
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
|
||||||
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
$: {
|
$: {
|
||||||
appearsInAlbums = [];
|
appearsInAlbums = [];
|
||||||
|
|
||||||
api.albumApi.getAllAlbums(undefined, asset.id).then(result => {
|
api.albumApi.getAllAlbums(undefined, asset.id).then((result) => {
|
||||||
appearsInAlbums = result.data;
|
appearsInAlbums = result.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -29,12 +29,14 @@
|
|||||||
let isShowDetail = false;
|
let isShowDetail = false;
|
||||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||||
|
|
||||||
|
const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
|
document.addEventListener('keydown', onKeyboardPress);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
document.removeEventListener('keydown', (e) => {});
|
document.removeEventListener('keydown', onKeyboardPress);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleKeyboardPress = (key: string) => {
|
const handleKeyboardPress = (key: string) => {
|
||||||
@@ -205,7 +207,7 @@
|
|||||||
<div
|
<div
|
||||||
transition:fly={{ duration: 150 }}
|
transition:fly={{ duration: 150 }}
|
||||||
id="detail-panel"
|
id="detail-panel"
|
||||||
class="bg-immich-bg w-[360px] row-span-full transition-all "
|
class="bg-immich-bg w-[360px] row-span-full transition-all overflow-y-auto"
|
||||||
translate="yes"
|
translate="yes"
|
||||||
>
|
>
|
||||||
<DetailPanel {asset} albums={appearsInAlbums} on:close={() => (isShowDetail = false)} />
|
<DetailPanel {asset} albums={appearsInAlbums} on:close={() => (isShowDetail = false)} />
|
||||||
|
|||||||
@@ -9,10 +9,14 @@
|
|||||||
import { browser } from '$app/env';
|
import { browser } from '$app/env';
|
||||||
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
import { AssetResponseDto, AlbumResponseDto } from '@api';
|
||||||
|
|
||||||
|
type Leaflet = typeof import('leaflet');
|
||||||
|
type LeafletMap = import('leaflet').Map;
|
||||||
|
type LeafletMarker = import('leaflet').Marker;
|
||||||
|
|
||||||
// Map Property
|
// Map Property
|
||||||
let map: any;
|
let map: LeafletMap;
|
||||||
let leaflet: any;
|
let leaflet: Leaflet;
|
||||||
let marker: any;
|
let marker: LeafletMarker;
|
||||||
|
|
||||||
export let asset: AssetResponseDto;
|
export let asset: AssetResponseDto;
|
||||||
$: if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
|
$: if (asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null) {
|
||||||
@@ -31,7 +35,6 @@
|
|||||||
|
|
||||||
async function drawMap(lat: number, lon: number) {
|
async function drawMap(lat: number, lon: number) {
|
||||||
if (!leaflet) {
|
if (!leaflet) {
|
||||||
// @ts-ignore
|
|
||||||
leaflet = await import('leaflet');
|
leaflet = await import('leaflet');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
export let root: HTMLElement | null = null;
|
export let root: HTMLElement | null = null;
|
||||||
|
|
||||||
let intersecting = false;
|
let intersecting = false;
|
||||||
let container: any;
|
let container: HTMLDivElement;
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
|
|
||||||
const assetData = URL.createObjectURL(data);
|
const assetData = URL.createObjectURL(data);
|
||||||
return assetData;
|
return assetData;
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import { api, AssetResponseDto, getFileUrl } from '@api';
|
import { api, AssetResponseDto, getFileUrl } from '@api';
|
||||||
|
|
||||||
@@ -9,8 +9,6 @@
|
|||||||
|
|
||||||
let asset: AssetResponseDto;
|
let asset: AssetResponseDto;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
let videoPlayerNode: HTMLVideoElement;
|
let videoPlayerNode: HTMLVideoElement;
|
||||||
let isVideoLoading = true;
|
let isVideoLoading = true;
|
||||||
let videoUrl: string;
|
let videoUrl: string;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
let error: string;
|
let error: string;
|
||||||
let success: string;
|
let success: string;
|
||||||
|
|
||||||
let password: string = '';
|
let password = '';
|
||||||
let confirmPassowrd: string = '';
|
let confirmPassowrd = '';
|
||||||
|
|
||||||
let canRegister = false;
|
let canRegister = false;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
let error: string;
|
let error: string;
|
||||||
let success: string;
|
let success: string;
|
||||||
|
|
||||||
let password: string = '';
|
let password = '';
|
||||||
let confirmPassowrd: string = '';
|
let confirmPassowrd = '';
|
||||||
|
|
||||||
let changeChagePassword = false;
|
let changeChagePassword = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +1,121 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '@api';
|
import { api } from '@api';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
let error: string;
|
let error: string;
|
||||||
let success: string;
|
let success: string;
|
||||||
|
|
||||||
let password = '';
|
let password = '';
|
||||||
let confirmPassowrd = '';
|
let confirmPassowrd = '';
|
||||||
|
|
||||||
let canCreateUser = false;
|
let canCreateUser = false;
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||||
error = 'Password does not match';
|
error = 'Password does not match';
|
||||||
canCreateUser = false;
|
canCreateUser = false;
|
||||||
} else {
|
} else {
|
||||||
error = '';
|
error = '';
|
||||||
canCreateUser = true;
|
canCreateUser = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
async function registerUser(event: SubmitEvent) {
|
async function registerUser(event: SubmitEvent) {
|
||||||
if (canCreateUser) {
|
if (canCreateUser) {
|
||||||
error = '';
|
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 email = form.get('email');
|
||||||
const password = form.get('password');
|
const password = form.get('password');
|
||||||
const firstName = form.get('firstName');
|
const firstName = form.get('firstName');
|
||||||
const lastName = form.get('lastName');
|
const lastName = form.get('lastName');
|
||||||
|
|
||||||
const {status} = await api.userApi.createUser({
|
const { status } = await api.userApi.createUser({
|
||||||
email: String(email),
|
email: String(email),
|
||||||
password: String(password),
|
password: String(password),
|
||||||
firstName: String(firstName),
|
firstName: String(firstName),
|
||||||
lastName: String(lastName)
|
lastName: String(lastName)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status === 201) {
|
if (status === 201) {
|
||||||
success = 'New user created';
|
success = 'New user created';
|
||||||
|
|
||||||
dispatch('user-created');
|
dispatch('user-created');
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
error = 'Error create user account';
|
error = 'Error create user account';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-3xl py-8">
|
<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">
|
<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"/>
|
<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>
|
<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">
|
<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
|
Please provide your user with the password, they will have to change it on their first sign
|
||||||
in.
|
in.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="email">Email</label>
|
<label class="immich-form-label" for="email">Email</label>
|
||||||
<input class="immich-form-input" id="email" name="email" type="email" required/>
|
<input class="immich-form-input" id="email" name="email" type="email" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="password">Password</label>
|
<label class="immich-form-label" for="password">Password</label>
|
||||||
<input
|
<input
|
||||||
class="immich-form-input"
|
class="immich-form-input"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||||
<input
|
<input
|
||||||
class="immich-form-input"
|
class="immich-form-input"
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
bind:value={confirmPassowrd}
|
bind:value={confirmPassowrd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="firstName">First Name</label>
|
<label class="immich-form-label" for="firstName">First Name</label>
|
||||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required/>
|
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required/>
|
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if success}
|
{#if success}
|
||||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex w-full">
|
<div class="flex w-full">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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
|
>Create
|
||||||
</button
|
</button>
|
||||||
>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
const editUser = async (event: SubmitEvent) => {
|
const editUser = async (event: SubmitEvent) => {
|
||||||
try {
|
try {
|
||||||
const formElement = event.target as HTMLFormElement;
|
const formElement = event.target as HTMLFormElement;
|
||||||
@@ -25,8 +24,8 @@
|
|||||||
|
|
||||||
const { status } = await api.userApi.updateUser({
|
const { status } = await api.userApi.updateUser({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
firstName: firstName!.toString(),
|
firstName: firstName?.toString(),
|
||||||
lastName: lastName!.toString()
|
lastName: lastName?.toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status == 200) {
|
if (status == 200) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
let error: string;
|
let error: string;
|
||||||
let email: string = '';
|
let email = '';
|
||||||
let password: string = '';
|
let password = '';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import lodash from 'lodash-es';
|
import lodash from 'lodash-es';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
import ImmichThumbnail from '../shared-components/immich-thumbnail.svelte';
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import {
|
import {
|
||||||
assetInteractionStore,
|
assetInteractionStore,
|
||||||
assetsInAlbumStoreState,
|
assetsInAlbumStoreState,
|
||||||
@@ -22,7 +21,7 @@
|
|||||||
|
|
||||||
let isMouseOverGroup = false;
|
let isMouseOverGroup = false;
|
||||||
let actualBucketHeight: number;
|
let actualBucketHeight: number;
|
||||||
let hoveredDateGroup: string = '';
|
let hoveredDateGroup = '';
|
||||||
$: assetsGroupByDate = lodash
|
$: assetsGroupByDate = lodash
|
||||||
.chain(assets)
|
.chain(assets)
|
||||||
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
|
.groupBy((a) => moment(a.createdAt).format('ddd, MMM DD YYYY'))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
import IntersectionObserver from '../asset-viewer/intersection-observer.svelte';
|
||||||
import { assetGridState, assetStore, loadingBucketState } from '$lib/stores/assets.store';
|
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 AssetDateGroup from './asset-date-group.svelte';
|
||||||
import Portal from '../shared-components/portal/portal.svelte';
|
import Portal from '../shared-components/portal/portal.svelte';
|
||||||
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
import AssetViewer from '../asset-viewer/asset-viewer.svelte';
|
||||||
@@ -12,16 +12,23 @@
|
|||||||
isViewingAssetStoreState,
|
isViewingAssetStoreState,
|
||||||
viewingAssetStoreState
|
viewingAssetStoreState
|
||||||
} from '$lib/stores/asset-interaction.store';
|
} from '$lib/stores/asset-interaction.store';
|
||||||
|
import Scrollbar, {
|
||||||
|
OnScrollbarClickDetail,
|
||||||
|
OnScrollbarDragDetail
|
||||||
|
} from '../shared-components/scrollbar/scrollbar.svelte';
|
||||||
|
|
||||||
|
export let isAlbumSelectionMode = false;
|
||||||
|
|
||||||
let viewportHeight = 0;
|
let viewportHeight = 0;
|
||||||
let viewportWidth = 0;
|
let viewportWidth = 0;
|
||||||
let assetGridElement: HTMLElement;
|
let assetGridElement: HTMLElement;
|
||||||
export let isAlbumSelectionMode = false;
|
let bucketInfo: AssetCountByTimeBucketResponseDto;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
|
const { data: assetCountByTimebucket } = await api.assetApi.getAssetCountByTimeBucket({
|
||||||
timeGroup: TimeGroupEnum.Month
|
timeGroup: TimeGroupEnum.Month
|
||||||
});
|
});
|
||||||
|
bucketInfo = assetCountByTimebucket;
|
||||||
|
|
||||||
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
|
assetStore.setInitialState(viewportHeight, viewportWidth, assetCountByTimebucket);
|
||||||
|
|
||||||
@@ -60,14 +67,46 @@
|
|||||||
const navigateToNextAsset = () => {
|
const navigateToNextAsset = () => {
|
||||||
assetInteractionStore.navigateAsset('next');
|
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>
|
</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
|
<section
|
||||||
id="asset-grid"
|
id="asset-grid"
|
||||||
class="overflow-y-auto pl-4"
|
class="overflow-y-auto pl-4 scrollbar-hidden"
|
||||||
bind:clientHeight={viewportHeight}
|
bind:clientHeight={viewportHeight}
|
||||||
bind:clientWidth={viewportWidth}
|
bind:clientWidth={viewportWidth}
|
||||||
bind:this={assetGridElement}
|
bind:this={assetGridElement}
|
||||||
|
on:scroll={handleTimelineScroll}
|
||||||
>
|
>
|
||||||
{#if assetGridElement}
|
{#if assetGridElement}
|
||||||
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
|
<section id="virtual-timeline" style:height={$assetGridState.timelineHeight + 'px'}>
|
||||||
@@ -117,5 +156,6 @@
|
|||||||
<style>
|
<style>
|
||||||
#asset-grid {
|
#asset-grid {
|
||||||
contain: layout;
|
contain: layout;
|
||||||
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
|
||||||
import FullScreenModal from './full-screen-modal.svelte';
|
import FullScreenModal from './full-screen-modal.svelte';
|
||||||
export let localVersion: string;
|
export let localVersion: string;
|
||||||
export let remoteVersion: string;
|
export let remoteVersion: string;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
window.onscroll = function () {};
|
window.onscroll = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
|
|
||||||
// Avatar Size In Pixel
|
// Avatar Size In Pixel
|
||||||
export let size: number = 48;
|
export let size = 48;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
// TODO: why any here?
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export let logo: any;
|
export let logo: any;
|
||||||
export let backgroundColor: string = 'transparent';
|
export let backgroundColor = 'transparent';
|
||||||
export let hoverColor: string = '#e2e7e9';
|
export let hoverColor = '#e2e7e9';
|
||||||
export let logoColor: string = '#5f6368';
|
export let logoColor = '#5f6368';
|
||||||
export let size = '24';
|
export let size = '24';
|
||||||
export let title = '';
|
export let title = '';
|
||||||
let iconButton: HTMLButtonElement;
|
let iconButton: HTMLButtonElement;
|
||||||
|
|||||||
@@ -6,15 +6,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* x coordiante of the context menu.
|
* x coordiante of the context menu.
|
||||||
* @type {number}
|
|
||||||
*/
|
*/
|
||||||
export let x: number = 0;
|
export let x = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* x coordiante of the context menu.
|
* x coordiante of the context menu.
|
||||||
* @type {number}
|
|
||||||
*/
|
*/
|
||||||
export let y: number = 0;
|
export let y = 0;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
|||||||
@@ -12,21 +12,23 @@
|
|||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
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(() => {
|
onMount(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
document.addEventListener('scroll', (e) => {
|
document.addEventListener('scroll', onScroll);
|
||||||
if (window.pageYOffset > 80) {
|
|
||||||
appBarBorder = 'border border-gray-200 bg-gray-50';
|
|
||||||
} else {
|
|
||||||
appBarBorder = 'bg-immich-bg border border-transparent';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
document.removeEventListener('scroll', (e) => {});
|
document.removeEventListener('scroll', onScroll);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||||
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
|
||||||
import LoadingSpinner from './loading-spinner.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();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -14,14 +14,14 @@
|
|||||||
export let groupIndex = 0;
|
export let groupIndex = 0;
|
||||||
export let thumbnailSize: number | undefined = undefined;
|
export let thumbnailSize: number | undefined = undefined;
|
||||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||||
export let selected: boolean = false;
|
export let selected = false;
|
||||||
export let disabled: boolean = false;
|
export let disabled = false;
|
||||||
let imageData: string;
|
let imageData: string;
|
||||||
|
|
||||||
let mouseOver: boolean = false;
|
let mouseOver = false;
|
||||||
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
$: dispatch('mouse-event', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
|
||||||
|
|
||||||
let mouseOverIcon: boolean = false;
|
let mouseOverIcon = false;
|
||||||
let videoPlayerNode: HTMLVideoElement;
|
let videoPlayerNode: HTMLVideoElement;
|
||||||
let isThumbnailVideoPlaying = false;
|
let isThumbnailVideoPlaying = false;
|
||||||
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<div
|
<div
|
||||||
style:width={`${thumbnailSize}px`}
|
style:width={`${thumbnailSize}px`}
|
||||||
style:height={`${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'
|
disabled ? 'cursor-not-allowed' : 'hover:cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
on:mouseenter={handleMouseOverThumbnail}
|
on:mouseenter={handleMouseOverThumbnail}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
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 TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
|
||||||
import { clickOutside } from '../../utils/click-outside';
|
import { clickOutside } from '../../utils/click-outside';
|
||||||
import { api, UserResponseDto } from '@api';
|
import { api, UserResponseDto } from '@api';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user