mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:52:32 -04:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0281de7ff6 | |||
| 43554fc6cf | |||
| fd7ddfef54 | |||
| 0975b1599c | |||
| 78ac0ade01 | |||
| 7015e511e8 | |||
| 96420bbf04 | |||
| f4e275a257 | |||
| 561fe231ac | |||
| 6b291c469e | |||
| 7d5be4317f | |||
| eee3d2ce61 | |||
| e2f5308cba | |||
| d96cb8d386 | |||
| 2c9639f18b | |||
| 880155916f | |||
| 84854a8575 | |||
| fde0959579 | |||
| ca203726dc | |||
| 5d33870403 | |||
| 0276e86895 | |||
| 90d9d0075a | |||
| 6b7b029562 | |||
| 7adc568575 | |||
| f5dd2cfb18 | |||
| 8c143d36ef | |||
| 45411f38e8 | |||
| 28dda8e2d5 | |||
| dc15af4e69 | |||
| 2775a09dc5 | |||
| 80c9796abe | |||
| 66a3aa27b5 | |||
| 275c324e8d | |||
| 4354431327 | |||
| 0d4d59c7e7 | |||
| b3b0b0f576 | |||
| 4806dc76aa | |||
| 719c7d955b | |||
| 175f8d99de | |||
| fb66f53410 | |||
| 136379a882 | |||
| c35c948f63 | |||
| bc301a3aac | |||
| 3ab68a4bf8 | |||
| 66c6daeded | |||
| bb803f13da | |||
| bda0ceb2e2 | |||
| ef80a8e936 |
@@ -115,7 +115,6 @@ describe('/server', () => {
|
||||
oauthAutoLaunch: false,
|
||||
ocr: false,
|
||||
passwordLogin: true,
|
||||
realtimeTranscoding: false,
|
||||
search: true,
|
||||
sidecar: true,
|
||||
trash: true,
|
||||
|
||||
@@ -399,10 +399,6 @@
|
||||
"transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.",
|
||||
"transcoding_preset_preset": "Preset (-preset)",
|
||||
"transcoding_preset_preset_description": "Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above 'faster'.",
|
||||
"transcoding_realtime": "Real-time Transcoding [EXPERIMENTAL]",
|
||||
"transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.",
|
||||
"transcoding_realtime_enabled": "Enable real-time transcoding",
|
||||
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
|
||||
"transcoding_reference_frames": "Reference frames",
|
||||
"transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
|
||||
"transcoding_required_description": "Only videos not in an accepted format",
|
||||
@@ -2448,7 +2444,6 @@
|
||||
"video": "Video",
|
||||
"video_hover_setting": "Play video thumbnail on hover",
|
||||
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",
|
||||
"video_quality": "Video quality",
|
||||
"videos": "Videos",
|
||||
"videos_count": "{count, plural, one {# Video} other {# Videos}}",
|
||||
"videos_only": "Videos only",
|
||||
|
||||
@@ -89,6 +89,20 @@
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow Immich to act as an image viewer -->
|
||||
<intent-filter android:label="View in Immich">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow Immich to act as a video viewer -->
|
||||
<intent-filter android:label="View in Immich">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="content" android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- immich:// URL scheme handling -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
@@ -17,9 +18,12 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
import app.alextran.immich.viewintent.ViewIntentPlugin
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
@@ -29,6 +33,11 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
registerPlugins(this, flutterEngine)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
HttpClientManager.initialize(ctx)
|
||||
@@ -44,15 +53,19 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
} else {
|
||||
NativeSyncApiImpl30(ctx)
|
||||
}
|
||||
val permissionApiImpl = PermissionApiImpl(ctx)
|
||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||
PermissionApi.setUp(messenger, permissionApiImpl)
|
||||
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
||||
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
||||
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(ViewIntentPlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
flutterEngine.plugins.add(permissionApiImpl)
|
||||
}
|
||||
|
||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||
@@ -60,6 +73,8 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||
nativeApi?.detachFromEngine()
|
||||
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||
permissionApi?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import androidx.core.net.toUri
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class ManageMediaPermissionDelegate(
|
||||
context: Context,
|
||||
private val requestCode: Int = 1003,
|
||||
) : PluginRegistry.ActivityResultListener {
|
||||
private val ctx = context.applicationContext
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||
|
||||
fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(ctx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
if (hasManageMediaPermission()) {
|
||||
callback(Result.success(true))
|
||||
return
|
||||
}
|
||||
|
||||
openManageMediaPermissionSettings(callback)
|
||||
}
|
||||
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
openManageMediaPermissionSettings(callback)
|
||||
}
|
||||
|
||||
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
callback(Result.success(false))
|
||||
return
|
||||
}
|
||||
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null) {
|
||||
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||
return
|
||||
}
|
||||
|
||||
pendingResult = callback
|
||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
|
||||
data = "package:${activity.packageName}".toUri()
|
||||
}
|
||||
try {
|
||||
activity.startActivityForResult(intent, requestCode)
|
||||
} catch (e: Exception) {
|
||||
pendingResult = null
|
||||
callback(
|
||||
Result.failure(
|
||||
FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromActivity() {
|
||||
failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == this.requestCode) {
|
||||
val callback = pendingResult
|
||||
pendingResult = null
|
||||
callback?.invoke(Result.success(hasManageMediaPermission()))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun failPending(code: String, message: String) {
|
||||
val callback = pendingResult ?: return
|
||||
pendingResult = null
|
||||
callback(Result.failure(FlutterError(code, message, null)))
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object PermissionApiPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface PermissionApi {
|
||||
fun hasManageMediaPermission(): Boolean
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by PermissionApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
PermissionApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hasManageMediaPermission())
|
||||
} catch (exception: Throwable) {
|
||||
PermissionApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.requestManageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.manageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
|
||||
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
|
||||
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
|
||||
|
||||
override fun hasManageMediaPermission(): Boolean =
|
||||
manageMediaPermissionDelegate.hasManageMediaPermission()
|
||||
|
||||
override fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package app.alextran.immich.sync
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class MediaTrashDelegate(
|
||||
context: Context,
|
||||
private val trashRequestCode: Int = 1002,
|
||||
) : PluginRegistry.ActivityResultListener {
|
||||
private val ctx = context.applicationContext
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||
|
||||
private fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(ctx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
|
||||
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
|
||||
return
|
||||
}
|
||||
|
||||
val id = mediaId.toLongOrNull()
|
||||
if (id == null) {
|
||||
callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isInTrash(id)) {
|
||||
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
|
||||
return
|
||||
}
|
||||
|
||||
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun restoreUri(
|
||||
contentUri: Uri,
|
||||
callback: (Result<Boolean>) -> Unit,
|
||||
) {
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null) {
|
||||
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
|
||||
pendingResult = callback
|
||||
activity.startIntentSenderForResult(
|
||||
pendingIntent.intentSender,
|
||||
trashRequestCode,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingResult = null
|
||||
callback(
|
||||
Result.failure(
|
||||
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun isInTrash(id: Long): Boolean {
|
||||
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val args = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||
}
|
||||
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||
?.use { it.moveToFirst() } == true
|
||||
}
|
||||
|
||||
private fun contentUriForType(type: Int): Uri =
|
||||
when (type) {
|
||||
// Same order as AssetType from Dart.
|
||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
}
|
||||
|
||||
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromActivity() {
|
||||
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == trashRequestCode) {
|
||||
val callback = pendingResult
|
||||
pendingResult = null
|
||||
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun failPending(code: String, message: String) {
|
||||
val callback = pendingResult ?: return
|
||||
pendingResult = null
|
||||
callback(Result.failure(FlutterError(code, message, null)))
|
||||
}
|
||||
}
|
||||
@@ -553,6 +553,7 @@ interface NativeSyncApi {
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
|
||||
companion object {
|
||||
@@ -747,6 +748,27 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val mediaIdArg = args[0] as String
|
||||
val typeArg = args[1] as Long
|
||||
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -39,10 +41,11 @@ sealed class AssetResult {
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||
@@ -448,6 +451,26 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
hashTask = null
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.viewintent
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object ViewIntentPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun doubleEquals(a: Double, b: Double): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun floatEquals(a: Float, b: Float): Boolean {
|
||||
// Normalize -0.0 to 0.0 and handle NaN equality.
|
||||
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
|
||||
}
|
||||
|
||||
fun doubleHash(d: Double): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (d == 0.0) 0.0 else d
|
||||
val bits = java.lang.Double.doubleToLongBits(normalized)
|
||||
return (bits xor (bits ushr 32)).toInt()
|
||||
}
|
||||
|
||||
fun floatHash(f: Float): Int {
|
||||
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
|
||||
val normalized = if (f == 0.0f) 0.0f else f
|
||||
return java.lang.Float.floatToIntBits(normalized)
|
||||
}
|
||||
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return false
|
||||
}
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!doubleEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is FloatArray && b is FloatArray) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!floatEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!deepEquals(a[i], b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
if (a.size != b.size) return false
|
||||
val iterA = a.iterator()
|
||||
val iterB = b.iterator()
|
||||
while (iterA.hasNext() && iterB.hasNext()) {
|
||||
if (!deepEquals(iterA.next(), iterB.next())) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
if (a.size != b.size) return false
|
||||
for (entry in a) {
|
||||
val key = entry.key
|
||||
var found = false
|
||||
for (bEntry in b) {
|
||||
if (deepEquals(key, bEntry.key)) {
|
||||
if (deepEquals(entry.value, bEntry.value)) {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (a is Double && b is Double) {
|
||||
return doubleEquals(a, b)
|
||||
}
|
||||
if (a is Float && b is Float) {
|
||||
return floatEquals(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
fun deepHash(value: Any?): Int {
|
||||
return when (value) {
|
||||
null -> 0
|
||||
is ByteArray -> value.contentHashCode()
|
||||
is IntArray -> value.contentHashCode()
|
||||
is LongArray -> value.contentHashCode()
|
||||
is DoubleArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + doubleHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is FloatArray -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + floatHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Array<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is List<*> -> {
|
||||
var result = 1
|
||||
for (item in value) {
|
||||
result = 31 * result + deepHash(item)
|
||||
}
|
||||
result
|
||||
}
|
||||
is Map<*, *> -> {
|
||||
var result = 0
|
||||
for (entry in value) {
|
||||
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
|
||||
}
|
||||
result
|
||||
}
|
||||
is Double -> doubleHash(value)
|
||||
is Float -> floatHash(value)
|
||||
else -> value.hashCode()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class ViewIntentPayload (
|
||||
val path: String? = null,
|
||||
val mimeType: String,
|
||||
val localAssetId: String? = null
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): ViewIntentPayload {
|
||||
val path = pigeonVar_list[0] as String?
|
||||
val mimeType = pigeonVar_list[1] as String
|
||||
val localAssetId = pigeonVar_list[2] as String?
|
||||
return ViewIntentPayload(path, mimeType, localAssetId)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
path,
|
||||
mimeType,
|
||||
localAssetId,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as ViewIntentPayload
|
||||
return ViewIntentPigeonUtils.deepEquals(this.path, other.path) && ViewIntentPigeonUtils.deepEquals(this.mimeType, other.mimeType) && ViewIntentPigeonUtils.deepEquals(this.localAssetId, other.localAssetId)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.path)
|
||||
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.mimeType)
|
||||
result = 31 * result + ViewIntentPigeonUtils.deepHash(this.localAssetId)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class ViewIntentPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
ViewIntentPayload.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is ViewIntentPayload -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface ViewIntentHostApi {
|
||||
fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by ViewIntentHostApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
ViewIntentPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `ViewIntentHostApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: ViewIntentHostApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.consumeViewIntent{ result: Result<ViewIntentPayload?> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(ViewIntentPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(ViewIntentPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package app.alextran.immich.viewintent
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TAG = "ViewIntentPlugin"
|
||||
|
||||
class ViewIntentPlugin : FlutterPlugin, ActivityAware, PluginRegistry.NewIntentListener, ViewIntentHostApi {
|
||||
private var context: Context? = null
|
||||
private var activity: Activity? = null
|
||||
private var pendingIntent: Intent? = null
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
context = binding.applicationContext
|
||||
ViewIntentHostApi.setUp(binding.binaryMessenger, this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
ViewIntentHostApi.setUp(binding.binaryMessenger, null)
|
||||
ioScope.cancel()
|
||||
context = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
pendingIntent = binding.activity.intent
|
||||
binding.addOnNewIntentListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent): Boolean {
|
||||
pendingIntent = intent
|
||||
return false
|
||||
}
|
||||
|
||||
override fun consumeViewIntent(callback: (Result<ViewIntentPayload?>) -> Unit) {
|
||||
val context = context ?: run {
|
||||
callback(Result.success(null))
|
||||
return
|
||||
}
|
||||
val intent = pendingIntent ?: activity?.intent
|
||||
|
||||
if (intent?.action != Intent.ACTION_VIEW) {
|
||||
callback(Result.success(null))
|
||||
return
|
||||
}
|
||||
|
||||
val uri = intent.data
|
||||
if (uri == null) {
|
||||
callback(Result.success(null))
|
||||
return
|
||||
}
|
||||
|
||||
ioScope.launch {
|
||||
try {
|
||||
val mimeType = context.contentResolver.getType(uri) ?: intent.type
|
||||
if (mimeType == null || (!mimeType.startsWith("image/") && !mimeType.startsWith("video/"))) {
|
||||
callback(Result.success(null))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val localAssetId = extractLocalAssetId(context, uri, mimeType)
|
||||
val tempFilePath = if (localAssetId == null) {
|
||||
copyUriToTempFile(context, uri, mimeType)?.absolutePath ?: run {
|
||||
callback(Result.success(null))
|
||||
return@launch
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val payload = ViewIntentPayload(
|
||||
path = tempFilePath,
|
||||
mimeType = mimeType,
|
||||
localAssetId = localAssetId,
|
||||
)
|
||||
consumeViewIntent(intent)
|
||||
callback(Result.success(payload))
|
||||
} catch (e: Exception) {
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeViewIntent(currentIntent: Intent) {
|
||||
pendingIntent = Intent(currentIntent).apply {
|
||||
action = null
|
||||
data = null
|
||||
type = null
|
||||
}
|
||||
activity?.intent = pendingIntent
|
||||
}
|
||||
|
||||
private fun extractLocalAssetId(context: Context, uri: Uri, mimeType: String): String? {
|
||||
return tryExtractDocumentLocalAssetId(context, uri)
|
||||
?: tryParseContentUriId(uri)
|
||||
?: tryParseLastPathSegmentId(uri)
|
||||
?: resolveLocalIdByNameAndSize(context, uri, mimeType)
|
||||
}
|
||||
|
||||
private fun tryExtractDocumentLocalAssetId(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
if (!DocumentsContract.isDocumentUri(context, uri)) return null
|
||||
val docId = DocumentsContract.getDocumentId(uri)
|
||||
if (docId.isBlank() || docId.startsWith("raw:")) return null
|
||||
val parsed = docId.substringAfter(':', docId)
|
||||
if (parsed.isNotEmpty() && parsed.all(Char::isDigit)) parsed else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to resolve local asset id from document URI: $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseContentUriId(uri: Uri): String? {
|
||||
return try {
|
||||
val parsed = ContentUris.parseId(uri)
|
||||
if (parsed >= 0) parsed.toString() else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse local asset id from content URI: $uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseLastPathSegmentId(uri: Uri): String? {
|
||||
val segment = uri.lastPathSegment ?: return null
|
||||
return if (segment.all(Char::isDigit)) segment else null
|
||||
}
|
||||
|
||||
private fun copyUriToTempFile(context: Context, uri: Uri, mimeType: String): File? {
|
||||
return try {
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
|
||||
val tempFile = File.createTempFile("view_intent_", extension, context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: return null
|
||||
tempFile
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveLocalIdByNameAndSize(context: Context, uri: Uri, mimeType: String): String? {
|
||||
val metaProjection = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||
val (displayName, size) =
|
||||
try {
|
||||
context.contentResolver.query(uri, metaProjection, null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return null
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
val name = if (nameIdx >= 0) cursor.getString(nameIdx) else null
|
||||
val bytes = if (sizeIdx >= 0) cursor.getLong(sizeIdx) else -1L
|
||||
if (name.isNullOrBlank() || bytes < 0) return null
|
||||
name to bytes
|
||||
} ?: return null
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val tableUri = when {
|
||||
mimeType.startsWith("image/") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
mimeType.startsWith("video/") -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
} else {
|
||||
MediaStore.Files.getContentUri("external")
|
||||
}
|
||||
}
|
||||
return try {
|
||||
context.contentResolver
|
||||
.query(
|
||||
tableUri,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.SIZE}=?",
|
||||
arrayOf(displayName, size.toString()),
|
||||
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return null
|
||||
val idIndex = cursor.getColumnIndex(MediaStore.MediaColumns._ID)
|
||||
if (idIndex < 0) return null
|
||||
cursor.getLong(idIndex).toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
||||
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
|
||||
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
@@ -105,6 +107,8 @@
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
|
||||
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -283,6 +287,7 @@
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
B2EE00052E72CA15008B6CA7 /* Permission */,
|
||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
@@ -317,6 +322,15 @@
|
||||
path = Connectivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2EE00052E72CA15008B6CA7 /* Permission */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
|
||||
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
|
||||
);
|
||||
path = Permission;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -619,6 +633,8 @@
|
||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
||||
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
|
||||
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
|
||||
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||
|
||||
@@ -26,6 +26,7 @@ import native_video_player
|
||||
|
||||
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
||||
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
|
||||
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
||||
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(Swift.type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol PermissionApi {
|
||||
func hasManageMediaPermission() throws -> Bool
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class PermissionApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
|
||||
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.hasManageMediaPermission()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
api.requestManageMediaPermission { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
manageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
api.manageMediaPermission { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
manageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
class PermissionApiImpl: PermissionApi {
|
||||
func hasManageMediaPermission() throws -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.success(false))
|
||||
}
|
||||
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.success(false))
|
||||
}
|
||||
}
|
||||
Generated
+19
@@ -537,6 +537,7 @@ protocol NativeSyncApi {
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
}
|
||||
|
||||
@@ -721,6 +722,24 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let mediaIdArg = args[0] as! String
|
||||
let typeArg = args[1] as! Int64
|
||||
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreFromTrashByIdChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
|
||||
@@ -318,7 +318,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func cancelHashing() {
|
||||
hashTask?.cancel()
|
||||
hashTask = nil
|
||||
@@ -382,6 +382,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.success(false))
|
||||
}
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
|
||||
@@ -9,10 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -23,29 +23,29 @@ class LocalSyncService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
} else {
|
||||
@@ -373,7 +373,7 @@ class LocalSyncService {
|
||||
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||
@@ -381,15 +381,15 @@ class LocalSyncService {
|
||||
|
||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_log.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||
|
||||
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -34,8 +34,8 @@ class SyncStreamService {
|
||||
final SyncStreamRepository _syncStreamRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final SyncMigrationRepository _syncMigrationRepository;
|
||||
final ApiService _api;
|
||||
final bool Function()? _cancelChecker;
|
||||
@@ -45,8 +45,8 @@ class SyncStreamService {
|
||||
required SyncStreamRepository syncStreamRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required SyncMigrationRepository syncMigrationRepository,
|
||||
required ApiService api,
|
||||
bool Function()? cancelChecker,
|
||||
@@ -54,8 +54,8 @@ class SyncStreamService {
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_syncMigrationRepository = syncMigrationRepository,
|
||||
_api = api,
|
||||
_cancelChecker = cancelChecker;
|
||||
@@ -500,22 +500,22 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_logger.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
@@ -523,7 +523,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
@@ -533,7 +533,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ typedef TimelineBucketSource = Stream<List<Bucket>> Function();
|
||||
|
||||
typedef TimelineQuery = ({TimelineAssetSource assetSource, TimelineBucketSource bucketSource, TimelineOrigin origin});
|
||||
|
||||
enum TimelineStatus { uninitialized, ready, disposed }
|
||||
|
||||
enum TimelineOrigin {
|
||||
main,
|
||||
localAlbum,
|
||||
@@ -101,9 +103,13 @@ class TimelineService {
|
||||
int _bufferOffset = 0;
|
||||
List<BaseAsset> _buffer = [];
|
||||
StreamSubscription? _bucketSubscription;
|
||||
final StreamController<TimelineStatus> _statusController = StreamController<TimelineStatus>.broadcast();
|
||||
|
||||
int _totalAssets = 0;
|
||||
int get totalAssets => _totalAssets;
|
||||
TimelineStatus _status = TimelineStatus.uninitialized;
|
||||
TimelineStatus get status => _status;
|
||||
bool get isReady => _status == TimelineStatus.ready;
|
||||
|
||||
TimelineService(TimelineQuery query)
|
||||
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
|
||||
@@ -139,12 +145,17 @@ class TimelineService {
|
||||
|
||||
// change the state's total assets count only after the buffer is reloaded
|
||||
_totalAssets = totalAssets;
|
||||
if (_status == TimelineStatus.uninitialized) {
|
||||
_status = TimelineStatus.ready;
|
||||
_statusController.add(_status);
|
||||
}
|
||||
EventStream.shared.emit(const TimelineReloadEvent());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||
Stream<TimelineStatus> watchStatus() => _statusController.stream;
|
||||
|
||||
Future<List<BaseAsset>> loadAssets(int index, int count) => _mutex.run(() => _loadAssets(index, count));
|
||||
|
||||
@@ -247,5 +258,12 @@ class TimelineService {
|
||||
_bucketSubscription = null;
|
||||
_buffer = [];
|
||||
_bufferOffset = 0;
|
||||
if (_status != TimelineStatus.disposed) {
|
||||
_status = TimelineStatus.disposed;
|
||||
if (!_statusController.isClosed) {
|
||||
_statusController.add(_status);
|
||||
}
|
||||
}
|
||||
await _statusController.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,6 +678,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
List<Bucket> _generateBuckets(int count) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
@@ -128,6 +129,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
case AppLifecycleState.resumed:
|
||||
dPrint(() => "[APP STATE] resumed");
|
||||
ref.read(appStateProvider.notifier).handleAppResume();
|
||||
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
dPrint(() => "[APP STATE] inactive");
|
||||
@@ -233,6 +235,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
|
||||
}
|
||||
});
|
||||
|
||||
ref.read(viewIntentHandlerProvider).init();
|
||||
ref.read(shareIntentUploadProvider.notifier).init();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
extension ViewIntentPayloadX on ViewIntentPayload {
|
||||
String get fileName {
|
||||
final resolvedPath = path;
|
||||
if (resolvedPath != null && resolvedPath.isNotEmpty) {
|
||||
return basename(resolvedPath);
|
||||
}
|
||||
return localAssetId ?? 'view_intent_asset';
|
||||
}
|
||||
|
||||
bool get isImage => mimeType.toLowerCase().startsWith('image/');
|
||||
|
||||
bool get isVideo => mimeType.toLowerCase().startsWith('video/');
|
||||
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) {
|
||||
return AssetPlaybackStyle.video;
|
||||
}
|
||||
|
||||
final normalizedMimeType = mimeType.toLowerCase();
|
||||
if (normalizedMimeType == 'image/gif' || normalizedMimeType == 'image/webp') {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
|
||||
final normalizedPath = path?.toLowerCase();
|
||||
if (normalizedPath != null && (normalizedPath.endsWith('.gif') || normalizedPath.endsWith('.webp'))) {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
|
||||
return AssetPlaybackStyle.image;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/theme/color_scheme.dart';
|
||||
@@ -314,6 +315,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
final wsProvider = ref.read(websocketProvider.notifier);
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final backupProvider = ref.read(driftBackupProvider.notifier);
|
||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||
|
||||
unawaited(
|
||||
ref.read(authProvider.notifier).saveAuthInfo(accessToken: accessToken).then(
|
||||
@@ -328,6 +330,8 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
|
||||
backgroundManager.syncRemote().then((success) => syncSuccess = success),
|
||||
]);
|
||||
|
||||
await viewIntentHandler.flushDeferredViewIntent();
|
||||
|
||||
if (syncSuccess) {
|
||||
await Future.wait([
|
||||
backgroundManager.hashAssets().then((_) {
|
||||
|
||||
+183
-120
@@ -9,14 +9,22 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
Object? _extractReplyValueOrThrow(
|
||||
List<Object?>? replyList,
|
||||
String channelName, {
|
||||
required bool isNullValid,
|
||||
}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
throw PlatformException(
|
||||
code: replyList[0]! as String,
|
||||
message: replyList[1] as String?,
|
||||
details: replyList[2],
|
||||
);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
@@ -37,7 +45,9 @@ bool _deepEquals(Object? a, Object? b) {
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
@@ -86,7 +96,15 @@ int _deepHash(Object? value) {
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum PlatformAssetPlaybackStyle {
|
||||
unknown,
|
||||
image,
|
||||
video,
|
||||
imageAnimated,
|
||||
livePhoto,
|
||||
videoLooping,
|
||||
}
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
@@ -154,8 +172,7 @@ class PlatformAsset {
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static PlatformAsset decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -186,20 +203,7 @@ class PlatformAsset {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) &&
|
||||
_deepEquals(name, other.name) &&
|
||||
_deepEquals(type, other.type) &&
|
||||
_deepEquals(createdAt, other.createdAt) &&
|
||||
_deepEquals(updatedAt, other.updatedAt) &&
|
||||
_deepEquals(width, other.width) &&
|
||||
_deepEquals(height, other.height) &&
|
||||
_deepEquals(durationMs, other.durationMs) &&
|
||||
_deepEquals(orientation, other.orientation) &&
|
||||
_deepEquals(isFavorite, other.isFavorite) &&
|
||||
_deepEquals(adjustmentTime, other.adjustmentTime) &&
|
||||
_deepEquals(latitude, other.latitude) &&
|
||||
_deepEquals(longitude, other.longitude) &&
|
||||
_deepEquals(playbackStyle, other.playbackStyle);
|
||||
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(type, other.type) && _deepEquals(createdAt, other.createdAt) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(width, other.width) && _deepEquals(height, other.height) && _deepEquals(durationMs, other.durationMs) && _deepEquals(orientation, other.orientation) && _deepEquals(isFavorite, other.isFavorite) && _deepEquals(adjustmentTime, other.adjustmentTime) && _deepEquals(latitude, other.latitude) && _deepEquals(longitude, other.longitude) && _deepEquals(playbackStyle, other.playbackStyle);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -227,12 +231,17 @@ class PlatformAlbum {
|
||||
int assetCount;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[id, name, updatedAt, isCloud, assetCount];
|
||||
return <Object?>[
|
||||
id,
|
||||
name,
|
||||
updatedAt,
|
||||
isCloud,
|
||||
assetCount,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static PlatformAlbum decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -254,11 +263,7 @@ class PlatformAlbum {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(id, other.id) &&
|
||||
_deepEquals(name, other.name) &&
|
||||
_deepEquals(updatedAt, other.updatedAt) &&
|
||||
_deepEquals(isCloud, other.isCloud) &&
|
||||
_deepEquals(assetCount, other.assetCount);
|
||||
return _deepEquals(id, other.id) && _deepEquals(name, other.name) && _deepEquals(updatedAt, other.updatedAt) && _deepEquals(isCloud, other.isCloud) && _deepEquals(assetCount, other.assetCount);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -267,7 +272,12 @@ class PlatformAlbum {
|
||||
}
|
||||
|
||||
class SyncDelta {
|
||||
SyncDelta({required this.hasChanges, required this.updates, required this.deletes, required this.assetAlbums});
|
||||
SyncDelta({
|
||||
required this.hasChanges,
|
||||
required this.updates,
|
||||
required this.deletes,
|
||||
required this.assetAlbums,
|
||||
});
|
||||
|
||||
bool hasChanges;
|
||||
|
||||
@@ -278,12 +288,16 @@ class SyncDelta {
|
||||
Map<String, List<String>> assetAlbums;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[hasChanges, updates, deletes, assetAlbums];
|
||||
return <Object?>[
|
||||
hasChanges,
|
||||
updates,
|
||||
deletes,
|
||||
assetAlbums,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static SyncDelta decode(Object result) {
|
||||
result as List<Object?>;
|
||||
@@ -304,10 +318,7 @@ class SyncDelta {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(hasChanges, other.hasChanges) &&
|
||||
_deepEquals(updates, other.updates) &&
|
||||
_deepEquals(deletes, other.deletes) &&
|
||||
_deepEquals(assetAlbums, other.assetAlbums);
|
||||
return _deepEquals(hasChanges, other.hasChanges) && _deepEquals(updates, other.updates) && _deepEquals(deletes, other.deletes) && _deepEquals(assetAlbums, other.assetAlbums);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -316,7 +327,11 @@ class SyncDelta {
|
||||
}
|
||||
|
||||
class HashResult {
|
||||
HashResult({required this.assetId, this.error, this.hash});
|
||||
HashResult({
|
||||
required this.assetId,
|
||||
this.error,
|
||||
this.hash,
|
||||
});
|
||||
|
||||
String assetId;
|
||||
|
||||
@@ -325,16 +340,23 @@ class HashResult {
|
||||
String? hash;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[assetId, error, hash];
|
||||
return <Object?>[
|
||||
assetId,
|
||||
error,
|
||||
hash,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static HashResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return HashResult(assetId: result[0]! as String, error: result[1] as String?, hash: result[2] as String?);
|
||||
return HashResult(
|
||||
assetId: result[0]! as String,
|
||||
error: result[1] as String?,
|
||||
hash: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -355,7 +377,11 @@ class HashResult {
|
||||
}
|
||||
|
||||
class CloudIdResult {
|
||||
CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||
CloudIdResult({
|
||||
required this.assetId,
|
||||
this.error,
|
||||
this.cloudId,
|
||||
});
|
||||
|
||||
String assetId;
|
||||
|
||||
@@ -364,16 +390,23 @@ class CloudIdResult {
|
||||
String? cloudId;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[assetId, error, cloudId];
|
||||
return <Object?>[
|
||||
assetId,
|
||||
error,
|
||||
cloudId,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
return _toList(); }
|
||||
|
||||
static CloudIdResult decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return CloudIdResult(assetId: result[0]! as String, error: result[1] as String?, cloudId: result[2] as String?);
|
||||
return CloudIdResult(
|
||||
assetId: result[0]! as String,
|
||||
error: result[1] as String?,
|
||||
cloudId: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -385,9 +418,7 @@ class CloudIdResult {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(assetId, other.assetId) &&
|
||||
_deepEquals(error, other.error) &&
|
||||
_deepEquals(cloudId, other.cloudId);
|
||||
return _deepEquals(assetId, other.assetId) && _deepEquals(error, other.error) && _deepEquals(cloudId, other.cloudId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -395,6 +426,7 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -402,22 +434,22 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
@@ -452,8 +484,8 @@ class NativeSyncApi {
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
NativeSyncApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
@@ -461,8 +493,7 @@ class NativeSyncApi {
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<bool> shouldFullSync() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.shouldFullSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -472,16 +503,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<SyncDelta> getMediaChanges() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -491,16 +522,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as SyncDelta;
|
||||
}
|
||||
|
||||
Future<void> checkpointSync() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.checkpointSync$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -509,12 +540,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<void> clearSyncCheckpoint() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.clearSyncCheckpoint$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -523,12 +558,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<List<String>> getAssetIdsForAlbum(String albumId) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetIdsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -538,16 +577,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
|
||||
}
|
||||
|
||||
Future<List<PlatformAlbum>> getAlbums() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -557,16 +596,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAlbum>();
|
||||
}
|
||||
|
||||
Future<int> getAssetsCountSince(String albumId, int timestamp) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsCountSince$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -576,16 +615,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as int;
|
||||
}
|
||||
|
||||
Future<List<PlatformAsset>> getAssetsForAlbum(String albumId, {int? updatedTimeCond}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAssetsForAlbum$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -595,16 +634,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<PlatformAsset>();
|
||||
}
|
||||
|
||||
Future<List<HashResult>> hashAssets(List<String> assetIds, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.hashAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -614,16 +653,16 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<HashResult>();
|
||||
}
|
||||
|
||||
Future<void> cancelHashing() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.cancelHashing$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -632,12 +671,16 @@ class NativeSyncApi {
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
|
||||
_extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
Future<Map<String, List<PlatformAsset>>> getTrashedAssets() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getTrashedAssets$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -647,16 +690,35 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
@@ -666,10 +728,11 @@ class NativeSyncApi {
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
)
|
||||
;
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||
}
|
||||
}
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionApi {
|
||||
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (identical(a, b)) {
|
||||
return true;
|
||||
}
|
||||
if (a is double && b is double) {
|
||||
if (a.isNaN && b.isNaN) {
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length && a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
for (final MapEntry<Object?, Object?> entryA in a.entries) {
|
||||
bool found = false;
|
||||
for (final MapEntry<Object?, Object?> entryB in b.entries) {
|
||||
if (_deepEquals(entryA.key, entryB.key)) {
|
||||
if (_deepEquals(entryA.value, entryB.value)) {
|
||||
found = true;
|
||||
break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
int _deepHash(Object? value) {
|
||||
if (value is List) {
|
||||
return Object.hashAll(value.map(_deepHash));
|
||||
}
|
||||
if (value is Map) {
|
||||
int result = 0;
|
||||
for (final MapEntry<Object?, Object?> entry in value.entries) {
|
||||
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value is double && value.isNaN) {
|
||||
// Normalize NaN to a consistent hash.
|
||||
return 0x7FF8000000000000.hashCode;
|
||||
}
|
||||
if (value is double && value == 0.0) {
|
||||
// Normalize -0.0 to 0.0 so they have the same hash code.
|
||||
return 0.0.hashCode;
|
||||
}
|
||||
return value.hashCode;
|
||||
}
|
||||
|
||||
class ViewIntentPayload {
|
||||
ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
|
||||
|
||||
String? path;
|
||||
|
||||
String mimeType;
|
||||
|
||||
String? localAssetId;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[path, mimeType, localAssetId];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static ViewIntentPayload decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return ViewIntentPayload(
|
||||
path: result[0] as String?,
|
||||
mimeType: result[1]! as String,
|
||||
localAssetId: result[2] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ViewIntentPayload || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(path, other.path) &&
|
||||
_deepEquals(mimeType, other.mimeType) &&
|
||||
_deepEquals(localAssetId, other.localAssetId);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is ViewIntentPayload) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
return ViewIntentPayload.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewIntentHostApi {
|
||||
/// Constructor for [ViewIntentHostApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
ViewIntentHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<ViewIntentPayload?> consumeViewIntent() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.ViewIntentHostApi.consumeViewIntent$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as ViewIntentPayload?;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class MainTimelinePage extends ConsumerWidget {
|
||||
class MainTimelinePage extends HookConsumerWidget {
|
||||
const MainTimelinePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
useEffect(() {
|
||||
unawaited(Future<void>(() => ref.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce()));
|
||||
return null;
|
||||
}, const []);
|
||||
|
||||
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
|
||||
return Timeline(
|
||||
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
@@ -7,9 +8,11 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
@@ -26,7 +29,11 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final isTimeline = source == ActionSource.timeline;
|
||||
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
|
||||
List<LocalAsset>? assets;
|
||||
var isUploadDialogOpen = false;
|
||||
var wasUploadCancelled = false;
|
||||
Future<void>? uploadDialogFuture;
|
||||
|
||||
if (source == ActionSource.timeline) {
|
||||
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
||||
@@ -35,22 +42,44 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
} else {
|
||||
unawaited(
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => const _UploadProgressDialog(),
|
||||
),
|
||||
);
|
||||
isUploadDialogOpen = true;
|
||||
uploadDialogFuture =
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => _UploadProgressDialog(
|
||||
onCancel: () {
|
||||
wasUploadCancelled = true;
|
||||
},
|
||||
),
|
||||
).whenComplete(() {
|
||||
isUploadDialogOpen = false;
|
||||
});
|
||||
unawaited(uploadDialogFuture);
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
var success = false;
|
||||
if (!isTimeline && viewerIntentFilePath != null) {
|
||||
var hasError = false;
|
||||
await ref
|
||||
.read(foregroundUploadServiceProvider)
|
||||
.uploadShareIntent(
|
||||
[File(viewerIntentFilePath)],
|
||||
onError: (fileId, errorMessage) {
|
||||
hasError = true;
|
||||
},
|
||||
);
|
||||
success = !hasError;
|
||||
} else {
|
||||
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
||||
success = result.success;
|
||||
}
|
||||
|
||||
if (!isTimeline && context.mounted) {
|
||||
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
|
||||
if (context.mounted && !result.success) {
|
||||
if (context.mounted && !success && !wasUploadCancelled) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
@@ -73,7 +102,9 @@ class UploadActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _UploadProgressDialog extends ConsumerWidget {
|
||||
const _UploadProgressDialog();
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _UploadProgressDialog({required this.onCancel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -103,7 +134,8 @@ class _UploadProgressDialog extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
ref.read(manualUploadCancelTokenProvider)?.complete();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
||||
Navigator.of(context).pop();
|
||||
onCancel();
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
labelText: 'cancel'.t(context: context),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
@@ -323,14 +325,18 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
required PhotoViewHeroAttributes? heroAttributes,
|
||||
required bool isCurrent,
|
||||
required bool isPlayingMotionVideo,
|
||||
required String? localFilePath,
|
||||
}) {
|
||||
final size = context.sizeData;
|
||||
final imageProvider = localFilePath != null
|
||||
? FileImage(File(localFilePath))
|
||||
: getFullImageProvider(asset, size: size);
|
||||
|
||||
if (asset.isImage && !isPlayingMotionVideo) {
|
||||
return PhotoView(
|
||||
key: Key(asset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
imageProvider: imageProvider,
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
gaplessPlayback: true,
|
||||
@@ -377,12 +383,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
localFilePath: localFilePath,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(
|
||||
image: getFullImageProvider(asset, size: size),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -393,6 +396,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||
|
||||
final asset = _asset;
|
||||
if (asset == null) {
|
||||
@@ -421,6 +425,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_scrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
final viewIntentFilePath = timelineOrigin == TimelineOrigin.deepLink ? ref.watch(viewIntentFilePathProvider) : null;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
@@ -440,6 +446,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
: null,
|
||||
isCurrent: isCurrent,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
localFilePath: viewIntentFilePath,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
|
||||
@@ -64,18 +64,7 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
ConsumerState createState() => _AssetViewerState();
|
||||
|
||||
static void setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
ref.read(assetViewerProvider.notifier).reset();
|
||||
|
||||
// Hide controls by default for videos
|
||||
if (asset.isVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
|
||||
_setAsset(ref, asset);
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
prepareAssetViewerState(ref.read(assetViewerProvider.notifier), asset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +78,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
bool _disposeStarted = false;
|
||||
|
||||
void _onTapNavigate(int direction) {
|
||||
final page = _pageController.page?.toInt();
|
||||
@@ -123,6 +113,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeStarted = true;
|
||||
_pageController.dispose();
|
||||
_preloader.dispose();
|
||||
_reloadSubscription?.cancel();
|
||||
@@ -160,14 +151,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
void _onAssetChanged(int index) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_currentPage = index;
|
||||
|
||||
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
|
||||
if (asset == null) {
|
||||
if (!mounted || asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
AssetViewer._setAsset(ref, asset);
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
_preloader.preload(index, context.sizeData);
|
||||
_handleCasting();
|
||||
_stackChildrenKeepAlive?.close();
|
||||
@@ -203,6 +197,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
void _onEvent(Event event) {
|
||||
if (!mounted || _disposeStarted) {
|
||||
return;
|
||||
}
|
||||
switch (event) {
|
||||
case TimelineReloadEvent():
|
||||
_onTimelineReloadEvent();
|
||||
@@ -226,13 +223,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
void _onTimelineReloadEvent() {
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final totalAssets = timelineService.totalAssets;
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
final isViewerTransitionInProgress = ref.read(
|
||||
assetViewerProvider.select((value) => value.isViewerTransitionInProgress),
|
||||
);
|
||||
|
||||
if (isViewerTransitionInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalAssets == 0) {
|
||||
context.maybePop();
|
||||
return;
|
||||
}
|
||||
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
|
||||
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -19,6 +20,7 @@ import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final String? localFilePath;
|
||||
final bool isCurrent;
|
||||
final bool showControls;
|
||||
final Widget image;
|
||||
@@ -26,6 +28,7 @@ class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.localFilePath,
|
||||
required this.image,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
@@ -106,6 +109,19 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
}
|
||||
|
||||
try {
|
||||
final localFilePath = widget.localFilePath;
|
||||
if (localFilePath != null) {
|
||||
final file = File(localFilePath);
|
||||
if (!await file.exists()) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
|
||||
return VideoSource.init(
|
||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
}
|
||||
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
|
||||
@@ -10,6 +10,7 @@ class AssetViewerState {
|
||||
final bool isZoomed;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
final bool isViewerTransitionInProgress;
|
||||
|
||||
const AssetViewerState({
|
||||
this.backgroundOpacity = 1.0,
|
||||
@@ -18,6 +19,7 @@ class AssetViewerState {
|
||||
this.isZoomed = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
this.isViewerTransitionInProgress = false,
|
||||
});
|
||||
|
||||
AssetViewerState copyWith({
|
||||
@@ -27,6 +29,7 @@ class AssetViewerState {
|
||||
bool? isZoomed,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
bool? isViewerTransitionInProgress,
|
||||
}) {
|
||||
return AssetViewerState(
|
||||
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||
@@ -35,6 +38,7 @@ class AssetViewerState {
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
isViewerTransitionInProgress: isViewerTransitionInProgress ?? this.isViewerTransitionInProgress,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +61,8 @@ class AssetViewerState {
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
other.stackIndex == stackIndex &&
|
||||
other.isViewerTransitionInProgress == isViewerTransitionInProgress;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -67,7 +72,8 @@ class AssetViewerState {
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
stackIndex.hashCode ^
|
||||
isViewerTransitionInProgress.hashCode;
|
||||
}
|
||||
|
||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
@@ -137,10 +143,28 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
|
||||
void setViewerTransitionInProgress(bool isInProgress) {
|
||||
if (isInProgress == state.isViewerTransitionInProgress) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(isViewerTransitionInProgress: isInProgress);
|
||||
}
|
||||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
void prepareAssetViewerState(AssetViewerStateNotifier notifier, BaseAsset asset) {
|
||||
notifier.reset();
|
||||
|
||||
// Hide controls by default for videos before the viewer is shown.
|
||||
if (asset.isVideo) {
|
||||
notifier.setControls(false);
|
||||
}
|
||||
|
||||
notifier.setAsset(asset);
|
||||
}
|
||||
|
||||
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
((ref) => ShareIntentUploadStateNotifier(
|
||||
ref.watch(appRouterProvider),
|
||||
ref.read(foregroundUploadServiceProvider),
|
||||
ref.read(shareIntentServiceProvider),
|
||||
)),
|
||||
);
|
||||
|
||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||
final AppRouter router;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
void init() {
|
||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||
_shareIntentService.init();
|
||||
}
|
||||
|
||||
void onSharedMedia(List<ShareIntentAttachment> attachments) {
|
||||
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
||||
clearAttachments();
|
||||
addAttachments(attachments);
|
||||
router.push(ShareIntentRoute(attachments: attachments));
|
||||
}
|
||||
|
||||
void addAttachments(List<ShareIntentAttachment> attachments) {
|
||||
if (attachments.isEmpty) {
|
||||
return;
|
||||
}
|
||||
state = [...state, ...attachments];
|
||||
}
|
||||
|
||||
void removeAttachment(ShareIntentAttachment attachment) {
|
||||
final updatedState = state.where((element) => element != attachment).toList();
|
||||
if (updatedState.length != state.length) {
|
||||
state = updatedState;
|
||||
}
|
||||
}
|
||||
|
||||
void clearAttachments() {
|
||||
if (state.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = [];
|
||||
}
|
||||
|
||||
Future<void> uploadAll(List<File> files) async {
|
||||
for (final file in files) {
|
||||
final fileId = p.hash(file.path).toString();
|
||||
_updateStatus(fileId, UploadStatus.running);
|
||||
}
|
||||
|
||||
await _foregroundUploadService.uploadShareIntent(
|
||||
files,
|
||||
onProgress: (fileId, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
_updateProgress(fileId, progress);
|
||||
},
|
||||
onSuccess: (fileId) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id)
|
||||
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
||||
else
|
||||
attachment,
|
||||
];
|
||||
}
|
||||
|
||||
void _updateProgress(String fileId, double progress) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
||||
];
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
((ref) => ShareIntentUploadStateNotifier(
|
||||
ref.watch(appRouterProvider),
|
||||
ref.read(foregroundUploadServiceProvider),
|
||||
ref.read(shareIntentServiceProvider),
|
||||
)),
|
||||
);
|
||||
|
||||
class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttachment>> {
|
||||
final AppRouter router;
|
||||
final ForegroundUploadService _foregroundUploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._foregroundUploadService, this._shareIntentService) : super([]);
|
||||
|
||||
void init() {
|
||||
_shareIntentService.onSharedMedia = onSharedMedia;
|
||||
_shareIntentService.init();
|
||||
}
|
||||
|
||||
void onSharedMedia(List<ShareIntentAttachment> attachments) {
|
||||
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
||||
clearAttachments();
|
||||
addAttachments(attachments);
|
||||
router.push(ShareIntentRoute(attachments: attachments));
|
||||
}
|
||||
|
||||
void addAttachments(List<ShareIntentAttachment> attachments) {
|
||||
if (attachments.isEmpty) {
|
||||
return;
|
||||
}
|
||||
state = [...state, ...attachments];
|
||||
}
|
||||
|
||||
void removeAttachment(ShareIntentAttachment attachment) {
|
||||
final updatedState = state.where((element) => element != attachment).toList();
|
||||
if (updatedState.length != state.length) {
|
||||
state = updatedState;
|
||||
}
|
||||
}
|
||||
|
||||
void clearAttachments() {
|
||||
if (state.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = [];
|
||||
}
|
||||
|
||||
Future<void> uploadAll(List<File> files) async {
|
||||
for (final file in files) {
|
||||
final fileId = p.hash(file.path).toString();
|
||||
_updateStatus(fileId, UploadStatus.running);
|
||||
}
|
||||
|
||||
await _foregroundUploadService.uploadShareIntent(
|
||||
files,
|
||||
onProgress: (fileId, bytes, totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
_updateProgress(fileId, progress);
|
||||
},
|
||||
onSuccess: (fileId, _) {
|
||||
_updateStatus(fileId, UploadStatus.complete, progress: 1.0);
|
||||
},
|
||||
onError: (fileId, errorMessage) {
|
||||
_logger.warning("Upload failed for file: $fileId, error: $errorMessage");
|
||||
_updateStatus(fileId, UploadStatus.failed);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _updateStatus(String fileId, UploadStatus status, {double? progress}) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id)
|
||||
attachment.copyWith(status: status, uploadProgress: progress ?? attachment.uploadProgress)
|
||||
else
|
||||
attachment,
|
||||
];
|
||||
}
|
||||
|
||||
void _updateProgress(String fileId, double progress) {
|
||||
final id = int.parse(fileId);
|
||||
state = [
|
||||
for (final attachment in state)
|
||||
if (attachment.id == id) attachment.copyWith(uploadProgress: progress) else attachment,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,12 @@ class ActionResult {
|
||||
final int count;
|
||||
final bool success;
|
||||
final String? error;
|
||||
final List<String> remoteAssetIds;
|
||||
|
||||
const ActionResult({required this.count, required this.success, this.error});
|
||||
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
|
||||
|
||||
@override
|
||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error)';
|
||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
|
||||
}
|
||||
|
||||
class ActionNotifier extends Notifier<void> {
|
||||
@@ -489,10 +490,14 @@ class ActionNotifier extends Notifier<void> {
|
||||
|
||||
Future<ActionResult> upload(ActionSource source, {List<LocalAsset>? assets}) async {
|
||||
final assetsToUpload = assets ?? _getAssets(source).whereType<LocalAsset>().toList();
|
||||
if (assetsToUpload.isEmpty) {
|
||||
return const ActionResult(count: 0, success: false, error: 'No assets to upload');
|
||||
}
|
||||
|
||||
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
|
||||
final cancelToken = Completer<void>();
|
||||
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
|
||||
final remoteAssetIds = <String>[];
|
||||
|
||||
// Initialize progress for all assets
|
||||
for (final asset in assetsToUpload) {
|
||||
@@ -509,6 +514,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
progressNotifier.setProgress(localAssetId, progress);
|
||||
},
|
||||
onSuccess: (localAssetId, remoteAssetId) {
|
||||
remoteAssetIds.add(remoteAssetId);
|
||||
progressNotifier.remove(localAssetId);
|
||||
},
|
||||
onError: (localAssetId, errorMessage) {
|
||||
@@ -516,7 +522,14 @@ class ActionNotifier extends Notifier<void> {
|
||||
},
|
||||
),
|
||||
);
|
||||
return ActionResult(count: assetsToUpload.length, success: true);
|
||||
final uploadedCount = remoteAssetIds.length;
|
||||
final success = uploadedCount == assetsToUpload.length;
|
||||
return ActionResult(
|
||||
count: assetsToUpload.length,
|
||||
success: success,
|
||||
error: success ? null : 'Uploaded $uploadedCount/${assetsToUpload.length} assets successfully',
|
||||
remoteAssetIds: remoteAssetIds,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed manually upload assets', error, stack);
|
||||
return ActionResult(count: assetsToUpload.length, success: false, error: error.toString());
|
||||
|
||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
@@ -16,6 +17,8 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
|
||||
|
||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final localImageApi = LocalImageApi();
|
||||
|
||||
@@ -11,8 +11,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||
|
||||
@@ -22,8 +22,8 @@ final syncStreamServiceProvider = Provider(
|
||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||
api: ref.watch(apiServiceProvider),
|
||||
cancelChecker: ref.watch(cancellationProvider),
|
||||
@@ -39,8 +39,8 @@ final localSyncServiceProvider = Provider(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -41,3 +41,23 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
|
||||
|
||||
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
|
||||
});
|
||||
|
||||
final timelineStatusProvider = StreamProvider.autoDispose.family<TimelineStatus, TimelineService>((
|
||||
ref,
|
||||
timelineService,
|
||||
) async* {
|
||||
yield timelineService.status;
|
||||
yield* timelineService.watchStatus();
|
||||
});
|
||||
|
||||
Future<void> waitForTimelineReady(TimelineService timelineService, Duration timeout) {
|
||||
if (timelineService.isReady) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return timelineService
|
||||
.watchStatus()
|
||||
.firstWhere((status) => status == TimelineStatus.ready)
|
||||
.timeout(timeout)
|
||||
.then((_) {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class ViewIntentFilePathNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() => null;
|
||||
|
||||
void setPath(String path) {
|
||||
if (state == path) {
|
||||
return;
|
||||
}
|
||||
state = path;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
|
||||
void clearIfMatch(String path) {
|
||||
if (state != path) {
|
||||
return;
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
final viewIntentFilePathProvider = NotifierProvider<ViewIntentFilePathNotifier, String?>(
|
||||
ViewIntentFilePathNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'dart:io';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_stub.dart';
|
||||
|
||||
abstract class ViewIntentHandler {
|
||||
void init();
|
||||
|
||||
Future<void> onAppResumed();
|
||||
|
||||
Future<void> flushDeferredViewIntent();
|
||||
|
||||
Future<void> handle(ViewIntentPayload attachment);
|
||||
}
|
||||
|
||||
final viewIntentHandlerProvider = Provider<ViewIntentHandler>((ref) {
|
||||
if (Platform.isAndroid) {
|
||||
return AndroidViewIntentHandler(ref);
|
||||
}
|
||||
|
||||
return const StubViewIntentHandler();
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class AndroidViewIntentHandler implements ViewIntentHandler {
|
||||
final Ref _ref;
|
||||
final ViewIntentService _viewIntentService;
|
||||
final ViewIntentAssetResolver _viewIntentAssetResolver;
|
||||
final AppRouter _router;
|
||||
static final Logger _logger = Logger('ViewIntentHandler');
|
||||
|
||||
AndroidViewIntentHandler(Ref ref)
|
||||
: _ref = ref,
|
||||
_viewIntentService = ref.read(viewIntentServiceProvider),
|
||||
_viewIntentAssetResolver = ref.read(viewIntentAssetResolverProvider),
|
||||
_router = ref.watch(appRouterProvider);
|
||||
|
||||
@override
|
||||
void init() {
|
||||
// Covers cold start from a view intent before the first lifecycle "resumed".
|
||||
unawaited(onAppResumed());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onAppResumed() => _checkForViewIntent();
|
||||
|
||||
@override
|
||||
Future<void> flushDeferredViewIntent() => _flushPending();
|
||||
|
||||
Future<void> _checkForViewIntent() async {
|
||||
final attachment = await _viewIntentService.consumeViewIntent();
|
||||
if (attachment != null) {
|
||||
await handle(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ref.read(viewIntentPendingProvider) == null) {
|
||||
await _viewIntentService.cleanupStaleTempFiles();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _flushPending() async {
|
||||
if (_ref.read(viewIntentPendingProvider) == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _ref.read(viewIntentMainTimelineReadyProvider.notifier).wait(timeout: const Duration(seconds: 3));
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingAttachment = _ref.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
_logger.info('flushPending, pendingAttachment:$pendingAttachment}');
|
||||
if (pendingAttachment != null) {
|
||||
await handle(pendingAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handle(ViewIntentPayload attachment) async {
|
||||
_logger.info(
|
||||
'handle attachment, mimeType:${attachment.mimeType}, localAssetId=${attachment.localAssetId}, path=${attachment.path}, isAuthenticated:${_ref.read(authProvider).isAuthenticated}',
|
||||
);
|
||||
|
||||
if (!_ref.read(authProvider).isAuthenticated) {
|
||||
_ref.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedAsset = await _viewIntentAssetResolver.resolve(attachment);
|
||||
_logger.fine('resolved view intent asset: ${resolvedAsset.asset}');
|
||||
await _openAssetViewer(
|
||||
resolvedAsset.asset,
|
||||
resolvedAsset.timelineService,
|
||||
viewIntentFilePath: resolvedAsset.viewIntentFilePath,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openAssetViewer(BaseAsset asset, TimelineService timelineService, {String? viewIntentFilePath}) async {
|
||||
final notifier = _ref.read(assetViewerProvider.notifier);
|
||||
notifier.setViewerTransitionInProgress(true);
|
||||
try {
|
||||
prepareAssetViewerState(notifier, asset);
|
||||
if (viewIntentFilePath != null) {
|
||||
_ref.read(viewIntentFilePathProvider.notifier).setPath(viewIntentFilePath);
|
||||
unawaited(_viewIntentService.setManagedTempFilePath(viewIntentFilePath));
|
||||
} else {
|
||||
_ref.read(viewIntentFilePathProvider.notifier).clear();
|
||||
unawaited(_viewIntentService.cleanupManagedTempFile());
|
||||
}
|
||||
|
||||
// Mirror the home-screen widget pattern: replace the route stack so
|
||||
// the viewer sits directly on top of the main timeline. Back-press
|
||||
// from the viewer lands the user on the timeline rather than on
|
||||
// whatever route happened to be current (e.g. splash, login).
|
||||
await _router.replaceAll([
|
||||
const TabShellRoute(),
|
||||
AssetViewerRoute(initialIndex: 0, timelineService: timelineService),
|
||||
]);
|
||||
} finally {
|
||||
notifier.setViewerTransitionInProgress(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
|
||||
class StubViewIntentHandler implements ViewIntentHandler {
|
||||
const StubViewIntentHandler();
|
||||
|
||||
@override
|
||||
void init() {}
|
||||
|
||||
@override
|
||||
Future<void> onAppResumed() async {}
|
||||
|
||||
@override
|
||||
Future<void> flushDeferredViewIntent() async {}
|
||||
|
||||
@override
|
||||
Future<void> handle(ViewIntentPayload attachment) async {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
final viewIntentMainTimelineReadyProvider = NotifierProvider<ViewIntentMainTimelineReadyNotifier, bool>(
|
||||
ViewIntentMainTimelineReadyNotifier.new,
|
||||
);
|
||||
|
||||
class ViewIntentMainTimelineReadyNotifier extends Notifier<bool> {
|
||||
Completer<void>? _readyCompleter;
|
||||
bool _hasSeenMainTimeline = false;
|
||||
bool _hasTimelineUsers = false;
|
||||
bool _isTimelineReady = false;
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
_readyCompleter ??= Completer<void>();
|
||||
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull;
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
final timelineStatus = ref.watch(timelineStatusProvider(timelineService)).valueOrNull ?? timelineService.status;
|
||||
|
||||
_hasTimelineUsers = timelineUsers != null && timelineUsers.isNotEmpty;
|
||||
_isTimelineReady = timelineStatus == TimelineStatus.ready;
|
||||
|
||||
final isReady = _computeReady();
|
||||
_completeWaitersIfReady(isReady);
|
||||
return isReady;
|
||||
}
|
||||
|
||||
Future<void> wait({required Duration timeout}) {
|
||||
if (state) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return _readyCompleter!.future.timeout(timeout);
|
||||
}
|
||||
|
||||
void markMountedOnce() {
|
||||
_hasSeenMainTimeline = true;
|
||||
final isReady = _computeReady();
|
||||
state = isReady;
|
||||
_completeWaitersIfReady(isReady);
|
||||
}
|
||||
|
||||
bool _computeReady() => _hasSeenMainTimeline && _hasTimelineUsers && _isTimelineReady;
|
||||
|
||||
void _completeWaitersIfReady(bool isReady) {
|
||||
if (isReady) {
|
||||
if (!(_readyCompleter?.isCompleted ?? true)) {
|
||||
_readyCompleter?.complete();
|
||||
}
|
||||
} else if (_readyCompleter?.isCompleted ?? true) {
|
||||
_readyCompleter = Completer<void>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
|
||||
final viewIntentNowProvider = Provider<DateTime Function()>((ref) => DateTime.now);
|
||||
|
||||
final viewIntentPendingProvider = NotifierProvider<ViewIntentPendingNotifier, ViewIntentPayload?>(
|
||||
ViewIntentPendingNotifier.new,
|
||||
);
|
||||
|
||||
class ViewIntentPendingNotifier extends Notifier<ViewIntentPayload?> {
|
||||
static const _ttl = Duration(minutes: 10);
|
||||
|
||||
DateTime? _deferredAt;
|
||||
|
||||
@override
|
||||
ViewIntentPayload? build() => null;
|
||||
|
||||
void defer(ViewIntentPayload attachment) {
|
||||
_deferredAt = ref.read(viewIntentNowProvider)();
|
||||
state = attachment;
|
||||
}
|
||||
|
||||
ViewIntentPayload? takeIfFresh() {
|
||||
final attachment = state;
|
||||
final deferredAt = _deferredAt;
|
||||
state = null;
|
||||
_deferredAt = null;
|
||||
|
||||
if (attachment == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deferredAt != null && ref.read(viewIntentNowProvider)().difference(deferredAt) > _ttl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._assetApiRepository);
|
||||
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -45,6 +50,27 @@ class AssetMediaRepository {
|
||||
return PhotoManager.editor.deleteWithIds(ids);
|
||||
}
|
||||
|
||||
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
|
||||
try {
|
||||
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
|
||||
} catch (e, s) {
|
||||
_log.warning('Error restore file from trash by Id', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
|
||||
final result = await _restoreFromTrashById(asset.id, asset.type.index);
|
||||
if (result) {
|
||||
restoredIds.add(asset.id);
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
|
||||
Future<AssetEntity?> get(String id) async {
|
||||
final entity = await AssetEntity.fromId(id);
|
||||
return entity;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/services/local_files_manager.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localFilesManagerRepositoryProvider = Provider(
|
||||
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
||||
);
|
||||
|
||||
class LocalFilesManagerRepository {
|
||||
LocalFilesManagerRepository(this._service);
|
||||
|
||||
final Logger _logger = Logger('LocalFilesManagerRepo');
|
||||
final LocalFilesManagerService _service;
|
||||
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
return await _service.moveToTrash(mediaUrls);
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
return await _service.restoreFromTrash(fileName, type);
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
return await _service.requestManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
return await _service.hasManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
return await _service.manageMediaPermission();
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
|
||||
try {
|
||||
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
|
||||
if (result) {
|
||||
restoredIds.add(asset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning("Restoring failure: $e");
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
final permissionRepositoryProvider = Provider((_) {
|
||||
return const PermissionRepository();
|
||||
final permissionRepositoryProvider = Provider((ref) {
|
||||
return PermissionRepository(ref.watch(permissionApiProvider));
|
||||
});
|
||||
|
||||
class PermissionRepository implements IPermissionRepository {
|
||||
const PermissionRepository();
|
||||
final PermissionApi _permissionApi;
|
||||
|
||||
const PermissionRepository(this._permissionApi);
|
||||
|
||||
@override
|
||||
Future<bool> hasLocationWhenInUsePermission() {
|
||||
@@ -34,6 +38,21 @@ class PermissionRepository implements IPermissionRepository {
|
||||
Future<bool> openSettings() {
|
||||
return openAppSettings();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasManageMediaPermission() {
|
||||
return _permissionApi.hasManageMediaPermission();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requestManageMediaPermission() {
|
||||
return _permissionApi.requestManageMediaPermission();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> manageMediaPermission() {
|
||||
return _permissionApi.manageMediaPermission();
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class IPermissionRepository {
|
||||
@@ -42,4 +61,7 @@ abstract interface class IPermissionRepository {
|
||||
Future<bool> hasLocationAlwaysPermission();
|
||||
Future<bool> requestLocationAlwaysPermission();
|
||||
Future<bool> openSettings();
|
||||
Future<bool> hasManageMediaPermission();
|
||||
Future<bool> requestManageMediaPermission();
|
||||
Future<bool> manageMediaPermission();
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ class ForegroundUploadService {
|
||||
List<File> files, {
|
||||
Completer<void>? cancelToken,
|
||||
void Function(String fileId, int bytes, int totalBytes)? onProgress,
|
||||
void Function(String fileId)? onSuccess,
|
||||
void Function(String fileId, String remoteAssetId)? onSuccess,
|
||||
void Function(String fileId, String errorMessage)? onError,
|
||||
}) async {
|
||||
if (files.isEmpty) {
|
||||
@@ -171,7 +171,7 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess) {
|
||||
onSuccess?.call(fileId);
|
||||
onSuccess?.call(fileId, result.remoteAssetId!);
|
||||
} else if (!result.isCancelled && result.errorMessage != null) {
|
||||
onError?.call(fileId, result.errorMessage!);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
|
||||
|
||||
class LocalFilesManagerService {
|
||||
const LocalFilesManagerService();
|
||||
|
||||
static final Logger _logger = Logger('LocalFilesManager');
|
||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
try {
|
||||
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error moving file to trash', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
try {
|
||||
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error restore file from trash', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
try {
|
||||
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error restore file from trash by Id', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('hasManageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission state', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('manageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission settings', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
final viewIntentServiceProvider = Provider((ref) => ViewIntentService(ViewIntentHostApi()));
|
||||
|
||||
class ViewIntentService {
|
||||
final ViewIntentHostApi _viewIntentHostApi;
|
||||
final Future<Directory> Function() _temporaryDirectory;
|
||||
String? _managedTempFilePath;
|
||||
|
||||
ViewIntentService(this._viewIntentHostApi, {Future<Directory> Function()? temporaryDirectory})
|
||||
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
|
||||
|
||||
Future<ViewIntentPayload?> consumeViewIntent() async {
|
||||
try {
|
||||
return await _viewIntentHostApi.consumeViewIntent();
|
||||
} catch (_) {
|
||||
// Ignore errors - view intent might not be present
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setManagedTempFilePath(String path) async {
|
||||
final previous = _managedTempFilePath;
|
||||
if (previous == path) {
|
||||
return;
|
||||
}
|
||||
_managedTempFilePath = path;
|
||||
if (previous != null) {
|
||||
await cleanupTempFile(previous);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cleanupManagedTempFile() async {
|
||||
final path = _managedTempFilePath;
|
||||
_managedTempFilePath = null;
|
||||
if (path != null) {
|
||||
await cleanupTempFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cleanupManagedTempFileIfCurrent(String path) async {
|
||||
if (_managedTempFilePath == path) {
|
||||
_managedTempFilePath = null;
|
||||
}
|
||||
await cleanupTempFile(path);
|
||||
}
|
||||
|
||||
Future<void> cleanupTempFile(String path) async {
|
||||
if (!_isManagedTempFile(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cleanupStaleTempFiles() async {
|
||||
try {
|
||||
final tempDirectory = await _temporaryDirectory();
|
||||
await for (final entity in tempDirectory.list()) {
|
||||
if (entity is! File) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final path = entity.path;
|
||||
if (!_isManagedTempFile(path) || path == _managedTempFilePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await entity.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
bool _isManagedTempFile(String path) {
|
||||
return p.basename(path).startsWith('view_intent_') && p.basename(p.dirname(path)) == 'cache';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class ViewIntentResolvedAsset {
|
||||
final BaseAsset asset;
|
||||
final TimelineService timelineService;
|
||||
|
||||
/// Path to the materialized temp file backing this asset, if any. Set only
|
||||
/// for the transient deep-link case (no DB-backed local asset). The upload
|
||||
/// flow reads this to know which file to upload.
|
||||
final String? viewIntentFilePath;
|
||||
|
||||
const ViewIntentResolvedAsset({required this.asset, required this.timelineService, this.viewIntentFilePath});
|
||||
}
|
||||
|
||||
final viewIntentAssetResolverProvider = Provider<ViewIntentAssetResolver>(
|
||||
(ref) => ViewIntentAssetResolver(
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
timelineFactory: ref.read(timelineFactoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
/// Resolves an incoming ACTION_VIEW intent into the data the asset viewer
|
||||
/// needs: a [BaseAsset] and a [TimelineService] containing it.
|
||||
///
|
||||
/// Always wraps the resolved asset in a 1-element [TimelineOrigin.deepLink]
|
||||
/// timeline — mirroring how the app's home-screen widgets open a single
|
||||
/// asset. We don't try to map the asset to its position in the user's main
|
||||
/// timeline because that would require ROW_NUMBER queries over the full
|
||||
/// merged timeline (slow at scale) and complex "wait until the main timeline
|
||||
/// service is ready at that index" coordination. Back-navigation from the
|
||||
/// viewer lands on the main timeline because the handler pushes the viewer
|
||||
/// on top of [TabShellRoute].
|
||||
class ViewIntentAssetResolver {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final TimelineFactory _timelineFactory;
|
||||
static final Logger _logger = Logger('ViewIntentAssetResolver');
|
||||
|
||||
const ViewIntentAssetResolver({
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required TimelineFactory timelineFactory,
|
||||
}) : _localAssetRepository = localAssetRepository,
|
||||
_timelineFactory = timelineFactory;
|
||||
|
||||
Future<ViewIntentResolvedAsset> resolve(ViewIntentPayload attachment) async {
|
||||
final localAssetId = attachment.localAssetId;
|
||||
final path = attachment.path;
|
||||
_logger.fine('resolve start, localAssetId=$localAssetId, path=$path, mimeType=${attachment.mimeType}');
|
||||
|
||||
if (localAssetId == null && path == null) {
|
||||
throw StateError('ViewIntent resolution requires either a localAssetId or a materialized file path.');
|
||||
}
|
||||
|
||||
// Prefer the DB-backed local asset when we have one — it carries richer
|
||||
// metadata than the transient model we'd otherwise synthesise.
|
||||
final localAsset = localAssetId != null ? await _localAssetRepository.getById(localAssetId) : null;
|
||||
final asset = localAsset ?? _toTransientAsset(attachment);
|
||||
|
||||
return ViewIntentResolvedAsset(
|
||||
asset: asset,
|
||||
timelineService: _timelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
|
||||
// viewIntentFilePath is only meaningful for the transient case — the
|
||||
// DB-backed local asset carries its own path/URI for the upload flow.
|
||||
viewIntentFilePath: localAsset == null ? path : null,
|
||||
);
|
||||
}
|
||||
|
||||
LocalAsset _toTransientAsset(ViewIntentPayload attachment) {
|
||||
final now = DateTime.now();
|
||||
return LocalAsset(
|
||||
// TODO(Ombodi): Introduce a file-backed BaseAsset for path-only view intents.
|
||||
// The viewer currently expects a BaseAsset, so this temporary LocalAsset
|
||||
// adapts an unmanaged file into the existing timeline/viewer pipeline.
|
||||
id: attachment.localAssetId ?? '-${attachment.path!.hashCode.abs()}',
|
||||
name: attachment.fileName,
|
||||
type: attachment.isVideo ? AssetType.video : AssetType.image,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isEdited: false,
|
||||
playbackStyle: attachment.playbackStyle,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,9 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
@@ -182,9 +183,11 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
Future<void> handleSyncFlow() async {
|
||||
final backgroundManager = ref.read(backgroundSyncProvider);
|
||||
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
|
||||
|
||||
await backgroundManager.syncLocal(full: true);
|
||||
await backgroundManager.syncRemote();
|
||||
await viewIntentHandler.flushDeferredViewIntent();
|
||||
await backgroundManager.hashAssets();
|
||||
|
||||
if (MetadataRepository.instance.appConfig.backup.syncAlbums) {
|
||||
@@ -193,7 +196,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -224,7 +227,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
@@ -259,7 +262,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
ref.read(websocketProvider.notifier).connect();
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -346,7 +349,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(context.replaceRoute(const TabShellRoute()));
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
@@ -57,9 +57,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
() async {
|
||||
isManageMediaSupported.value = await checkAndroidVersion();
|
||||
if (isManageMediaSupported.value) {
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(localFilesManagerRepositoryProvider)
|
||||
.hasManageMediaPermission();
|
||||
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||
}
|
||||
}();
|
||||
return null;
|
||||
@@ -82,7 +80,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
manageMediaAndroidPermission.value = result;
|
||||
}
|
||||
@@ -96,7 +94,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
? const Color.fromARGB(255, 243, 188, 106)
|
||||
: null,
|
||||
onActionTap: () async {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
||||
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = result;
|
||||
},
|
||||
),
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ run = [
|
||||
"dart run pigeon --input pigeon/background_worker_lock_api.dart",
|
||||
"dart run pigeon --input pigeon/connectivity_api.dart",
|
||||
"dart run pigeon --input pigeon/network_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart",
|
||||
"dart run pigeon --input pigeon/view_intent_api.dart",
|
||||
"dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart lib/platform/view_intent_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."codegen:translation"]
|
||||
|
||||
Generated
-5
@@ -103,16 +103,12 @@ Class | Method | HTTP request | Description
|
||||
*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
|
||||
*AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset
|
||||
*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Apply edits to an existing asset
|
||||
*AssetsApi* | [**endSession**](doc//AssetsApi.md#endsession) | **DELETE** /assets/{id}/video/stream/{sessionId} | End HLS streaming session
|
||||
*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset
|
||||
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset
|
||||
*AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata
|
||||
*AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key
|
||||
*AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | Retrieve asset OCR data
|
||||
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics
|
||||
*AssetsApi* | [**getMainPlaylist**](doc//AssetsApi.md#getmainplaylist) | **GET** /assets/{id}/video/stream/main.m3u8 | Get HLS main playlist
|
||||
*AssetsApi* | [**getMediaPlaylist**](doc//AssetsApi.md#getmediaplaylist) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8 | Get HLS media playlist
|
||||
*AssetsApi* | [**getSegment**](doc//AssetsApi.md#getsegment) | **GET** /assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename} | Get HLS segment or init file
|
||||
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
|
||||
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
|
||||
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
|
||||
@@ -598,7 +594,6 @@ Class | Method | HTTP request | Description
|
||||
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
|
||||
- [SystemConfigDto](doc//SystemConfigDto.md)
|
||||
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
|
||||
- [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md)
|
||||
- [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
|
||||
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
|
||||
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
|
||||
|
||||
Generated
-1
@@ -340,7 +340,6 @@ part 'model/sync_user_v1.dart';
|
||||
part 'model/system_config_backups_dto.dart';
|
||||
part 'model/system_config_dto.dart';
|
||||
part 'model/system_config_f_fmpeg_dto.dart';
|
||||
part 'model/system_config_f_fmpeg_realtime_dto.dart';
|
||||
part 'model/system_config_faces_dto.dart';
|
||||
part 'model/system_config_generated_fullsize_image_dto.dart';
|
||||
part 'model/system_config_generated_image_dto.dart';
|
||||
|
||||
Generated
-310
@@ -416,75 +416,6 @@ class AssetsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// End HLS streaming session
|
||||
///
|
||||
/// Releases server resources for the streaming session.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] sessionId (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> endSessionWithHttpInfo(String id, String sessionId, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/video/stream/{sessionId}'
|
||||
.replaceAll('{id}', id)
|
||||
.replaceAll('{sessionId}', sessionId);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// End HLS streaming session
|
||||
///
|
||||
/// Releases server resources for the streaming session.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] sessionId (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<void> endSession(String id, String sessionId, { String? key, String? slug, }) async {
|
||||
final response = await endSessionWithHttpInfo(id, sessionId, key: key, slug: slug, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve edits for an existing asset
|
||||
///
|
||||
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
|
||||
@@ -878,247 +809,6 @@ class AssetsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get HLS main playlist
|
||||
///
|
||||
/// Returns an HLS main playlist with all available variants for the asset.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> getMainPlaylistWithHttpInfo(String id, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/video/stream/main.m3u8'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get HLS main playlist
|
||||
///
|
||||
/// Returns an HLS main playlist with all available variants for the asset.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<String?> getMainPlaylist(String id, { String? key, String? slug, }) async {
|
||||
final response = await getMainPlaylistWithHttpInfo(id, key: key, slug: slug, );
|
||||
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), 'String',) as String;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get HLS media playlist
|
||||
///
|
||||
/// Returns an HLS media playlist for one variant of the streaming session.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] sessionId (required):
|
||||
///
|
||||
/// * [int] variantIndex (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> getMediaPlaylistWithHttpInfo(String id, String sessionId, int variantIndex, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8'
|
||||
.replaceAll('{id}', id)
|
||||
.replaceAll('{sessionId}', sessionId)
|
||||
.replaceAll('{variantIndex}', variantIndex.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get HLS media playlist
|
||||
///
|
||||
/// Returns an HLS media playlist for one variant of the streaming session.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] sessionId (required):
|
||||
///
|
||||
/// * [int] variantIndex (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<String?> getMediaPlaylist(String id, String sessionId, int variantIndex, { String? key, String? slug, }) async {
|
||||
final response = await getMediaPlaylistWithHttpInfo(id, sessionId, variantIndex, key: key, slug: slug, );
|
||||
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), 'String',) as String;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get HLS segment or init file
|
||||
///
|
||||
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] filename (required):
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] sessionId (required):
|
||||
///
|
||||
/// * [int] variantIndex (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> getSegmentWithHttpInfo(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}'
|
||||
.replaceAll('{filename}', filename)
|
||||
.replaceAll('{id}', id)
|
||||
.replaceAll('{sessionId}', sessionId)
|
||||
.replaceAll('{variantIndex}', variantIndex.toString());
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get HLS segment or init file
|
||||
///
|
||||
/// Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] filename (required):
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [String] sessionId (required):
|
||||
///
|
||||
/// * [int] variantIndex (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<MultipartFile?> getSegment(String filename, String id, String sessionId, int variantIndex, { String? key, String? slug, }) async {
|
||||
final response = await getSegmentWithHttpInfo(filename, id, sessionId, variantIndex, key: key, slug: slug, );
|
||||
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), 'MultipartFile',) as MultipartFile;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Play asset video
|
||||
///
|
||||
/// Streams the video file for the specified asset. This endpoint also supports byte range requests.
|
||||
|
||||
Generated
-2
@@ -726,8 +726,6 @@ class ApiClient {
|
||||
return SystemConfigDto.fromJson(value);
|
||||
case 'SystemConfigFFmpegDto':
|
||||
return SystemConfigFFmpegDto.fromJson(value);
|
||||
case 'SystemConfigFFmpegRealtimeDto':
|
||||
return SystemConfigFFmpegRealtimeDto.fromJson(value);
|
||||
case 'SystemConfigFacesDto':
|
||||
return SystemConfigFacesDto.fromJson(value);
|
||||
case 'SystemConfigGeneratedFullsizeImageDto':
|
||||
|
||||
Generated
-3
@@ -52,7 +52,6 @@ class JobName {
|
||||
static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll');
|
||||
static const librarySyncFiles = JobName._(r'LibrarySyncFiles');
|
||||
static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll');
|
||||
static const hlsSessionCleanup = JobName._(r'HlsSessionCleanup');
|
||||
static const memoryCleanup = JobName._(r'MemoryCleanup');
|
||||
static const memoryGenerate = JobName._(r'MemoryGenerate');
|
||||
static const notificationsCleanup = JobName._(r'NotificationsCleanup');
|
||||
@@ -111,7 +110,6 @@ class JobName {
|
||||
librarySyncFilesQueueAll,
|
||||
librarySyncFiles,
|
||||
libraryScanQueueAll,
|
||||
hlsSessionCleanup,
|
||||
memoryCleanup,
|
||||
memoryGenerate,
|
||||
notificationsCleanup,
|
||||
@@ -205,7 +203,6 @@ class JobNameTypeTransformer {
|
||||
case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll;
|
||||
case r'LibrarySyncFiles': return JobName.librarySyncFiles;
|
||||
case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll;
|
||||
case r'HlsSessionCleanup': return JobName.hlsSessionCleanup;
|
||||
case r'MemoryCleanup': return JobName.memoryCleanup;
|
||||
case r'MemoryGenerate': return JobName.memoryGenerate;
|
||||
case r'NotificationsCleanup': return JobName.notificationsCleanup;
|
||||
|
||||
+1
-10
@@ -23,7 +23,6 @@ class ServerFeaturesDto {
|
||||
required this.oauthAutoLaunch,
|
||||
required this.ocr,
|
||||
required this.passwordLogin,
|
||||
required this.realtimeTranscoding,
|
||||
required this.reverseGeocoding,
|
||||
required this.search,
|
||||
required this.sidecar,
|
||||
@@ -61,9 +60,6 @@ class ServerFeaturesDto {
|
||||
/// Whether password login is enabled
|
||||
bool passwordLogin;
|
||||
|
||||
/// Whether real-time transcoding is enabled
|
||||
bool realtimeTranscoding;
|
||||
|
||||
/// Whether reverse geocoding is enabled
|
||||
bool reverseGeocoding;
|
||||
|
||||
@@ -91,7 +87,6 @@ class ServerFeaturesDto {
|
||||
other.oauthAutoLaunch == oauthAutoLaunch &&
|
||||
other.ocr == ocr &&
|
||||
other.passwordLogin == passwordLogin &&
|
||||
other.realtimeTranscoding == realtimeTranscoding &&
|
||||
other.reverseGeocoding == reverseGeocoding &&
|
||||
other.search == search &&
|
||||
other.sidecar == sidecar &&
|
||||
@@ -111,7 +106,6 @@ class ServerFeaturesDto {
|
||||
(oauthAutoLaunch.hashCode) +
|
||||
(ocr.hashCode) +
|
||||
(passwordLogin.hashCode) +
|
||||
(realtimeTranscoding.hashCode) +
|
||||
(reverseGeocoding.hashCode) +
|
||||
(search.hashCode) +
|
||||
(sidecar.hashCode) +
|
||||
@@ -119,7 +113,7 @@ class ServerFeaturesDto {
|
||||
(trash.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, realtimeTranscoding=$realtimeTranscoding, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
|
||||
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -133,7 +127,6 @@ class ServerFeaturesDto {
|
||||
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
|
||||
json[r'ocr'] = this.ocr;
|
||||
json[r'passwordLogin'] = this.passwordLogin;
|
||||
json[r'realtimeTranscoding'] = this.realtimeTranscoding;
|
||||
json[r'reverseGeocoding'] = this.reverseGeocoding;
|
||||
json[r'search'] = this.search;
|
||||
json[r'sidecar'] = this.sidecar;
|
||||
@@ -161,7 +154,6 @@ class ServerFeaturesDto {
|
||||
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
|
||||
ocr: mapValueOfType<bool>(json, r'ocr')!,
|
||||
passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
|
||||
realtimeTranscoding: mapValueOfType<bool>(json, r'realtimeTranscoding')!,
|
||||
reverseGeocoding: mapValueOfType<bool>(json, r'reverseGeocoding')!,
|
||||
search: mapValueOfType<bool>(json, r'search')!,
|
||||
sidecar: mapValueOfType<bool>(json, r'sidecar')!,
|
||||
@@ -224,7 +216,6 @@ class ServerFeaturesDto {
|
||||
'oauthAutoLaunch',
|
||||
'ocr',
|
||||
'passwordLogin',
|
||||
'realtimeTranscoding',
|
||||
'reverseGeocoding',
|
||||
'search',
|
||||
'sidecar',
|
||||
|
||||
+1
-9
@@ -25,7 +25,6 @@ class SystemConfigFFmpegDto {
|
||||
required this.maxBitrate,
|
||||
required this.preferredHwDevice,
|
||||
required this.preset,
|
||||
required this.realtime,
|
||||
required this.refs,
|
||||
required this.targetAudioCodec,
|
||||
required this.targetResolution,
|
||||
@@ -80,8 +79,6 @@ class SystemConfigFFmpegDto {
|
||||
/// Preset
|
||||
String preset;
|
||||
|
||||
SystemConfigFFmpegRealtimeDto realtime;
|
||||
|
||||
/// References
|
||||
///
|
||||
/// Minimum value: 0
|
||||
@@ -125,7 +122,6 @@ class SystemConfigFFmpegDto {
|
||||
other.maxBitrate == maxBitrate &&
|
||||
other.preferredHwDevice == preferredHwDevice &&
|
||||
other.preset == preset &&
|
||||
other.realtime == realtime &&
|
||||
other.refs == refs &&
|
||||
other.targetAudioCodec == targetAudioCodec &&
|
||||
other.targetResolution == targetResolution &&
|
||||
@@ -151,7 +147,6 @@ class SystemConfigFFmpegDto {
|
||||
(maxBitrate.hashCode) +
|
||||
(preferredHwDevice.hashCode) +
|
||||
(preset.hashCode) +
|
||||
(realtime.hashCode) +
|
||||
(refs.hashCode) +
|
||||
(targetAudioCodec.hashCode) +
|
||||
(targetResolution.hashCode) +
|
||||
@@ -163,7 +158,7 @@ class SystemConfigFFmpegDto {
|
||||
(twoPass.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, realtime=$realtime, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
|
||||
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -179,7 +174,6 @@ class SystemConfigFFmpegDto {
|
||||
json[r'maxBitrate'] = this.maxBitrate;
|
||||
json[r'preferredHwDevice'] = this.preferredHwDevice;
|
||||
json[r'preset'] = this.preset;
|
||||
json[r'realtime'] = this.realtime;
|
||||
json[r'refs'] = this.refs;
|
||||
json[r'targetAudioCodec'] = this.targetAudioCodec;
|
||||
json[r'targetResolution'] = this.targetResolution;
|
||||
@@ -213,7 +207,6 @@ class SystemConfigFFmpegDto {
|
||||
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
|
||||
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
|
||||
preset: mapValueOfType<String>(json, r'preset')!,
|
||||
realtime: SystemConfigFFmpegRealtimeDto.fromJson(json[r'realtime'])!,
|
||||
refs: mapValueOfType<int>(json, r'refs')!,
|
||||
targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!,
|
||||
targetResolution: mapValueOfType<String>(json, r'targetResolution')!,
|
||||
@@ -282,7 +275,6 @@ class SystemConfigFFmpegDto {
|
||||
'maxBitrate',
|
||||
'preferredHwDevice',
|
||||
'preset',
|
||||
'realtime',
|
||||
'refs',
|
||||
'targetAudioCodec',
|
||||
'targetResolution',
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 SystemConfigFFmpegRealtimeDto {
|
||||
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
|
||||
SystemConfigFFmpegRealtimeDto({
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
/// Enable real-time HLS transcoding (alpha)
|
||||
bool enabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigFFmpegRealtimeDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SystemConfigFFmpegRealtimeDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigFFmpegRealtimeDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigFFmpegRealtimeDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigFFmpegRealtimeDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigFFmpegRealtimeDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigFFmpegRealtimeDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigFFmpegRealtimeDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigFFmpegRealtimeDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigFFmpegRealtimeDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigFFmpegRealtimeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigFFmpegRealtimeDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigFFmpegRealtimeDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart';
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
enum PlatformAssetPlaybackStyle {
|
||||
unknown,
|
||||
image,
|
||||
video,
|
||||
imageAnimated,
|
||||
livePhoto,
|
||||
videoLooping,
|
||||
}
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
class PlatformAsset {
|
||||
final String id;
|
||||
@@ -142,6 +135,9 @@ abstract class NativeSyncApi {
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||
|
||||
@async
|
||||
bool restoreFromTrashById(String mediaId, int type);
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/permission_api.g.dart',
|
||||
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
|
||||
swiftOptions: SwiftOptions(),
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class PermissionApi {
|
||||
bool hasManageMediaPermission();
|
||||
|
||||
@async
|
||||
bool requestManageMediaPermission();
|
||||
|
||||
@async
|
||||
bool manageMediaPermission();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/view_intent_api.g.dart',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/viewintent/ViewIntent.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.viewintent'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
class ViewIntentPayload {
|
||||
final String? path;
|
||||
final String mimeType;
|
||||
final String? localAssetId;
|
||||
|
||||
const ViewIntentPayload({this.path, required this.mimeType, this.localAssetId});
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class ViewIntentHostApi {
|
||||
@async
|
||||
ViewIntentPayload? consumeViewIntent();
|
||||
}
|
||||
@@ -10,17 +10,15 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
@@ -28,8 +26,8 @@ void main() {
|
||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||
late LocalFilesManagerRepository mockLocalFilesManager;
|
||||
late StorageRepository mockStorageRepository;
|
||||
late AssetMediaRepository mockAssetMediaRepository;
|
||||
late MockPermissionRepository mockPermissionRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late Drift db;
|
||||
|
||||
@@ -51,8 +49,8 @@ void main() {
|
||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
mockPermissionRepository = MockPermissionRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
|
||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||
@@ -65,25 +63,28 @@ void main() {
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
||||
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
localFilesManager: mockLocalFilesManager,
|
||||
storageRepository: mockStorageRepository,
|
||||
assetMediaRepository: mockAssetMediaRepository,
|
||||
permissionRepository: mockPermissionRepository,
|
||||
nativeSyncApi: mockNativeSyncApi,
|
||||
);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -93,7 +94,7 @@ void main() {
|
||||
|
||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -102,7 +103,7 @@ void main() {
|
||||
|
||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -114,7 +115,7 @@ void main() {
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -131,13 +132,13 @@ void main() {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||
final restoredIds = ['image1'];
|
||||
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requested, orderedEquals(assetsToRestore));
|
||||
return restoredIds;
|
||||
@@ -150,10 +151,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
final assetEntity = MockAssetEntity();
|
||||
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
||||
|
||||
await sut.processTrashedAssets({
|
||||
'album-a': [platformAsset],
|
||||
});
|
||||
@@ -168,12 +165,11 @@ void main() {
|
||||
expect(trashedEntry.asset.name, platformAsset.name);
|
||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||
|
||||
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||
|
||||
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
||||
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['content://local-trash']);
|
||||
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['local-trash']);
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
@@ -181,6 +177,26 @@ void main() {
|
||||
expect(trashArgs['album-a'], [localAssetToTrash]);
|
||||
});
|
||||
|
||||
test('records only local assets that were moved to device trash', () async {
|
||||
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [movedAsset],
|
||||
'album-b': [skippedAsset],
|
||||
},
|
||||
);
|
||||
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [movedAsset]);
|
||||
});
|
||||
|
||||
test('does not attempt restore when repository has no assets to restore', () async {
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
|
||||
@@ -190,7 +206,7 @@ void main() {
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
expect(trashedSnapshot, isEmpty);
|
||||
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||
});
|
||||
|
||||
@@ -199,7 +215,7 @@ void main() {
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||
});
|
||||
});
|
||||
@@ -215,7 +231,7 @@ void main() {
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -12,12 +12,11 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -26,7 +25,6 @@ import '../../api.mocks.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../fixtures/sync_stream.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
@@ -52,8 +50,8 @@ void main() {
|
||||
late SyncApiRepository mockSyncApiRepo;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
||||
late StorageRepository mockStorageRepo;
|
||||
late AssetMediaRepository mockAssetMediaRepo;
|
||||
late MockPermissionRepository mockPermissionRepo;
|
||||
late MockApiService mockApi;
|
||||
late MockServerApi mockServerApi;
|
||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||
@@ -86,8 +84,8 @@ void main() {
|
||||
mockSyncApiRepo = MockSyncApiRepository();
|
||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
||||
mockStorageRepo = MockStorageRepository();
|
||||
mockAssetMediaRepo = MockAssetMediaRepository();
|
||||
mockPermissionRepo = MockPermissionRepository();
|
||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockApi = MockApiService();
|
||||
@@ -159,8 +157,8 @@ void main() {
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
@@ -170,10 +168,12 @@ void main() {
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
hasManageMediaPermission = false;
|
||||
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
||||
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
});
|
||||
|
||||
@@ -241,8 +241,8 @@ void main() {
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -282,8 +282,8 @@ void main() {
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -424,18 +424,10 @@ void main() {
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
final localEntity = MockAssetEntity();
|
||||
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
||||
|
||||
final mergedEntity = MockAssetEntity();
|
||||
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
|
||||
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
|
||||
final urls = invocation.positionalArguments.first as List<String>;
|
||||
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
|
||||
return true;
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
expect(ids, unorderedEquals(['local-only', 'merged-local']));
|
||||
return ids;
|
||||
});
|
||||
|
||||
final events = [
|
||||
@@ -461,10 +453,51 @@ void main() {
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
|
||||
expect(trashArgs['album-a'], [localAsset]);
|
||||
expect(trashArgs['album-b'], [mergedAsset]);
|
||||
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
|
||||
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||
});
|
||||
|
||||
test("records only assets that were moved to device trash", () async {
|
||||
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [movedAsset],
|
||||
'album-b': [skippedAsset],
|
||||
},
|
||||
);
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-moved',
|
||||
checksum: movedAsset.checksum!,
|
||||
ack: 'asset-remote-moved',
|
||||
trashedAt: DateTime(2025, 5, 1),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-skipped',
|
||||
checksum: skippedAsset.checksum!,
|
||||
ack: 'asset-remote-skipped',
|
||||
trashedAt: DateTime(2025, 5, 2),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [movedAsset]);
|
||||
});
|
||||
|
||||
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
@@ -478,7 +511,7 @@ void main() {
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
});
|
||||
|
||||
@@ -494,7 +527,7 @@ void main() {
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
@@ -505,7 +538,7 @@ void main() {
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||
|
||||
final restoredIds = ['trashed-1'];
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||
return restoredIds;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_handler_android.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_main_timeline_ready.provider.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/services/widget.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
||||
|
||||
class MockViewIntentAssetResolver extends Mock implements ViewIntentAssetResolver {}
|
||||
|
||||
class MockAppRouter extends Mock implements AppRouter {}
|
||||
|
||||
class MockAuthService extends Mock implements AuthService {}
|
||||
|
||||
class MockApiService extends Mock implements ApiService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
|
||||
class MockSecureStorageService extends Mock implements SecureStorageService {}
|
||||
|
||||
class MockWidgetService extends Mock implements WidgetService {}
|
||||
|
||||
class FakePageRouteInfo extends Fake implements PageRouteInfo<dynamic> {}
|
||||
|
||||
class FakeTimelineService extends Fake implements TimelineService {}
|
||||
|
||||
class TestViewIntentService extends ViewIntentService {
|
||||
ViewIntentPayload? consumedAttachment;
|
||||
int cleanupStaleTempFilesCalls = 0;
|
||||
int cleanupManagedTempFileCalls = 0;
|
||||
final List<String> managedTempPaths = [];
|
||||
|
||||
TestViewIntentService() : super(MockViewIntentHostApi());
|
||||
|
||||
@override
|
||||
Future<ViewIntentPayload?> consumeViewIntent() async => consumedAttachment;
|
||||
|
||||
@override
|
||||
Future<void> cleanupStaleTempFiles() async {
|
||||
cleanupStaleTempFilesCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cleanupManagedTempFile() async {
|
||||
cleanupManagedTempFileCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setManagedTempFilePath(String path) async {
|
||||
managedTempPaths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
class TestAuthNotifier extends AuthNotifier {
|
||||
TestAuthNotifier(Ref ref, AuthState initial)
|
||||
: super(
|
||||
MockAuthService(),
|
||||
MockApiService(),
|
||||
MockUserService(),
|
||||
MockSecureStorageService(),
|
||||
MockWidgetService(),
|
||||
ref,
|
||||
) {
|
||||
state = initial;
|
||||
}
|
||||
|
||||
void setAuthenticated(bool isAuthenticated) {
|
||||
state = state.copyWith(isAuthenticated: isAuthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
final _handlerProvider = Provider<AndroidViewIntentHandler>((ref) => AndroidViewIntentHandler(ref));
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late TestViewIntentService viewIntentService;
|
||||
late MockViewIntentAssetResolver resolver;
|
||||
late MockAppRouter router;
|
||||
late TestAuthNotifier authNotifier;
|
||||
late ProviderContainer container;
|
||||
late AndroidViewIntentHandler handler;
|
||||
late ViewIntentPayload payload;
|
||||
late LocalAsset deepLinkAsset;
|
||||
late TimelineService deepLinkTimelineService;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(FakePageRouteInfo());
|
||||
registerFallbackValue(<PageRouteInfo<dynamic>>[]);
|
||||
registerFallbackValue(FakeTimelineService());
|
||||
registerFallbackValue(
|
||||
ViewIntentPayload(path: '/tmp/fallback.jpg', mimeType: 'image/jpeg', localAssetId: 'fallback'),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
viewIntentService = TestViewIntentService();
|
||||
resolver = MockViewIntentAssetResolver();
|
||||
router = MockAppRouter();
|
||||
payload = ViewIntentPayload(path: '/tmp/incoming.jpg', mimeType: 'image/jpeg', localAssetId: 'local-1');
|
||||
deepLinkAsset = _localAsset(id: 'local-1');
|
||||
deepLinkTimelineService = await _createReadyTimelineService([deepLinkAsset], TimelineOrigin.deepLink);
|
||||
|
||||
when(() => router.replaceAll(any())).thenAnswer((_) async {});
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
viewIntentServiceProvider.overrideWithValue(viewIntentService),
|
||||
viewIntentAssetResolverProvider.overrideWithValue(resolver),
|
||||
appRouterProvider.overrideWithValue(router),
|
||||
// viewIntentMainTimelineReadyProvider reads both of these to compute
|
||||
// its ready state — without them wait() never resolves.
|
||||
timelineServiceProvider.overrideWithValue(deepLinkTimelineService),
|
||||
timelineUsersProvider.overrideWith((ref) => Stream.value(['user-1'])),
|
||||
authProvider.overrideWith((ref) {
|
||||
authNotifier = TestAuthNotifier(ref, _authState(isAuthenticated: true));
|
||||
return authNotifier;
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
authNotifier = container.read(authProvider.notifier) as TestAuthNotifier;
|
||||
await container.read(timelineUsersProvider.future);
|
||||
handler = container.read(_handlerProvider);
|
||||
|
||||
addTearDown(() async {
|
||||
await deepLinkTimelineService.dispose();
|
||||
container.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('handle defers unauthenticated attachment', () async {
|
||||
authNotifier.setAuthenticated(false);
|
||||
|
||||
await handler.handle(payload);
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), payload);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('flushDeferredViewIntent waits for main timeline readiness before flushing pending attachment', (
|
||||
tester,
|
||||
) async {
|
||||
authNotifier.setAuthenticated(false);
|
||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||
authNotifier.setAuthenticated(true);
|
||||
|
||||
when(() => resolver.resolve(payload)).thenAnswer((_) async {
|
||||
return ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService);
|
||||
});
|
||||
|
||||
unawaited(handler.flushDeferredViewIntent());
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), payload);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
|
||||
container.read(viewIntentMainTimelineReadyProvider.notifier).markMountedOnce();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.idle();
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), isNull);
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
});
|
||||
|
||||
test('flushDeferredViewIntent does nothing when there is no pending attachment', () async {
|
||||
await handler.flushDeferredViewIntent();
|
||||
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed cleans stale temp files when no attachment is present', () async {
|
||||
viewIntentService.consumedAttachment = null;
|
||||
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 1);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
test('onAppResumed does not clean stale temp files while pending attachment exists', () async {
|
||||
viewIntentService.consumedAttachment = null;
|
||||
container.read(viewIntentPendingProvider.notifier).defer(payload);
|
||||
|
||||
await handler.onAppResumed();
|
||||
|
||||
expect(viewIntentService.cleanupStaleTempFilesCalls, 0);
|
||||
verifyNever(() => resolver.resolve(any()));
|
||||
});
|
||||
|
||||
testWidgets('onAppResumed handles attachment immediately when authenticated', (tester) async {
|
||||
viewIntentService.consumedAttachment = payload;
|
||||
when(() => resolver.resolve(payload)).thenAnswer(
|
||||
(_) async => ViewIntentResolvedAsset(asset: deepLinkAsset, timelineService: deepLinkTimelineService),
|
||||
);
|
||||
|
||||
unawaited(handler.onAppResumed());
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.idle();
|
||||
|
||||
verify(() => resolver.resolve(payload)).called(1);
|
||||
// Routes the user to [TabShell, AssetViewer] so back-press lands on the
|
||||
// main timeline — mirrors the home-screen widget navigation pattern.
|
||||
final captured = verify(() => router.replaceAll(captureAny())).captured;
|
||||
expect(captured, hasLength(1));
|
||||
final routes = captured.single as List<PageRouteInfo<dynamic>>;
|
||||
expect(routes, hasLength(2));
|
||||
expect(routes[0].routeName, TabShellRoute.name);
|
||||
expect(routes[1].routeName, AssetViewerRoute.name);
|
||||
});
|
||||
}
|
||||
|
||||
AuthState _authState({required bool isAuthenticated}) {
|
||||
return AuthState(
|
||||
deviceId: 'device-1',
|
||||
userId: 'user-1',
|
||||
userEmail: 'user@example.com',
|
||||
isAuthenticated: isAuthenticated,
|
||||
name: 'User',
|
||||
isAdmin: false,
|
||||
profileImagePath: '',
|
||||
);
|
||||
}
|
||||
|
||||
LocalAsset _localAsset({required String id}) {
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: 'checksum-1',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: origin,
|
||||
));
|
||||
}
|
||||
|
||||
Future<TimelineService> _createReadyTimelineService(List<BaseAsset> assets, TimelineOrigin origin) async {
|
||||
final timelineService = _timelineServiceFromAssets(assets, origin);
|
||||
|
||||
if (!timelineService.isReady) {
|
||||
await timelineService.watchStatus().firstWhere((status) => status == TimelineStatus.ready);
|
||||
}
|
||||
|
||||
return timelineService;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/view_intent/view_intent_pending.provider.dart';
|
||||
|
||||
void main() {
|
||||
late DateTime now;
|
||||
late ProviderContainer container;
|
||||
|
||||
final attachment = ViewIntentPayload(
|
||||
path: '/tmp/file.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '42',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
now = DateTime(2026, 4, 17, 12);
|
||||
container = ProviderContainer(
|
||||
overrides: [viewIntentNowProvider.overrideWithValue(() => now)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
});
|
||||
|
||||
test('defer stores pending attachment', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
|
||||
expect(container.read(viewIntentPendingProvider), attachment);
|
||||
});
|
||||
|
||||
test('takeIfFresh returns pending attachment once', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
|
||||
final first = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
final second = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(first, attachment);
|
||||
expect(second, isNull);
|
||||
});
|
||||
|
||||
test('takeIfFresh drops expired attachment', () {
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
now = now.add(const Duration(minutes: 11));
|
||||
|
||||
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(result, isNull);
|
||||
expect(container.read(viewIntentPendingProvider), isNull);
|
||||
});
|
||||
|
||||
test('newer deferred attachment replaces older one', () {
|
||||
final newerAttachment = ViewIntentPayload(
|
||||
path: '/tmp/file-2.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '43',
|
||||
);
|
||||
|
||||
container.read(viewIntentPendingProvider.notifier).defer(attachment);
|
||||
container.read(viewIntentPendingProvider.notifier).defer(newerAttachment);
|
||||
|
||||
final result = container.read(viewIntentPendingProvider.notifier).takeIfFresh();
|
||||
|
||||
expect(result, newerAttachment);
|
||||
});
|
||||
}
|
||||
@@ -3,17 +3,17 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
|
||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||
|
||||
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||
|
||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
|
||||
|
||||
class MockTagService extends Mock implements TagService {}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
|
||||
class MockTimelineFactory extends Mock implements TimelineFactory {}
|
||||
|
||||
void main() {
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockTimelineFactory timelineFactory;
|
||||
late List<TimelineService> createdTimelineServices;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
timelineFactory = MockTimelineFactory();
|
||||
createdTimelineServices = [];
|
||||
|
||||
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
|
||||
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
|
||||
final timelineService = _timelineServiceFromAssets(assets, TimelineOrigin.deepLink);
|
||||
createdTimelineServices.add(timelineService);
|
||||
return timelineService;
|
||||
});
|
||||
|
||||
container = ProviderContainer(
|
||||
overrides: [
|
||||
localAssetRepository.overrideWith((ref) => mockLocalAssetRepository),
|
||||
timelineFactoryProvider.overrideWith((ref) => timelineFactory),
|
||||
],
|
||||
);
|
||||
|
||||
addTearDown(() async {
|
||||
for (final timelineService in createdTimelineServices) {
|
||||
await timelineService.dispose();
|
||||
}
|
||||
container.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('returns DB-backed local asset wrapped in a 1-element deep-link timeline', () async {
|
||||
final localAsset = _localAsset(id: 'local-1', checksum: 'checksum-1');
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => localAsset);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1'));
|
||||
|
||||
expect(result.asset, equals(localAsset));
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, isNull, reason: 'DB-backed assets carry their own source — no temp file needed');
|
||||
});
|
||||
|
||||
test('returns transient asset with temp file path when localAssetId has no DB row', () async {
|
||||
when(() => mockLocalAssetRepository.getById('local-1')).thenAnswer((_) async => null);
|
||||
|
||||
final result = await _resolve(container, _payload(localAssetId: 'local-1', path: '/tmp/incoming.jpg'));
|
||||
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.jpg');
|
||||
});
|
||||
|
||||
test('returns transient asset for path-only attachment', () async {
|
||||
final result = await _resolve(
|
||||
container,
|
||||
_payload(localAssetId: null, path: '/tmp/incoming.webp', mimeType: 'image/webp'),
|
||||
);
|
||||
|
||||
expect(result.asset, isA<LocalAsset>());
|
||||
expect(result.timelineService.origin, TimelineOrigin.deepLink);
|
||||
expect(result.viewIntentFilePath, '/tmp/incoming.webp');
|
||||
|
||||
final asset = result.asset as LocalAsset;
|
||||
expect(asset.localId, startsWith('-'));
|
||||
expect(asset.name, 'incoming.webp');
|
||||
expect(asset.playbackStyle, AssetPlaybackStyle.imageAnimated);
|
||||
});
|
||||
|
||||
test('throws when neither localAssetId nor path is provided', () async {
|
||||
await expectLater(
|
||||
_resolve(container, _payload(localAssetId: null, path: null)),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<ViewIntentResolvedAsset> _resolve(ProviderContainer container, ViewIntentPayload payload) {
|
||||
return container.read(viewIntentAssetResolverProvider).resolve(payload);
|
||||
}
|
||||
|
||||
ViewIntentPayload _payload({String? localAssetId = 'local-1', String? path, String mimeType = 'image/jpeg'}) {
|
||||
return ViewIntentPayload(path: path, mimeType: mimeType, localAssetId: localAssetId);
|
||||
}
|
||||
|
||||
LocalAsset _localAsset({required String id, String? checksum}) {
|
||||
return LocalAsset(
|
||||
id: id,
|
||||
name: '$id.jpg',
|
||||
checksum: checksum,
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 4, 20),
|
||||
updatedAt: DateTime(2026, 4, 20),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
TimelineService _timelineServiceFromAssets(List<BaseAsset> assets, TimelineOrigin origin) {
|
||||
return TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: origin,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/platform/view_intent_api.g.dart';
|
||||
import 'package:immich_mobile/services/view_intent.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockViewIntentHostApi extends Mock implements ViewIntentHostApi {}
|
||||
|
||||
void main() {
|
||||
late MockViewIntentHostApi hostApi;
|
||||
late ViewIntentService service;
|
||||
late Directory tempRoot;
|
||||
late Directory cacheDir;
|
||||
|
||||
final attachment = ViewIntentPayload(
|
||||
path: '/tmp/file.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
localAssetId: '42',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
hostApi = MockViewIntentHostApi();
|
||||
tempRoot = Directory.systemTemp.createTempSync('view-intent-root');
|
||||
cacheDir = Directory('${tempRoot.path}/cache')..createSync();
|
||||
service = ViewIntentService(hostApi, temporaryDirectory: () async => cacheDir);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
clearInteractions(hostApi);
|
||||
if (await tempRoot.exists()) {
|
||||
await tempRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeViewIntent returns null when no attachment', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => null);
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('consumeViewIntent returns attachment when present', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenAnswer((_) async => attachment);
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, attachment);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('consumeViewIntent swallows host api errors', () async {
|
||||
when(() => hostApi.consumeViewIntent()).thenThrow(Exception('boom'));
|
||||
|
||||
final result = await service.consumeViewIntent();
|
||||
|
||||
expect(result, isNull);
|
||||
verify(() => hostApi.consumeViewIntent()).called(1);
|
||||
});
|
||||
|
||||
test('setManagedTempFilePath cleans previous managed temp file', () async {
|
||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
||||
|
||||
await service.setManagedTempFilePath(firstFile.path);
|
||||
await service.setManagedTempFilePath(secondFile.path);
|
||||
|
||||
expect(await firstFile.exists(), isFalse);
|
||||
expect(await secondFile.exists(), isTrue);
|
||||
|
||||
await service.cleanupManagedTempFile();
|
||||
expect(await secondFile.exists(), isFalse);
|
||||
});
|
||||
|
||||
test('cleanupTempFile ignores non-managed paths', () async {
|
||||
final nonManagedFile = File('${tempRoot.path}/plain_file.jpg')..writeAsStringSync('content');
|
||||
|
||||
await service.cleanupTempFile(nonManagedFile.path);
|
||||
|
||||
expect(await nonManagedFile.exists(), isTrue);
|
||||
});
|
||||
|
||||
test('cleanupStaleTempFiles removes view-intent temp files and keeps unrelated files', () async {
|
||||
final firstFile = File('${cacheDir.path}/view_intent_first.jpg')..writeAsStringSync('first');
|
||||
final secondFile = File('${cacheDir.path}/view_intent_second.jpg')..writeAsStringSync('second');
|
||||
final unrelatedFile = File('${cacheDir.path}/plain_file.jpg')..writeAsStringSync('plain');
|
||||
|
||||
await service.cleanupStaleTempFiles();
|
||||
|
||||
expect(await firstFile.exists(), isFalse);
|
||||
expect(await secondFile.exists(), isFalse);
|
||||
expect(await unrelatedFile.exists(), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -4288,351 +4288,6 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/assets/{id}/video/stream/main.m3u8": {
|
||||
"get": {
|
||||
"description": "Returns an HLS main playlist with all available variants for the asset.",
|
||||
"operationId": "getMainPlaylist",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/vnd.apple.mpegurl": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get HLS main playlist",
|
||||
"tags": [
|
||||
"Assets"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "asset.view",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/assets/{id}/video/stream/{sessionId}": {
|
||||
"delete": {
|
||||
"description": "Releases server resources for the streaming session.",
|
||||
"operationId": "endSession",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sessionId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "End HLS streaming session",
|
||||
"tags": [
|
||||
"Assets"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "asset.view",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/assets/{id}/video/stream/{sessionId}/{variantIndex}/playlist.m3u8": {
|
||||
"get": {
|
||||
"description": "Returns an HLS media playlist for one variant of the streaming session.",
|
||||
"operationId": "getMediaPlaylist",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sessionId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variantIndex",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/vnd.apple.mpegurl": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get HLS media playlist",
|
||||
"tags": [
|
||||
"Assets"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "asset.view",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/assets/{id}/video/stream/{sessionId}/{variantIndex}/{filename}": {
|
||||
"get": {
|
||||
"description": "Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).",
|
||||
"operationId": "getSegment",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "filename",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"pattern": "^(init\\.mp4|seg_\\d+\\.m4s)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sessionId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "variantIndex",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get HLS segment or init file",
|
||||
"tags": [
|
||||
"Assets"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Alpha"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "asset.view",
|
||||
"x-immich-state": "Alpha"
|
||||
}
|
||||
},
|
||||
"/auth/admin-sign-up": {
|
||||
"post": {
|
||||
"description": "Create the first admin user in the system.",
|
||||
@@ -18427,7 +18082,6 @@
|
||||
"LibrarySyncFilesQueueAll",
|
||||
"LibrarySyncFiles",
|
||||
"LibraryScanQueueAll",
|
||||
"HlsSessionCleanup",
|
||||
"MemoryCleanup",
|
||||
"MemoryGenerate",
|
||||
"NotificationsCleanup",
|
||||
@@ -21494,10 +21148,6 @@
|
||||
"description": "Whether password login is enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"realtimeTranscoding": {
|
||||
"description": "Whether real-time transcoding is enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"reverseGeocoding": {
|
||||
"description": "Whether reverse geocoding is enabled",
|
||||
"type": "boolean"
|
||||
@@ -21530,7 +21180,6 @@
|
||||
"oauthAutoLaunch",
|
||||
"ocr",
|
||||
"passwordLogin",
|
||||
"realtimeTranscoding",
|
||||
"reverseGeocoding",
|
||||
"search",
|
||||
"sidecar",
|
||||
@@ -24391,9 +24040,6 @@
|
||||
"description": "Preset",
|
||||
"type": "string"
|
||||
},
|
||||
"realtime": {
|
||||
"$ref": "#/components/schemas/SystemConfigFFmpegRealtimeDto"
|
||||
},
|
||||
"refs": {
|
||||
"description": "References",
|
||||
"maximum": 6,
|
||||
@@ -24444,7 +24090,6 @@
|
||||
"maxBitrate",
|
||||
"preferredHwDevice",
|
||||
"preset",
|
||||
"realtime",
|
||||
"refs",
|
||||
"targetAudioCodec",
|
||||
"targetResolution",
|
||||
@@ -24457,18 +24102,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigFFmpegRealtimeDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Enable real-time HLS transcoding (alpha)",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigFacesDto": {
|
||||
"properties": {
|
||||
"import": {
|
||||
|
||||
@@ -1969,8 +1969,6 @@ export type ServerFeaturesDto = {
|
||||
ocr: boolean;
|
||||
/** Whether password login is enabled */
|
||||
passwordLogin: boolean;
|
||||
/** Whether real-time transcoding is enabled */
|
||||
realtimeTranscoding: boolean;
|
||||
/** Whether reverse geocoding is enabled */
|
||||
reverseGeocoding: boolean;
|
||||
/** Whether search is enabled */
|
||||
@@ -2229,10 +2227,6 @@ export type DatabaseBackupConfig = {
|
||||
export type SystemConfigBackupsDto = {
|
||||
database: DatabaseBackupConfig;
|
||||
};
|
||||
export type SystemConfigFFmpegRealtimeDto = {
|
||||
/** Enable real-time HLS transcoding (alpha) */
|
||||
enabled: boolean;
|
||||
};
|
||||
export type SystemConfigFFmpegDto = {
|
||||
accel: TranscodeHWAccel;
|
||||
/** Accelerated decode */
|
||||
@@ -2256,7 +2250,6 @@ export type SystemConfigFFmpegDto = {
|
||||
preferredHwDevice: string;
|
||||
/** Preset */
|
||||
preset: string;
|
||||
realtime: SystemConfigFFmpegRealtimeDto;
|
||||
/** References */
|
||||
refs: number;
|
||||
targetAudioCodec: AudioCodec;
|
||||
@@ -4191,82 +4184,6 @@ export function playAssetVideo({ id, key, slug }: {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Get HLS main playlist
|
||||
*/
|
||||
export function getMainPlaylist({ id, key, slug }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: string;
|
||||
}>(`/assets/${encodeURIComponent(id)}/video/stream/main.m3u8${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* End HLS streaming session
|
||||
*/
|
||||
export function endSession({ id, key, sessionId, slug }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
sessionId: string;
|
||||
slug?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "DELETE"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Get HLS media playlist
|
||||
*/
|
||||
export function getMediaPlaylist({ id, key, sessionId, slug, variantIndex }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
sessionId: string;
|
||||
slug?: string;
|
||||
variantIndex: number;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: string;
|
||||
}>(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}/${encodeURIComponent(variantIndex)}/playlist.m3u8${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Get HLS segment or init file
|
||||
*/
|
||||
export function getSegment({ filename, id, key, sessionId, slug, variantIndex }: {
|
||||
filename: string;
|
||||
id: string;
|
||||
key?: string;
|
||||
sessionId: string;
|
||||
slug?: string;
|
||||
variantIndex: number;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/assets/${encodeURIComponent(id)}/video/stream/${encodeURIComponent(sessionId)}/${encodeURIComponent(variantIndex)}/${encodeURIComponent(filename)}${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Register admin
|
||||
*/
|
||||
@@ -7165,7 +7082,6 @@ export enum JobName {
|
||||
LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll",
|
||||
LibrarySyncFiles = "LibrarySyncFiles",
|
||||
LibraryScanQueueAll = "LibraryScanQueueAll",
|
||||
HlsSessionCleanup = "HlsSessionCleanup",
|
||||
MemoryCleanup = "MemoryCleanup",
|
||||
MemoryGenerate = "MemoryGenerate",
|
||||
NotificationsCleanup = "NotificationsCleanup",
|
||||
|
||||
Generated
-30
@@ -814,12 +814,6 @@ importers:
|
||||
happy-dom:
|
||||
specifier: ^20.0.0
|
||||
version: 20.9.0
|
||||
hls-video-element:
|
||||
specifier: ^1.5.11
|
||||
version: 1.5.11
|
||||
hls.js:
|
||||
specifier: ^1.6.16
|
||||
version: 1.6.16
|
||||
intl-messageformat:
|
||||
specifier: ^11.0.0
|
||||
version: 11.2.6
|
||||
@@ -6942,9 +6936,6 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
custom-media-element@1.4.6:
|
||||
resolution: {integrity: sha512-/HRYqJOa1ob5ik4q7FIJVYxTJCFs/FL3+cQPAJjUf2uiqrDEzbTgB315gQ2rG8oK3w094W9m5tcB8S5Qah+caA==}
|
||||
|
||||
cytoscape-cose-bilkent@4.1.0:
|
||||
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
|
||||
peerDependencies:
|
||||
@@ -8269,12 +8260,6 @@ packages:
|
||||
history@4.10.1:
|
||||
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==}
|
||||
|
||||
hls-video-element@1.5.11:
|
||||
resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==}
|
||||
|
||||
hls.js@1.6.16:
|
||||
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
|
||||
|
||||
hogan.js@3.0.2:
|
||||
resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==}
|
||||
hasBin: true
|
||||
@@ -9279,9 +9264,6 @@ packages:
|
||||
media-chrome@4.19.0:
|
||||
resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==}
|
||||
|
||||
media-tracks@0.3.5:
|
||||
resolution: {integrity: sha512-l54rkKXlLBt3ob3zOLWHcnjvwUmX5bNEZ70igyapOZZC9imzqBmq1oz8p2roiV04KhjblFIi2hetLPF1oYVLRA==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -19868,8 +19850,6 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
custom-media-element@1.4.6: {}
|
||||
|
||||
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.4):
|
||||
dependencies:
|
||||
cose-base: 1.0.3
|
||||
@@ -21559,14 +21539,6 @@ snapshots:
|
||||
tiny-warning: 1.0.3
|
||||
value-equal: 1.0.1
|
||||
|
||||
hls-video-element@1.5.11:
|
||||
dependencies:
|
||||
custom-media-element: 1.4.6
|
||||
hls.js: 1.6.16
|
||||
media-tracks: 0.3.5
|
||||
|
||||
hls.js@1.6.16: {}
|
||||
|
||||
hogan.js@3.0.2:
|
||||
dependencies:
|
||||
mkdirp: 0.3.0
|
||||
@@ -22670,8 +22642,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
|
||||
media-tracks@0.3.5: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
+2
-2
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
|
||||
COPY --from=server /output/server-pruned ./server
|
||||
COPY --from=web /usr/src/app/web/build /build/www
|
||||
COPY --from=cli /output/cli-pruned ./cli
|
||||
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
|
||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
|
||||
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist
|
||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json
|
||||
RUN ln -s ../../cli/bin/immich server/bin/immich
|
||||
COPY LICENSE /licenses/LICENSE.txt
|
||||
COPY LICENSE /LICENSE
|
||||
|
||||
@@ -45,9 +45,6 @@ export type SystemConfig = {
|
||||
accel: TranscodeHardwareAcceleration;
|
||||
accelDecode: boolean;
|
||||
tonemap: ToneMapping;
|
||||
realtime: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
job: Record<ConcurrentQueueName, { concurrency: number }>;
|
||||
logging: {
|
||||
@@ -227,9 +224,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
tonemap: ToneMapping.Hable,
|
||||
accel: TranscodeHardwareAcceleration.Disabled,
|
||||
accelDecode: true,
|
||||
realtime: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
job: {
|
||||
[QueueName.BackgroundTask]: { concurrency: 5 },
|
||||
|
||||
+1
-38
@@ -1,15 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SemVer } from 'semver';
|
||||
import {
|
||||
ApiTag,
|
||||
AudioCodec,
|
||||
DatabaseExtension,
|
||||
ExifOrientation,
|
||||
TranscodeHardwareAcceleration,
|
||||
VectorIndex,
|
||||
VideoCodec,
|
||||
} from 'src/enum';
|
||||
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
|
||||
export const IMMICH_SERVER_START = 'Immich Server is listening';
|
||||
|
||||
@@ -210,32 +202,3 @@ export const AUDIO_ENCODER: Record<AudioCodec, string> = {
|
||||
[AudioCodec.Opus]: 'libopus',
|
||||
[AudioCodec.PcmS16le]: 'pcm_s16le',
|
||||
};
|
||||
|
||||
export const SUPPORTED_HWA_CODECS: Record<TranscodeHardwareAcceleration, VideoCodec[]> = {
|
||||
[TranscodeHardwareAcceleration.Nvenc]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Av1],
|
||||
[TranscodeHardwareAcceleration.Qsv]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
|
||||
[TranscodeHardwareAcceleration.Vaapi]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
|
||||
[TranscodeHardwareAcceleration.Rkmpp]: [VideoCodec.H264, VideoCodec.Hevc],
|
||||
[TranscodeHardwareAcceleration.Disabled]: [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1],
|
||||
};
|
||||
|
||||
export const HLS_BACKPRESSURE_PAUSE_SEGMENTS = 30;
|
||||
export const HLS_BACKPRESSURE_RESUME_SEGMENTS = 15;
|
||||
export const HLS_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
export const HLS_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
export const HLS_LEASE_DURATION_MS = 30 * 60 * 1000;
|
||||
export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
|
||||
export const HLS_SEGMENT_DURATION = 2;
|
||||
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
|
||||
export const HLS_VARIANTS = [
|
||||
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' },
|
||||
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' },
|
||||
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' },
|
||||
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' },
|
||||
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' },
|
||||
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' },
|
||||
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' },
|
||||
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' },
|
||||
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' },
|
||||
];
|
||||
export const HLS_VERSION = 7;
|
||||
|
||||
@@ -35,7 +35,6 @@ import { TimelineController } from 'src/controllers/timeline.controller';
|
||||
import { TrashController } from 'src/controllers/trash.controller';
|
||||
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||
import { UserController } from 'src/controllers/user.controller';
|
||||
import { VideoStreamController } from 'src/controllers/video-stream.controller';
|
||||
import { ViewController } from 'src/controllers/view.controller';
|
||||
import { WorkflowController } from 'src/controllers/workflow.controller';
|
||||
|
||||
@@ -77,7 +76,6 @@ export const controllers = [
|
||||
TrashController,
|
||||
UserAdminController,
|
||||
UserController,
|
||||
VideoStreamController,
|
||||
ViewController,
|
||||
WorkflowController,
|
||||
];
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Controller, Delete, Get, Header, HttpCode, HttpStatus, Next, Param, Res } from '@nestjs/common';
|
||||
import { ApiProduces, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { HLS_PLAYLIST_CONTENT_TYPE } from 'src/constants';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { HlsSegmentParamDto, HlsSessionParamDto, HlsVariantParamDto } from 'src/dtos/streaming.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { HlsService } from 'src/services/hls.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@Controller(RouteKey.Asset)
|
||||
export class VideoStreamController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: HlsService,
|
||||
) {}
|
||||
|
||||
@Get(':id/video/stream/main.m3u8')
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Header('Cache-Control', 'no-cache')
|
||||
@Header('Content-Type', HLS_PLAYLIST_CONTENT_TYPE)
|
||||
@ApiProduces(HLS_PLAYLIST_CONTENT_TYPE)
|
||||
@Endpoint({
|
||||
summary: 'Get HLS main playlist',
|
||||
description: 'Returns an HLS main playlist with all available variants for the asset.',
|
||||
history: new HistoryBuilder().added('v3').alpha('v3'),
|
||||
})
|
||||
getMainPlaylist(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
|
||||
return this.service.getMainPlaylist(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/video/stream/:sessionId/:variantIndex/playlist.m3u8')
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Header('Cache-Control', 'no-cache')
|
||||
@Header('Content-Type', HLS_PLAYLIST_CONTENT_TYPE)
|
||||
@ApiProduces(HLS_PLAYLIST_CONTENT_TYPE)
|
||||
@Endpoint({
|
||||
summary: 'Get HLS media playlist',
|
||||
description: 'Returns an HLS media playlist for one variant of the streaming session.',
|
||||
history: new HistoryBuilder().added('v3').alpha('v3'),
|
||||
})
|
||||
getMediaPlaylist(@Auth() auth: AuthDto, @Param() { id, sessionId }: HlsVariantParamDto) {
|
||||
return this.service.getMediaPlaylist(auth, id, sessionId);
|
||||
}
|
||||
|
||||
@Get(':id/video/stream/:sessionId/:variantIndex/:filename')
|
||||
@FileResponse()
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'Get HLS segment or init file',
|
||||
description: 'Streams an HLS init segment (init.mp4) or media segment (seg_N.m4s).',
|
||||
history: new HistoryBuilder().added('v3').alpha('v3'),
|
||||
})
|
||||
async getSegment(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id, sessionId, variantIndex, filename }: HlsSegmentParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.getSegment(auth, id, sessionId, variantIndex, filename), this.logger);
|
||||
}
|
||||
|
||||
@Delete(':id/video/stream/:sessionId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'End HLS streaming session',
|
||||
description: 'Releases server resources for the streaming session.',
|
||||
history: new HistoryBuilder().added('v3').alpha('v3'),
|
||||
})
|
||||
async endSession(@Auth() auth: AuthDto, @Param() { id, sessionId }: HlsSessionParamDto) {
|
||||
await this.service.endSession(auth, id, sessionId);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,6 @@ export interface MoveRequest {
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
export type HlsSessionFolder = { ownerId: string; sessionId: string };
|
||||
|
||||
export type HlsVariantFolder = { ownerId: string; sessionId: string; variantIndex: number };
|
||||
|
||||
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
@@ -129,14 +125,6 @@ export class StorageCore {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
|
||||
static getHlsSessionFolder({ ownerId, sessionId }: HlsSessionFolder) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, ownerId, sessionId);
|
||||
}
|
||||
|
||||
static getHlsVariantFolder({ ownerId, sessionId, variantIndex }: HlsVariantFolder) {
|
||||
return join(StorageCore.getHlsSessionFolder({ ownerId, sessionId }), variantIndex.toString());
|
||||
}
|
||||
|
||||
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${uuid}-MP.mp4`);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,6 @@ const ServerFeaturesSchema = z
|
||||
search: z.boolean().describe('Whether search is enabled'),
|
||||
email: z.boolean().describe('Whether email notifications are enabled'),
|
||||
ocr: z.boolean().describe('Whether OCR is enabled'),
|
||||
realtimeTranscoding: z.boolean().describe('Whether real-time transcoding is enabled'),
|
||||
})
|
||||
.meta({ id: 'ServerFeaturesDto' });
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import z from 'zod';
|
||||
|
||||
const HlsSessionParamSchema = z.object({
|
||||
id: z.uuidv4(),
|
||||
sessionId: z.uuidv4(),
|
||||
});
|
||||
|
||||
export class HlsSessionParamDto extends createZodDto(HlsSessionParamSchema) {}
|
||||
|
||||
const HlsVariantParamSchema = z.object({
|
||||
id: z.uuidv4(),
|
||||
sessionId: z.uuidv4(),
|
||||
variantIndex: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
export class HlsVariantParamDto extends createZodDto(HlsVariantParamSchema) {}
|
||||
|
||||
const HlsSegmentParamSchema = z.object({
|
||||
id: z.uuidv4(),
|
||||
sessionId: z.uuidv4(),
|
||||
variantIndex: z.coerce.number().int().min(0),
|
||||
filename: z.string().regex(/^(init\.mp4|seg_\d+\.m4s)$/, { error: 'Invalid HLS segment filename' }),
|
||||
});
|
||||
|
||||
export class HlsSegmentParamDto extends createZodDto(HlsSegmentParamSchema) {}
|
||||
@@ -79,11 +79,6 @@ const SystemConfigFFmpegSchema = z
|
||||
accel: TranscodeHardwareAccelerationSchema,
|
||||
accelDecode: configBool.describe('Accelerated decode'),
|
||||
tonemap: ToneMappingSchema,
|
||||
realtime: z
|
||||
.object({
|
||||
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
|
||||
})
|
||||
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
|
||||
})
|
||||
.meta({ id: 'SystemConfigFFmpegDto' });
|
||||
|
||||
|
||||
+5
-4
@@ -452,7 +452,11 @@ export enum VideoCodec {
|
||||
|
||||
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
|
||||
|
||||
export type VideoSegmentCodec = VideoCodec.Av1 | VideoCodec.Hevc | VideoCodec.H264;
|
||||
export enum VideoSegmentCodec {
|
||||
Av1 = 'av1',
|
||||
Hevc = 'hevc',
|
||||
H264 = 'h264',
|
||||
}
|
||||
|
||||
export enum AudioCodec {
|
||||
Mp3 = 'mp3',
|
||||
@@ -822,8 +826,6 @@ export enum JobName {
|
||||
LibrarySyncFiles = 'LibrarySyncFiles',
|
||||
LibraryScanQueueAll = 'LibraryScanQueueAll',
|
||||
|
||||
HlsSessionCleanup = 'HlsSessionCleanup',
|
||||
|
||||
MemoryCleanup = 'MemoryCleanup',
|
||||
MemoryGenerate = 'MemoryGenerate',
|
||||
|
||||
@@ -917,7 +919,6 @@ export enum DatabaseLock {
|
||||
MaintenanceOperation = 621,
|
||||
MemoryCreation = 777,
|
||||
VersionCheck = 800,
|
||||
HlsSessionCleanup = 850,
|
||||
}
|
||||
|
||||
export enum MaintenanceAction {
|
||||
|
||||
@@ -7,7 +7,6 @@ from
|
||||
"video_stream_session"
|
||||
where
|
||||
"id" = $1
|
||||
and "expiresAt" > $2
|
||||
|
||||
-- VideoStreamRepository.getVariant
|
||||
select
|
||||
@@ -28,13 +27,11 @@ where
|
||||
|
||||
-- VideoStreamRepository.getExpiredSessions
|
||||
select
|
||||
"video_stream_session"."id",
|
||||
"asset"."ownerId"
|
||||
"id"
|
||||
from
|
||||
"video_stream_session"
|
||||
inner join "asset" on "asset"."id" = "video_stream_session"."assetId"
|
||||
where
|
||||
"video_stream_session"."expiresAt" <= $1
|
||||
"expiresAt" <= $1
|
||||
|
||||
-- VideoStreamRepository.extendSession
|
||||
update "video_stream_session"
|
||||
@@ -47,253 +44,3 @@ where
|
||||
delete from "video_stream_session"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- VideoStreamRepository.getForMainPlaylist
|
||||
select
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."index",
|
||||
"asset_video"."codecName",
|
||||
"asset_video"."profile",
|
||||
"asset_video"."level",
|
||||
"asset_video"."bitrate",
|
||||
"asset_exif"."exifImageWidth" as "width",
|
||||
"asset_exif"."exifImageHeight" as "height",
|
||||
"asset_video"."pixelFormat",
|
||||
"asset_video"."frameCount",
|
||||
"asset_exif"."fps" as "frameRate",
|
||||
"asset_video"."timeBase",
|
||||
case
|
||||
when "asset_exif"."orientation" = '6' then -90
|
||||
when "asset_exif"."orientation" = '8' then 90
|
||||
when "asset_exif"."orientation" = '3' then 180
|
||||
else 0
|
||||
end as "rotation",
|
||||
"asset_video"."colorPrimaries",
|
||||
"asset_video"."colorMatrix",
|
||||
"asset_video"."colorTransfer",
|
||||
"asset_video"."dvProfile",
|
||||
"asset_video"."dvLevel",
|
||||
"asset_video"."dvBlSignalCompatibilityId"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "videoStream",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_keyframe"."pts" as "keyframePts",
|
||||
"asset_keyframe"."accDuration" as "keyframeAccDuration",
|
||||
"asset_keyframe"."ownDuration" as "keyframeOwnDuration",
|
||||
"asset_keyframe"."totalDuration",
|
||||
"asset_keyframe"."packetCount",
|
||||
"asset_keyframe"."outputFrames"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_keyframe"."assetId" is not null
|
||||
) as obj
|
||||
) as "packets"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
inner join "asset_video" on "asset"."id" = "asset_video"."assetId"
|
||||
inner join "asset_keyframe" on "asset"."id" = "asset_keyframe"."assetId"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- VideoStreamRepository.getForMediaPlaylist
|
||||
select
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."index",
|
||||
"asset_video"."codecName",
|
||||
"asset_video"."profile",
|
||||
"asset_video"."level",
|
||||
"asset_video"."bitrate",
|
||||
"asset_exif"."exifImageWidth" as "width",
|
||||
"asset_exif"."exifImageHeight" as "height",
|
||||
"asset_video"."pixelFormat",
|
||||
"asset_video"."frameCount",
|
||||
"asset_exif"."fps" as "frameRate",
|
||||
"asset_video"."timeBase",
|
||||
case
|
||||
when "asset_exif"."orientation" = '6' then -90
|
||||
when "asset_exif"."orientation" = '8' then 90
|
||||
when "asset_exif"."orientation" = '3' then 180
|
||||
else 0
|
||||
end as "rotation",
|
||||
"asset_video"."colorPrimaries",
|
||||
"asset_video"."colorMatrix",
|
||||
"asset_video"."colorTransfer",
|
||||
"asset_video"."dvProfile",
|
||||
"asset_video"."dvLevel",
|
||||
"asset_video"."dvBlSignalCompatibilityId"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "videoStream",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_keyframe"."pts" as "keyframePts",
|
||||
"asset_keyframe"."accDuration" as "keyframeAccDuration",
|
||||
"asset_keyframe"."ownDuration" as "keyframeOwnDuration",
|
||||
"asset_keyframe"."totalDuration",
|
||||
"asset_keyframe"."packetCount",
|
||||
"asset_keyframe"."outputFrames"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_keyframe"."assetId" is not null
|
||||
) as obj
|
||||
) as "packets"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
inner join "video_stream_session" on "asset"."id" = "video_stream_session"."assetId"
|
||||
inner join "asset_video" on "asset"."id" = "asset_video"."assetId"
|
||||
inner join "asset_keyframe" on "asset"."id" = "asset_keyframe"."assetId"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "video_stream_session"."id" = $2
|
||||
and "video_stream_session"."expiresAt" > $3
|
||||
|
||||
-- VideoStreamRepository.getForTranscoding
|
||||
select
|
||||
"asset"."originalPath",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_audio"."index",
|
||||
"asset_audio"."codecName",
|
||||
"asset_audio"."profile",
|
||||
"asset_audio"."bitrate"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_audio"."assetId" is not null
|
||||
) as obj
|
||||
) as "audioStream",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."index",
|
||||
"asset_video"."codecName",
|
||||
"asset_video"."profile",
|
||||
"asset_video"."level",
|
||||
"asset_video"."bitrate",
|
||||
"asset_exif"."exifImageWidth" as "width",
|
||||
"asset_exif"."exifImageHeight" as "height",
|
||||
"asset_video"."pixelFormat",
|
||||
"asset_video"."frameCount",
|
||||
"asset_exif"."fps" as "frameRate",
|
||||
"asset_video"."timeBase",
|
||||
case
|
||||
when "asset_exif"."orientation" = '6' then -90
|
||||
when "asset_exif"."orientation" = '8' then 90
|
||||
when "asset_exif"."orientation" = '3' then 180
|
||||
else 0
|
||||
end as "rotation",
|
||||
"asset_video"."colorPrimaries",
|
||||
"asset_video"."colorMatrix",
|
||||
"asset_video"."colorTransfer",
|
||||
"asset_video"."dvProfile",
|
||||
"asset_video"."dvLevel",
|
||||
"asset_video"."dvBlSignalCompatibilityId"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "videoStream",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_video"."formatName",
|
||||
"asset_video"."formatLongName",
|
||||
"asset"."duration",
|
||||
"asset_video"."bitrate"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_video"."assetId" is not null
|
||||
) as obj
|
||||
) as "format",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_keyframe"."pts" as "keyframePts",
|
||||
"asset_keyframe"."accDuration" as "keyframeAccDuration",
|
||||
"asset_keyframe"."ownDuration" as "keyframeOwnDuration",
|
||||
"asset_keyframe"."totalDuration",
|
||||
"asset_keyframe"."packetCount",
|
||||
"asset_keyframe"."outputFrames"
|
||||
from
|
||||
(
|
||||
select
|
||||
1
|
||||
) as "dummy"
|
||||
where
|
||||
"asset_keyframe"."assetId" is not null
|
||||
) as obj
|
||||
) as "packets"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
left join "asset_audio" on "asset"."id" = "asset_audio"."assetId"
|
||||
inner join "asset_video" on "asset"."id" = "asset_video"."assetId"
|
||||
inner join "asset_keyframe" on "asset"."id" = "asset_keyframe"."assetId"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
@@ -92,14 +92,6 @@ type EventMap = {
|
||||
|
||||
AuthChangePassword: [{ userId: string; currentSessionId?: string; invalidateSessions?: boolean }];
|
||||
|
||||
// hls streaming events
|
||||
HlsSegmentRequest: [{ sessionId: string; assetId: string; variantIndex: number; segmentIndex: number }];
|
||||
HlsSegmentResult: [{ sessionId: string; variantIndex: number; segmentIndex: number; error?: string }];
|
||||
HlsHeartbeat: [{ sessionId: string; variantIndex?: number; segmentIndex?: number }];
|
||||
HlsSessionRequest: [{ sessionId: string; assetId: string; ownerId: string }];
|
||||
HlsSessionResult: [{ sessionId: string; error?: string }];
|
||||
HlsSessionEnd: [{ sessionId: string }];
|
||||
|
||||
// websocket events
|
||||
WebsocketConnect: [{ userId: string }];
|
||||
};
|
||||
|
||||
@@ -490,43 +490,18 @@ export class MediaRepository {
|
||||
return this.parseInt(b.bit_rate) - this.parseInt(a.bit_rate);
|
||||
}
|
||||
|
||||
/* Ported from https://code.ffmpeg.org/FFmpeg/FFmpeg/src/commit/5c44245878e235ae64fe87fb9877644856d33d1d/fftools/ffmpeg_filter.c
|
||||
* SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
* Copyright (c) FFmpeg authors and contributors — https://ffmpeg.org/
|
||||
* Modifications: TS port operating on probe-derived packet metadata rather than decoded AVFrames. */
|
||||
private cfrOutputFrames(packets: { pts: number; duration: number }[], slotsPerTick: number) {
|
||||
// Packets may be out of PTS order due to B-frames
|
||||
packets.sort((a, b) => a.pts - b.pts);
|
||||
const firstPts = packets[0].pts;
|
||||
let outputFrames = 0;
|
||||
let nextPts = 0;
|
||||
const history = [0, 0, 0];
|
||||
for (const pkt of packets) {
|
||||
const syncIpts = (pkt.pts - firstPts) * slotsPerTick;
|
||||
const duration = pkt.duration * slotsPerTick;
|
||||
let delta0 = syncIpts - nextPts;
|
||||
const delta = delta0 + duration;
|
||||
|
||||
if (delta0 < 0 && delta > 0) {
|
||||
delta0 = 0;
|
||||
}
|
||||
|
||||
let nb = 1;
|
||||
let nbPrev = 0;
|
||||
if (delta < -1.1) {
|
||||
nb = 0;
|
||||
} else if (delta > 1.1) {
|
||||
nb = Math.round(delta);
|
||||
if (delta0 > 1.1) {
|
||||
nbPrev = Math.round(delta0 - 0.6);
|
||||
}
|
||||
}
|
||||
const delta = (pkt.pts - firstPts) * slotsPerTick - nextPts + pkt.duration * slotsPerTick;
|
||||
const nb = delta < -1.1 ? 0 : delta > 1.1 ? Math.round(delta) : 1;
|
||||
outputFrames += nb;
|
||||
nextPts += nb;
|
||||
history[2] = history[1];
|
||||
history[1] = history[0];
|
||||
history[0] = nbPrev;
|
||||
}
|
||||
const median = history.sort((a, b) => a - b)[1];
|
||||
return outputFrames + median;
|
||||
return outputFrames;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { ChildProcessWithoutNullStreams, fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||
import { Duplex } from 'node:stream';
|
||||
|
||||
@Injectable()
|
||||
export class ProcessRepository {
|
||||
spawn = spawn;
|
||||
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
||||
return spawn(command, args, options);
|
||||
}
|
||||
|
||||
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
||||
let stdinClosed = false;
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
ReadOptionsWithBuffer,
|
||||
watch,
|
||||
} from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -278,8 +277,6 @@ export class StorageRepository {
|
||||
return () => watcher.close();
|
||||
}
|
||||
|
||||
watchDir = watch; // Native fs.watch without chokidar overhead
|
||||
|
||||
private asGlob(pathToCrawl: string): string {
|
||||
const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]');
|
||||
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
import { withAudioStream, withVideoFormat, withVideoPackets, withVideoStream } from 'src/utils/database';
|
||||
|
||||
@Injectable()
|
||||
export class VideoStreamRepository {
|
||||
@@ -28,12 +27,7 @@ export class VideoStreamRepository {
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getSession(id: string) {
|
||||
return this.db
|
||||
.selectFrom('video_stream_session')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.executeTakeFirst();
|
||||
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
@@ -53,12 +47,7 @@ export class VideoStreamRepository {
|
||||
|
||||
@GenerateSql()
|
||||
getExpiredSessions() {
|
||||
return this.db
|
||||
.selectFrom('video_stream_session')
|
||||
.innerJoin('asset', 'asset.id', 'video_stream_session.assetId')
|
||||
.select(['video_stream_session.id', 'asset.ownerId'])
|
||||
.where('video_stream_session.expiresAt', '<=', new Date())
|
||||
.execute();
|
||||
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
|
||||
@@ -70,50 +59,4 @@ export class VideoStreamRepository {
|
||||
async deleteSession(id: string) {
|
||||
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForMainPlaylist(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.where('asset.id', '=', id)
|
||||
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
|
||||
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
|
||||
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
|
||||
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async getForMediaPlaylist(id: string, sessionId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.innerJoin('video_stream_session', 'asset.id', 'video_stream_session.assetId')
|
||||
.where('asset.id', '=', id)
|
||||
.where('video_stream_session.id', '=', sessionId)
|
||||
.where('video_stream_session.expiresAt', '>', new Date())
|
||||
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
|
||||
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
|
||||
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
|
||||
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForTranscoding(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.where('asset.id', '=', id)
|
||||
.leftJoin('asset_audio', 'asset.id', 'asset_audio.assetId')
|
||||
.innerJoin('asset_video', 'asset.id', 'asset_video.assetId')
|
||||
.innerJoin('asset_keyframe', 'asset.id', 'asset_keyframe.assetId')
|
||||
.select('asset.originalPath')
|
||||
.select((eb) => withAudioStream(eb).as('audioStream'))
|
||||
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
|
||||
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
|
||||
.select((eb) => withVideoPackets(eb).$notNull().as('packets'))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,7 @@ import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
export const serverEvents = [
|
||||
'ConfigUpdate',
|
||||
'AppRestart',
|
||||
'HlsSegmentRequest',
|
||||
'HlsSegmentResult',
|
||||
'HlsHeartbeat',
|
||||
'HlsSessionRequest',
|
||||
'HlsSessionResult',
|
||||
'HlsSessionEnd',
|
||||
] as const;
|
||||
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
|
||||
export type ServerEvents = (typeof serverEvents)[number];
|
||||
|
||||
export interface ClientEventMap {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { registerEnum } from '@immich/sql-tools';
|
||||
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType, VideoCodec } from 'src/enum';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
SourceType,
|
||||
VideoSegmentCodec,
|
||||
} from 'src/enum';
|
||||
|
||||
export const album_user_role_enum = registerEnum({
|
||||
name: 'album_user_role_enum',
|
||||
@@ -28,5 +35,5 @@ export const asset_checksum_algorithm_enum = registerEnum({
|
||||
|
||||
export const video_stream_variant_codec_enum = registerEnum({
|
||||
name: 'video_stream_variant_codec_enum',
|
||||
values: [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264],
|
||||
values: Object.values(VideoSegmentCodec),
|
||||
});
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { TranscodeHardwareAcceleration } from 'src/enum';
|
||||
import { HlsService } from 'src/services/hls.service';
|
||||
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
// EXTINF values come from FFmpeg's playlist to enforce an exact match
|
||||
const eiffelExpectedMediaPlaylist = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:2.007222,
|
||||
seg_0.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_1.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_2.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_3.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_4.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_5.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_6.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_7.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_8.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_9.m4s
|
||||
#EXTINF:2.007222,
|
||||
seg_10.m4s
|
||||
#EXTINF:0.281011,
|
||||
seg_11.m4s
|
||||
#EXT-X-ENDLIST
|
||||
`;
|
||||
|
||||
const waterfallExpectedMediaPlaylist = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:2.011405,
|
||||
seg_0.m4s
|
||||
#EXTINF:2.011405,
|
||||
seg_1.m4s
|
||||
#EXTINF:2.011405,
|
||||
seg_2.m4s
|
||||
#EXTINF:2.011405,
|
||||
seg_3.m4s
|
||||
#EXTINF:2.011405,
|
||||
seg_4.m4s
|
||||
#EXTINF:0.301711,
|
||||
seg_5.m4s
|
||||
#EXT-X-ENDLIST
|
||||
`;
|
||||
|
||||
const trainExpectedMediaPlaylist = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:2.000000,
|
||||
seg_0.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_1.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_2.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_3.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_4.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_5.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_6.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_7.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_8.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg_9.m4s
|
||||
#EXTINF:1.733333,
|
||||
seg_10.m4s
|
||||
#EXT-X-ENDLIST
|
||||
`;
|
||||
|
||||
const sessionId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const eiffelExpectedMasterDisabled = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/0/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/3/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/6/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/8/playlist.m3u8
|
||||
`;
|
||||
|
||||
const eiffelExpectedMasterRkmpp = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/8/playlist.m3u8
|
||||
`;
|
||||
|
||||
const waterfallExpectedMasterDisabled = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/0/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/3/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/6/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/8/playlist.m3u8
|
||||
`;
|
||||
|
||||
describe(HlsService.name, () => {
|
||||
let sut: HlsService;
|
||||
let mocks: ServiceMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, mocks } = newTestService(HlsService));
|
||||
});
|
||||
|
||||
describe('getMainPlaylist', () => {
|
||||
const auth = factory.auth();
|
||||
const assetId = 'asset-1';
|
||||
|
||||
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
|
||||
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
|
||||
mocks.crypto.randomUUID.mockReturnValue(sessionId);
|
||||
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
|
||||
if (event === 'HlsSessionRequest') {
|
||||
const { sessionId: id } = rest[0] as { sessionId: string };
|
||||
queueMicrotask(() => sut.onSessionResult({ sessionId: id }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
it('returns main playlist for eiffel-tower (1080p portrait, no acceleration)', async () => {
|
||||
setup(eiffelTower, TranscodeHardwareAcceleration.Disabled);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterDisabled);
|
||||
});
|
||||
|
||||
it('returns main playlist for eiffel-tower with RKMPP (no AV1 variants)', async () => {
|
||||
setup(eiffelTower, TranscodeHardwareAcceleration.Rkmpp);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterRkmpp);
|
||||
});
|
||||
|
||||
it('returns main playlist for waterfall (4K landscape) with no acceleration', async () => {
|
||||
setup(waterfall, TranscodeHardwareAcceleration.Disabled);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterDisabled);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when realtime transcoding is disabled', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: false } } });
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when asset is not yet ready for streaming', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true } } });
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaPlaylist', () => {
|
||||
const auth = factory.auth();
|
||||
const assetId = 'asset-1';
|
||||
const fixtures = [
|
||||
{ data: eiffelTower, playlist: eiffelExpectedMediaPlaylist },
|
||||
{ data: waterfall, playlist: waterfallExpectedMediaPlaylist },
|
||||
{ data: train, playlist: trainExpectedMediaPlaylist },
|
||||
];
|
||||
|
||||
it.each(fixtures)('matches FFmpeg for $data.originalPath', async ({ data, playlist }) => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(data);
|
||||
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).resolves.toBe(playlist);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when the session/asset cannot be loaded', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
await expect(sut.getMediaPlaylist(auth, assetId, sessionId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSegment', () => {
|
||||
const auth = factory.auth();
|
||||
const assetId = 'asset-1';
|
||||
const variantIndex = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('emits HlsHeartbeat with segmentIndex 0 for the first init.mp4 request', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits HlsHeartbeat with the parsed segment number for seg_K.m4s', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns lastRequested + 1 for init.mp4 after a segment has been served', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
|
||||
mocks.websocket.serverSend.mockClear();
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates lastRequested on a backward-seek segment request', async () => {
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_3.m4s');
|
||||
mocks.websocket.serverSend.mockClear();
|
||||
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId,
|
||||
variantIndex,
|
||||
segmentIndex: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks segment state per session independently', async () => {
|
||||
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'seg_5.m4s');
|
||||
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'seg_2.m4s');
|
||||
mocks.websocket.serverSend.mockClear();
|
||||
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'init.mp4');
|
||||
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'init.mp4');
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId: 'session-a',
|
||||
variantIndex,
|
||||
segmentIndex: 6,
|
||||
});
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
||||
sessionId: 'session-b',
|
||||
variantIndex,
|
||||
segmentIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects pending waiters for the previous variant on variant change', async () => {
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
||||
|
||||
const pending = sut.getSegment(auth, assetId, sessionId, 0, 'seg_1.m4s');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await sut.getSegment(auth, assetId, sessionId, 1, 'seg_1.m4s');
|
||||
|
||||
await expect(pending).rejects.toThrow('Variant changed');
|
||||
});
|
||||
|
||||
it('throws NotFoundException when the session does not exist', async () => {
|
||||
mocks.videoStream.getSession.mockReset();
|
||||
await expect(sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endSession', () => {
|
||||
it('emits HlsSessionEnd', async () => {
|
||||
const auth = factory.auth();
|
||||
const assetId = 'asset-1';
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
await sut.endSession(auth, assetId, sessionId);
|
||||
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionEnd', { sessionId });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { constants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
HLS_SEGMENT_DURATION,
|
||||
HLS_SEGMENT_FILENAME_REGEX,
|
||||
HLS_VARIANTS,
|
||||
HLS_VERSION,
|
||||
SUPPORTED_HWA_CODECS,
|
||||
} from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { CacheControl, ImmichWorker, Permission } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
import { PendingEvents } from 'src/utils/event';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { getOutputSize } from 'src/utils/media';
|
||||
|
||||
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
|
||||
type ApiSession = { lastRequestedSegment: number | null; lastVariantIndex: number | null };
|
||||
|
||||
@Injectable()
|
||||
export class HlsService extends BaseService {
|
||||
private pendingSegments = new PendingEvents<'HlsSegmentResult'>({ timeoutMs: 15_000 });
|
||||
private pendingSessions = new PendingEvents<'HlsSessionResult'>({ timeoutMs: 5000 });
|
||||
private sessions = new Map<string, ApiSession>();
|
||||
|
||||
@OnEvent({ name: 'HlsSessionResult', server: true, workers: [ImmichWorker.Api] })
|
||||
onSessionResult(event: ArgOf<'HlsSessionResult'>) {
|
||||
this.pendingSessions.complete(event.sessionId, event);
|
||||
if (event.error) {
|
||||
this.sessions.delete(event.sessionId);
|
||||
this.pendingSegments.rejectByPrefix(`${event.sessionId}:`, event.error);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'HlsSessionEnd', server: true, workers: [ImmichWorker.Api] })
|
||||
onSessionEnd({ sessionId }: ArgOf<'HlsSessionEnd'>) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.pendingSegments.rejectByPrefix(`${sessionId}:`, 'Session ended');
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'HlsSegmentResult', server: true, workers: [ImmichWorker.Api] })
|
||||
onSegmentResult(event: ArgOf<'HlsSegmentResult'>) {
|
||||
this.pendingSegments.complete(this.getSegmentKey(event), event);
|
||||
}
|
||||
|
||||
async getMainPlaylist(auth: AuthDto, assetId: string) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
|
||||
const { ffmpeg } = await this.getConfig({ withCache: true });
|
||||
if (!ffmpeg.realtime.enabled) {
|
||||
throw new BadRequestException('Real-time transcoding is not enabled');
|
||||
}
|
||||
|
||||
const asset = await this.videoStreamRepository.getForMainPlaylist(assetId);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset is not yet ready for streaming');
|
||||
}
|
||||
|
||||
// Sharing the sessionId allows only one microservices worker to successfully insert to the session table.
|
||||
// The microservices worker that creates a session owns the transcoding lifecycle for it.
|
||||
const sessionId = this.cryptoRepository.randomUUID();
|
||||
this.websocketRepository.serverSend('HlsSessionRequest', { sessionId, assetId, ownerId: auth.user.id });
|
||||
await this.pendingSessions.wait(sessionId);
|
||||
this.trackSession(sessionId);
|
||||
|
||||
return this.generateMainPlaylist(sessionId, ffmpeg, asset);
|
||||
}
|
||||
|
||||
async getMediaPlaylist(auth: AuthDto, assetId: string, sessionId: string) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
|
||||
|
||||
const asset = await this.videoStreamRepository.getForMediaPlaylist(assetId, sessionId);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found or not yet ready for streaming');
|
||||
}
|
||||
|
||||
return this.generateMediaPlaylist(asset);
|
||||
}
|
||||
|
||||
async getSegment(auth: AuthDto, assetId: string, sessionId: string, variantIndex: number, filename: string) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
|
||||
|
||||
const session = await this.videoStreamRepository.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
|
||||
const variantDir = StorageCore.getHlsVariantFolder({ ownerId: auth.user.id, sessionId, variantIndex });
|
||||
const path = join(variantDir, filename);
|
||||
const response = new ImmichFileResponse({
|
||||
path,
|
||||
contentType: 'video/mp4',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
|
||||
const apiSession = this.trackSession(sessionId, variantIndex);
|
||||
const segmentIndex = this.getSegmentIndex(apiSession, filename);
|
||||
this.websocketRepository.serverSend('HlsHeartbeat', { sessionId, variantIndex, segmentIndex });
|
||||
|
||||
if (await this.storageRepository.checkFileExists(path, constants.R_OK)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
this.websocketRepository.serverSend('HlsSegmentRequest', { sessionId, assetId, variantIndex, segmentIndex });
|
||||
await this.pendingSegments.wait(this.getSegmentKey({ sessionId, variantIndex, segmentIndex }));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async endSession(auth: AuthDto, assetId: string, sessionId: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [assetId] });
|
||||
|
||||
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId });
|
||||
}
|
||||
|
||||
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
|
||||
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3);
|
||||
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
|
||||
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
|
||||
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`];
|
||||
for (let i = 0; i < HLS_VARIANTS.length; i++) {
|
||||
const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i];
|
||||
if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) {
|
||||
continue;
|
||||
}
|
||||
const { width, height } = getOutputSize(asset.videoStream, resolution);
|
||||
lines.push(
|
||||
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
|
||||
`${sessionId}/${i}/playlist.m3u8`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
if (lines.length === 3) {
|
||||
throw new NotFoundException('No supported variants for this video');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private generateMediaPlaylist({ videoStream, packets }: AssetWithStreamInfo) {
|
||||
const fps = (packets.packetCount * videoStream.timeBase) / packets.totalDuration;
|
||||
const framesPerSegment = Math.ceil(HLS_SEGMENT_DURATION * fps);
|
||||
const fullSegmentDuration = framesPerSegment / fps;
|
||||
const segmentCount = Math.ceil(packets.outputFrames / framesPerSegment);
|
||||
const lastSegmentFrames = packets.outputFrames - framesPerSegment * (segmentCount - 1);
|
||||
const lastSegmentDuration = lastSegmentFrames / fps;
|
||||
|
||||
const lines = [
|
||||
'#EXTM3U',
|
||||
`#EXT-X-VERSION:${HLS_VERSION}`,
|
||||
`#EXT-X-TARGETDURATION:${HLS_SEGMENT_DURATION}`,
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD',
|
||||
'#EXT-X-MAP:URI="init.mp4"',
|
||||
];
|
||||
|
||||
for (let i = 0; i < segmentCount - 1; i++) {
|
||||
lines.push(`#EXTINF:${fullSegmentDuration.toFixed(6)},`, `seg_${i}.m4s`);
|
||||
}
|
||||
lines.push(`#EXTINF:${lastSegmentDuration.toFixed(6)},`, `seg_${segmentCount - 1}.m4s`, '#EXT-X-ENDLIST', '');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private getSegmentKey({ sessionId, variantIndex, segmentIndex }: ArgOf<'HlsSegmentResult'>) {
|
||||
return `${sessionId}:${variantIndex}:${segmentIndex}`;
|
||||
}
|
||||
|
||||
private getSegmentIndex(session: ApiSession, filename: string) {
|
||||
if (filename.endsWith('.mp4')) {
|
||||
return (session.lastRequestedSegment ?? -1) + 1;
|
||||
}
|
||||
const segmentIndex = Number.parseInt(HLS_SEGMENT_FILENAME_REGEX.exec(filename)![1]);
|
||||
session.lastRequestedSegment = segmentIndex;
|
||||
return segmentIndex;
|
||||
}
|
||||
|
||||
private trackSession(id: string, variantIndex: number | null = null) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) {
|
||||
const newSession = { lastRequestedSegment: null, lastVariantIndex: variantIndex };
|
||||
this.sessions.set(id, newSession);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
if (session.lastVariantIndex !== null && session.lastVariantIndex !== variantIndex) {
|
||||
this.pendingSegments.rejectByPrefix(`${id}:${session.lastVariantIndex}:`, 'Variant changed');
|
||||
}
|
||||
session.lastVariantIndex = variantIndex;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { DatabaseService } from 'src/services/database.service';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { HlsService } from 'src/services/hls.service';
|
||||
import { JobService } from 'src/services/job.service';
|
||||
import { LibraryService } from 'src/services/library.service';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
@@ -40,7 +39,6 @@ import { SystemMetadataService } from 'src/services/system-metadata.service';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import { TelemetryService } from 'src/services/telemetry.service';
|
||||
import { TimelineService } from 'src/services/timeline.service';
|
||||
import { TranscodingService } from 'src/services/transcoding.service';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
@@ -63,7 +61,6 @@ export const services = [
|
||||
DatabaseService,
|
||||
DownloadService,
|
||||
DuplicateService,
|
||||
HlsService,
|
||||
JobService,
|
||||
LibraryService,
|
||||
MaintenanceService,
|
||||
@@ -92,7 +89,6 @@ export const services = [
|
||||
TagService,
|
||||
TelemetryService,
|
||||
TimelineService,
|
||||
TranscodingService,
|
||||
TrashService,
|
||||
UserAdminService,
|
||||
UserService,
|
||||
|
||||
@@ -41,7 +41,6 @@ describe(QueueService.name, () => {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.HlsSessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.MemoryGenerate },
|
||||
{ name: JobName.UserSyncUsage },
|
||||
|
||||
@@ -269,7 +269,6 @@ export class QueueService extends BaseService {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.HlsSessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user