mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 08:02:29 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d1186949 | |||
| 8a95ff03d2 |
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
|
|||||||
force: false,
|
force: false,
|
||||||
ids: [assetToTrash.id],
|
ids: [assetToTrash.id],
|
||||||
});
|
});
|
||||||
await page.keyboard.press('Escape');
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
await page.getByText('Trash', { exact: true }).click();
|
await page.getByText('Trash', { exact: true }).click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||||
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
|
|||||||
ids: [assetToArchive.id],
|
ids: [assetToArchive.id],
|
||||||
});
|
});
|
||||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||||
await page.keyboard.press('Escape');
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
await page.getByRole('link').getByText('Archive').click();
|
await page.getByRole('link').getByText('Archive').click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
|
|||||||
});
|
});
|
||||||
// ensure thumbnail still exists and has favorite icon
|
// ensure thumbnail still exists and has favorite icon
|
||||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||||
await page.keyboard.press('Escape');
|
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||||
await page.getByRole('link').getByText('Favorites').click();
|
await page.getByRole('link').getByText('Favorites').click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
||||||
|
|||||||
@@ -399,6 +399,10 @@
|
|||||||
"transcoding_preferred_hardware_device_description": "Applies only to VAAPI and QSV. Sets the dri node used for hardware transcoding.",
|
"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": "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_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": "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_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",
|
"transcoding_required_description": "Only videos not in an accepted format",
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import app.alextran.immich.images.LocalImageApi
|
|||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
import app.alextran.immich.images.RemoteImagesImpl
|
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.NativeSyncApi
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||||
@@ -46,9 +44,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
NativeSyncApiImpl30(ctx)
|
NativeSyncApiImpl30(ctx)
|
||||||
}
|
}
|
||||||
val permissionApiImpl = PermissionApiImpl(ctx)
|
|
||||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||||
PermissionApi.setUp(messenger, permissionApiImpl)
|
|
||||||
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
||||||
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
||||||
|
|
||||||
@@ -57,7 +53,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
|
|
||||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||||
flutterEngine.plugins.add(permissionApiImpl)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||||
@@ -65,8 +60,6 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||||
nativeApi?.detachFromEngine()
|
nativeApi?.detachFromEngine()
|
||||||
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
|
||||||
permissionApi?.detachFromEngine()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-96
@@ -1,96 +0,0 @@
|
|||||||
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
@@ -1,128 +0,0 @@
|
|||||||
// 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
@@ -1,37 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
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,7 +553,6 @@ interface NativeSyncApi {
|
|||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
|
||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -748,27 +747,6 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
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 {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import com.bumptech.glide.Glide
|
|||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -41,11 +39,10 @@ sealed class AssetResult {
|
|||||||
private const val TAG = "NativeSyncApiImplBase"
|
private const val TAG = "NativeSyncApiImplBase"
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
private var hashTask: Job? = null
|
private var hashTask: Job? = null
|
||||||
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||||
@@ -451,26 +448,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
|||||||
hashTask = null
|
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
|
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||||
@Suppress("unused", "UNUSED_PARAMETER")
|
@Suppress("unused", "UNUSED_PARAMETER")
|
||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.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 */; };
|
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 */; };
|
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 */; };
|
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
||||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
@@ -107,8 +105,6 @@
|
|||||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -287,7 +283,6 @@
|
|||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
B2EE00052E72CA15008B6CA7 /* Permission */,
|
|
||||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
||||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@@ -322,15 +317,6 @@
|
|||||||
path = Connectivity;
|
path = Connectivity;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B2EE00052E72CA15008B6CA7 /* Permission */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
|
|
||||||
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
|
|
||||||
);
|
|
||||||
path = Permission;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -633,8 +619,6 @@
|
|||||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
||||||
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.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 */,
|
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import native_video_player
|
|||||||
|
|
||||||
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
||||||
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
|
|
||||||
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
||||||
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
||||||
|
|||||||
-106
@@ -1,106 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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,7 +537,6 @@ protocol NativeSyncApi {
|
|||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
|
||||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,24 +721,6 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
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
|
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)
|
||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
|||||||
@@ -382,10 +382,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
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> {
|
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||||
// Ensure to actually getting all assets for the Recents album
|
// 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/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.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/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/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
|
||||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -23,29 +23,29 @@ class LocalSyncService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final LocalFilesManagerRepository _localFilesManager;
|
||||||
final IPermissionRepository _permissionRepository;
|
final StorageRepository _storageRepository;
|
||||||
final Logger _log = Logger("DeviceSyncService");
|
final Logger _log = Logger("DeviceSyncService");
|
||||||
|
|
||||||
LocalSyncService({
|
LocalSyncService({
|
||||||
required DriftLocalAlbumRepository localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required AssetMediaRepository assetMediaRepository,
|
required LocalFilesManagerRepository localFilesManager,
|
||||||
required IPermissionRepository permissionRepository,
|
required StorageRepository storageRepository,
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_assetMediaRepository = assetMediaRepository,
|
_localFilesManager = localFilesManager,
|
||||||
_permissionRepository = permissionRepository,
|
_storageRepository = storageRepository,
|
||||||
_nativeSyncApi = nativeSyncApi;
|
_nativeSyncApi = nativeSyncApi;
|
||||||
|
|
||||||
Future<void> sync({bool full = false}) async {
|
Future<void> sync({bool full = false}) async {
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||||
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
await _syncTrashedAssets();
|
await _syncTrashedAssets();
|
||||||
} else {
|
} else {
|
||||||
@@ -373,7 +373,7 @@ class LocalSyncService {
|
|||||||
|
|
||||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||||
if (assetsToRestore.isNotEmpty) {
|
if (assetsToRestore.isNotEmpty) {
|
||||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||||
} else {
|
} else {
|
||||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||||
@@ -381,15 +381,15 @@ class LocalSyncService {
|
|||||||
|
|
||||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||||
if (localAssetsToTrash.isNotEmpty) {
|
if (localAssetsToTrash.isNotEmpty) {
|
||||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
final mediaUrls = await Future.wait(
|
||||||
_log.info("Moving to trash ${localIds.join(", ")} assets");
|
localAssetsToTrash.values
|
||||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
.expand((e) => e)
|
||||||
if (movedIds.isNotEmpty) {
|
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
);
|
||||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||||
)..removeWhere((_, assets) => assets.isEmpty);
|
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||||
|
if (result) {
|
||||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
_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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.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/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_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.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/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -34,8 +34,8 @@ class SyncStreamService {
|
|||||||
final SyncStreamRepository _syncStreamRepository;
|
final SyncStreamRepository _syncStreamRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final AssetMediaRepository _assetMediaRepository;
|
final LocalFilesManagerRepository _localFilesManager;
|
||||||
final IPermissionRepository _permissionRepository;
|
final StorageRepository _storageRepository;
|
||||||
final SyncMigrationRepository _syncMigrationRepository;
|
final SyncMigrationRepository _syncMigrationRepository;
|
||||||
final ApiService _api;
|
final ApiService _api;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
@@ -45,8 +45,8 @@ class SyncStreamService {
|
|||||||
required SyncStreamRepository syncStreamRepository,
|
required SyncStreamRepository syncStreamRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required AssetMediaRepository assetMediaRepository,
|
required LocalFilesManagerRepository localFilesManager,
|
||||||
required IPermissionRepository permissionRepository,
|
required StorageRepository storageRepository,
|
||||||
required SyncMigrationRepository syncMigrationRepository,
|
required SyncMigrationRepository syncMigrationRepository,
|
||||||
required ApiService api,
|
required ApiService api,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
@@ -54,8 +54,8 @@ class SyncStreamService {
|
|||||||
_syncStreamRepository = syncStreamRepository,
|
_syncStreamRepository = syncStreamRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_assetMediaRepository = assetMediaRepository,
|
_localFilesManager = localFilesManager,
|
||||||
_permissionRepository = permissionRepository,
|
_storageRepository = storageRepository,
|
||||||
_syncMigrationRepository = syncMigrationRepository,
|
_syncMigrationRepository = syncMigrationRepository,
|
||||||
_api = api,
|
_api = api,
|
||||||
_cancelChecker = cancelChecker;
|
_cancelChecker = cancelChecker;
|
||||||
@@ -500,22 +500,22 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
final mediaUrls = await Future.wait(
|
||||||
_logger.info("Moving to trash ${localIds.join(", ")} assets");
|
localAssetsToTrash.values
|
||||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
.expand((e) => e)
|
||||||
if (movedIds.isNotEmpty) {
|
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
);
|
||||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||||
)..removeWhere((_, assets) => assets.isEmpty);
|
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||||
|
if (result) {
|
||||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyRemoteRestoreToLocal() async {
|
Future<void> _applyRemoteRestoreToLocal() async {
|
||||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||||
if (assetsToRestore.isNotEmpty) {
|
if (assetsToRestore.isNotEmpty) {
|
||||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||||
} else {
|
} else {
|
||||||
_logger.info("No remote assets found for restoration");
|
_logger.info("No remote assets found for restoration");
|
||||||
@@ -523,7 +523,7 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -533,7 +533,7 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
-19
@@ -654,25 +654,6 @@ class NativeSyncApi {
|
|||||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
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 {
|
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||||
|
|||||||
-119
@@ -1,119 +0,0 @@
|
|||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,9 @@ 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_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/background_worker_lock_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/connectivity_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/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/network_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';
|
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||||
|
|
||||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||||
@@ -17,8 +16,6 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
|
|||||||
|
|
||||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||||
|
|
||||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
|
||||||
|
|
||||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||||
|
|
||||||
final localImageApi = LocalImageApi();
|
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/cancel.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.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/platform.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
|
|
||||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ final syncStreamServiceProvider = Provider(
|
|||||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
storageRepository: ref.watch(storageRepositoryProvider),
|
||||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||||
api: ref.watch(apiServiceProvider),
|
api: ref.watch(apiServiceProvider),
|
||||||
cancelChecker: ref.watch(cancellationProvider),
|
cancelChecker: ref.watch(cancellationProvider),
|
||||||
@@ -39,8 +39,8 @@ final localSyncServiceProvider = Provider(
|
|||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
storageRepository: ref.watch(storageRepositoryProvider),
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,24 +8,19 @@ 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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/response_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:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
final assetMediaRepositoryProvider = Provider(
|
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
||||||
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
|
||||||
);
|
|
||||||
|
|
||||||
class AssetMediaRepository {
|
class AssetMediaRepository {
|
||||||
final AssetApiRepository _assetApiRepository;
|
final AssetApiRepository _assetApiRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
|
||||||
static final Logger _log = Logger("AssetMediaRepository");
|
static final Logger _log = Logger("AssetMediaRepository");
|
||||||
|
|
||||||
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
const AssetMediaRepository(this._assetApiRepository);
|
||||||
|
|
||||||
Future<bool> _androidSupportsTrash() async {
|
Future<bool> _androidSupportsTrash() async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
@@ -50,27 +45,6 @@ class AssetMediaRepository {
|
|||||||
return PhotoManager.editor.deleteWithIds(ids);
|
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 {
|
Future<AssetEntity?> get(String id) async {
|
||||||
final entity = await AssetEntity.fromId(id);
|
final entity = await AssetEntity.fromId(id);
|
||||||
return entity;
|
return entity;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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,16 +1,12 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
final permissionRepositoryProvider = Provider((ref) {
|
final permissionRepositoryProvider = Provider((_) {
|
||||||
return PermissionRepository(ref.watch(permissionApiProvider));
|
return const PermissionRepository();
|
||||||
});
|
});
|
||||||
|
|
||||||
class PermissionRepository implements IPermissionRepository {
|
class PermissionRepository implements IPermissionRepository {
|
||||||
final PermissionApi _permissionApi;
|
const PermissionRepository();
|
||||||
|
|
||||||
const PermissionRepository(this._permissionApi);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> hasLocationWhenInUsePermission() {
|
Future<bool> hasLocationWhenInUsePermission() {
|
||||||
@@ -38,21 +34,6 @@ class PermissionRepository implements IPermissionRepository {
|
|||||||
Future<bool> openSettings() {
|
Future<bool> openSettings() {
|
||||||
return openAppSettings();
|
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 {
|
abstract interface class IPermissionRepository {
|
||||||
@@ -61,7 +42,4 @@ abstract interface class IPermissionRepository {
|
|||||||
Future<bool> hasLocationAlwaysPermission();
|
Future<bool> hasLocationAlwaysPermission();
|
||||||
Future<bool> requestLocationAlwaysPermission();
|
Future<bool> requestLocationAlwaysPermission();
|
||||||
Future<bool> openSettings();
|
Future<bool> openSettings();
|
||||||
Future<bool> hasManageMediaPermission();
|
|
||||||
Future<bool> requestManageMediaPermission();
|
|
||||||
Future<bool> manageMediaPermission();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getManageMediaPermission() async {
|
getManageMediaPermission() async {
|
||||||
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
|
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -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/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.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/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||||
@@ -57,7 +57,9 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
() async {
|
() async {
|
||||||
isManageMediaSupported.value = await checkAndroidVersion();
|
isManageMediaSupported.value = await checkAndroidVersion();
|
||||||
if (isManageMediaSupported.value) {
|
if (isManageMediaSupported.value) {
|
||||||
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
manageMediaAndroidPermission.value = await ref
|
||||||
|
.read(localFilesManagerRepositoryProvider)
|
||||||
|
.hasManageMediaPermission();
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
return null;
|
return null;
|
||||||
@@ -80,7 +82,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value) {
|
if (value) {
|
||||||
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
|
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||||
manageLocalMediaAndroid.value = result;
|
manageLocalMediaAndroid.value = result;
|
||||||
manageMediaAndroidPermission.value = result;
|
manageMediaAndroidPermission.value = result;
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
? const Color.fromARGB(255, 243, 188, 106)
|
? const Color.fromARGB(255, 243, 188, 106)
|
||||||
: null,
|
: null,
|
||||||
onActionTap: () async {
|
onActionTap: () async {
|
||||||
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
||||||
manageMediaAndroidPermission.value = result;
|
manageMediaAndroidPermission.value = result;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Generated
+5
@@ -103,12 +103,16 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata
|
*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* | [**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* | [**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* | [**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* | [**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* | [**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* | [**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* | [**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* | [**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* | [**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* | [**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
|
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
|
||||||
@@ -594,6 +598,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
|
- [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md)
|
||||||
- [SystemConfigDto](doc//SystemConfigDto.md)
|
- [SystemConfigDto](doc//SystemConfigDto.md)
|
||||||
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
|
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
|
||||||
|
- [SystemConfigFFmpegRealtimeDto](doc//SystemConfigFFmpegRealtimeDto.md)
|
||||||
- [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
|
- [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
|
||||||
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
|
- [SystemConfigGeneratedFullsizeImageDto](doc//SystemConfigGeneratedFullsizeImageDto.md)
|
||||||
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
|
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
|
||||||
|
|||||||
Generated
+1
@@ -340,6 +340,7 @@ part 'model/sync_user_v1.dart';
|
|||||||
part 'model/system_config_backups_dto.dart';
|
part 'model/system_config_backups_dto.dart';
|
||||||
part 'model/system_config_dto.dart';
|
part 'model/system_config_dto.dart';
|
||||||
part 'model/system_config_f_fmpeg_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_faces_dto.dart';
|
||||||
part 'model/system_config_generated_fullsize_image_dto.dart';
|
part 'model/system_config_generated_fullsize_image_dto.dart';
|
||||||
part 'model/system_config_generated_image_dto.dart';
|
part 'model/system_config_generated_image_dto.dart';
|
||||||
|
|||||||
Generated
+310
@@ -416,6 +416,75 @@ class AssetsApi {
|
|||||||
return null;
|
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 edits for an existing asset
|
||||||
///
|
///
|
||||||
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
|
/// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.
|
||||||
@@ -809,6 +878,247 @@ class AssetsApi {
|
|||||||
return null;
|
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
|
/// Play asset video
|
||||||
///
|
///
|
||||||
/// Streams the video file for the specified asset. This endpoint also supports byte range requests.
|
/// Streams the video file for the specified asset. This endpoint also supports byte range requests.
|
||||||
|
|||||||
Generated
+2
@@ -726,6 +726,8 @@ class ApiClient {
|
|||||||
return SystemConfigDto.fromJson(value);
|
return SystemConfigDto.fromJson(value);
|
||||||
case 'SystemConfigFFmpegDto':
|
case 'SystemConfigFFmpegDto':
|
||||||
return SystemConfigFFmpegDto.fromJson(value);
|
return SystemConfigFFmpegDto.fromJson(value);
|
||||||
|
case 'SystemConfigFFmpegRealtimeDto':
|
||||||
|
return SystemConfigFFmpegRealtimeDto.fromJson(value);
|
||||||
case 'SystemConfigFacesDto':
|
case 'SystemConfigFacesDto':
|
||||||
return SystemConfigFacesDto.fromJson(value);
|
return SystemConfigFacesDto.fromJson(value);
|
||||||
case 'SystemConfigGeneratedFullsizeImageDto':
|
case 'SystemConfigGeneratedFullsizeImageDto':
|
||||||
|
|||||||
Generated
+3
@@ -52,6 +52,7 @@ class JobName {
|
|||||||
static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll');
|
static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll');
|
||||||
static const librarySyncFiles = JobName._(r'LibrarySyncFiles');
|
static const librarySyncFiles = JobName._(r'LibrarySyncFiles');
|
||||||
static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll');
|
static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll');
|
||||||
|
static const hlsSessionCleanup = JobName._(r'HlsSessionCleanup');
|
||||||
static const memoryCleanup = JobName._(r'MemoryCleanup');
|
static const memoryCleanup = JobName._(r'MemoryCleanup');
|
||||||
static const memoryGenerate = JobName._(r'MemoryGenerate');
|
static const memoryGenerate = JobName._(r'MemoryGenerate');
|
||||||
static const notificationsCleanup = JobName._(r'NotificationsCleanup');
|
static const notificationsCleanup = JobName._(r'NotificationsCleanup');
|
||||||
@@ -110,6 +111,7 @@ class JobName {
|
|||||||
librarySyncFilesQueueAll,
|
librarySyncFilesQueueAll,
|
||||||
librarySyncFiles,
|
librarySyncFiles,
|
||||||
libraryScanQueueAll,
|
libraryScanQueueAll,
|
||||||
|
hlsSessionCleanup,
|
||||||
memoryCleanup,
|
memoryCleanup,
|
||||||
memoryGenerate,
|
memoryGenerate,
|
||||||
notificationsCleanup,
|
notificationsCleanup,
|
||||||
@@ -203,6 +205,7 @@ class JobNameTypeTransformer {
|
|||||||
case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll;
|
case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll;
|
||||||
case r'LibrarySyncFiles': return JobName.librarySyncFiles;
|
case r'LibrarySyncFiles': return JobName.librarySyncFiles;
|
||||||
case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll;
|
case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll;
|
||||||
|
case r'HlsSessionCleanup': return JobName.hlsSessionCleanup;
|
||||||
case r'MemoryCleanup': return JobName.memoryCleanup;
|
case r'MemoryCleanup': return JobName.memoryCleanup;
|
||||||
case r'MemoryGenerate': return JobName.memoryGenerate;
|
case r'MemoryGenerate': return JobName.memoryGenerate;
|
||||||
case r'NotificationsCleanup': return JobName.notificationsCleanup;
|
case r'NotificationsCleanup': return JobName.notificationsCleanup;
|
||||||
|
|||||||
+9
-1
@@ -25,6 +25,7 @@ class SystemConfigFFmpegDto {
|
|||||||
required this.maxBitrate,
|
required this.maxBitrate,
|
||||||
required this.preferredHwDevice,
|
required this.preferredHwDevice,
|
||||||
required this.preset,
|
required this.preset,
|
||||||
|
required this.realtime,
|
||||||
required this.refs,
|
required this.refs,
|
||||||
required this.targetAudioCodec,
|
required this.targetAudioCodec,
|
||||||
required this.targetResolution,
|
required this.targetResolution,
|
||||||
@@ -79,6 +80,8 @@ class SystemConfigFFmpegDto {
|
|||||||
/// Preset
|
/// Preset
|
||||||
String preset;
|
String preset;
|
||||||
|
|
||||||
|
SystemConfigFFmpegRealtimeDto realtime;
|
||||||
|
|
||||||
/// References
|
/// References
|
||||||
///
|
///
|
||||||
/// Minimum value: 0
|
/// Minimum value: 0
|
||||||
@@ -122,6 +125,7 @@ class SystemConfigFFmpegDto {
|
|||||||
other.maxBitrate == maxBitrate &&
|
other.maxBitrate == maxBitrate &&
|
||||||
other.preferredHwDevice == preferredHwDevice &&
|
other.preferredHwDevice == preferredHwDevice &&
|
||||||
other.preset == preset &&
|
other.preset == preset &&
|
||||||
|
other.realtime == realtime &&
|
||||||
other.refs == refs &&
|
other.refs == refs &&
|
||||||
other.targetAudioCodec == targetAudioCodec &&
|
other.targetAudioCodec == targetAudioCodec &&
|
||||||
other.targetResolution == targetResolution &&
|
other.targetResolution == targetResolution &&
|
||||||
@@ -147,6 +151,7 @@ class SystemConfigFFmpegDto {
|
|||||||
(maxBitrate.hashCode) +
|
(maxBitrate.hashCode) +
|
||||||
(preferredHwDevice.hashCode) +
|
(preferredHwDevice.hashCode) +
|
||||||
(preset.hashCode) +
|
(preset.hashCode) +
|
||||||
|
(realtime.hashCode) +
|
||||||
(refs.hashCode) +
|
(refs.hashCode) +
|
||||||
(targetAudioCodec.hashCode) +
|
(targetAudioCodec.hashCode) +
|
||||||
(targetResolution.hashCode) +
|
(targetResolution.hashCode) +
|
||||||
@@ -158,7 +163,7 @@ class SystemConfigFFmpegDto {
|
|||||||
(twoPass.hashCode);
|
(twoPass.hashCode);
|
||||||
|
|
||||||
@override
|
@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, 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, realtime=$realtime, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -174,6 +179,7 @@ class SystemConfigFFmpegDto {
|
|||||||
json[r'maxBitrate'] = this.maxBitrate;
|
json[r'maxBitrate'] = this.maxBitrate;
|
||||||
json[r'preferredHwDevice'] = this.preferredHwDevice;
|
json[r'preferredHwDevice'] = this.preferredHwDevice;
|
||||||
json[r'preset'] = this.preset;
|
json[r'preset'] = this.preset;
|
||||||
|
json[r'realtime'] = this.realtime;
|
||||||
json[r'refs'] = this.refs;
|
json[r'refs'] = this.refs;
|
||||||
json[r'targetAudioCodec'] = this.targetAudioCodec;
|
json[r'targetAudioCodec'] = this.targetAudioCodec;
|
||||||
json[r'targetResolution'] = this.targetResolution;
|
json[r'targetResolution'] = this.targetResolution;
|
||||||
@@ -207,6 +213,7 @@ class SystemConfigFFmpegDto {
|
|||||||
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
|
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
|
||||||
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
|
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
|
||||||
preset: mapValueOfType<String>(json, r'preset')!,
|
preset: mapValueOfType<String>(json, r'preset')!,
|
||||||
|
realtime: SystemConfigFFmpegRealtimeDto.fromJson(json[r'realtime'])!,
|
||||||
refs: mapValueOfType<int>(json, r'refs')!,
|
refs: mapValueOfType<int>(json, r'refs')!,
|
||||||
targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!,
|
targetAudioCodec: AudioCodec.fromJson(json[r'targetAudioCodec'])!,
|
||||||
targetResolution: mapValueOfType<String>(json, r'targetResolution')!,
|
targetResolution: mapValueOfType<String>(json, r'targetResolution')!,
|
||||||
@@ -275,6 +282,7 @@ class SystemConfigFFmpegDto {
|
|||||||
'maxBitrate',
|
'maxBitrate',
|
||||||
'preferredHwDevice',
|
'preferredHwDevice',
|
||||||
'preset',
|
'preset',
|
||||||
|
'realtime',
|
||||||
'refs',
|
'refs',
|
||||||
'targetAudioCodec',
|
'targetAudioCodec',
|
||||||
'targetResolution',
|
'targetResolution',
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//
|
||||||
|
// 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,7 +11,14 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
dartPackageName: 'immich_mobile',
|
dartPackageName: 'immich_mobile',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
enum PlatformAssetPlaybackStyle {
|
||||||
|
unknown,
|
||||||
|
image,
|
||||||
|
video,
|
||||||
|
imageAnimated,
|
||||||
|
livePhoto,
|
||||||
|
videoLooping,
|
||||||
|
}
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -135,9 +142,6 @@ abstract class NativeSyncApi {
|
|||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||||
|
|
||||||
@async
|
|
||||||
bool restoreFromTrashById(String mediaId, int type);
|
|
||||||
|
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -10,15 +10,17 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.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_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.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/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.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/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../../domain/service.mock.dart';
|
import '../../domain/service.mock.dart';
|
||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
import '../../mocks/asset_entity.mock.dart';
|
||||||
import '../../repository.mocks.dart';
|
import '../../repository.mocks.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -26,8 +28,8 @@ void main() {
|
|||||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||||
late AssetMediaRepository mockAssetMediaRepository;
|
late LocalFilesManagerRepository mockLocalFilesManager;
|
||||||
late MockPermissionRepository mockPermissionRepository;
|
late StorageRepository mockStorageRepository;
|
||||||
late MockNativeSyncApi mockNativeSyncApi;
|
late MockNativeSyncApi mockNativeSyncApi;
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
||||||
@@ -49,8 +51,8 @@ void main() {
|
|||||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
||||||
mockPermissionRepository = MockPermissionRepository();
|
mockStorageRepository = MockStorageRepository();
|
||||||
mockNativeSyncApi = MockNativeSyncApi();
|
mockNativeSyncApi = MockNativeSyncApi();
|
||||||
|
|
||||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||||
@@ -63,28 +65,25 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||||
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
|
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
||||||
final ids = invocation.positionalArguments.first as List<String>;
|
|
||||||
return ids;
|
|
||||||
});
|
|
||||||
|
|
||||||
sut = LocalSyncService(
|
sut = LocalSyncService(
|
||||||
localAlbumRepository: mockLocalAlbumRepository,
|
localAlbumRepository: mockLocalAlbumRepository,
|
||||||
localAssetRepository: mockLocalAssetRepository,
|
localAssetRepository: mockLocalAssetRepository,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||||
assetMediaRepository: mockAssetMediaRepository,
|
localFilesManager: mockLocalFilesManager,
|
||||||
permissionRepository: mockPermissionRepository,
|
storageRepository: mockStorageRepository,
|
||||||
nativeSyncApi: mockNativeSyncApi,
|
nativeSyncApi: mockNativeSyncApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ void main() {
|
|||||||
|
|
||||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -103,7 +102,7 @@ void main() {
|
|||||||
|
|
||||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ void main() {
|
|||||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||||
|
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -132,13 +131,13 @@ void main() {
|
|||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
orientation: 0,
|
orientation: 0,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetsToRestore = [LocalAssetStub.image1];
|
final assetsToRestore = [LocalAssetStub.image1];
|
||||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||||
final restoredIds = ['image1'];
|
final restoredIds = ['image1'];
|
||||||
when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||||
expect(requested, orderedEquals(assetsToRestore));
|
expect(requested, orderedEquals(assetsToRestore));
|
||||||
return restoredIds;
|
return restoredIds;
|
||||||
@@ -151,6 +150,10 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final assetEntity = MockAssetEntity();
|
||||||
|
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
||||||
|
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
||||||
|
|
||||||
await sut.processTrashedAssets({
|
await sut.processTrashedAssets({
|
||||||
'album-a': [platformAsset],
|
'album-a': [platformAsset],
|
||||||
});
|
});
|
||||||
@@ -165,11 +168,12 @@ void main() {
|
|||||||
expect(trashedEntry.asset.name, platformAsset.name);
|
expect(trashedEntry.asset.name, platformAsset.name);
|
||||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||||
|
|
||||||
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
|
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
||||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||||
|
|
||||||
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
|
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
||||||
expect(moveArgs, ['local-trash']);
|
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
||||||
|
expect(moveArgs, ['content://local-trash']);
|
||||||
final trashArgs =
|
final trashArgs =
|
||||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||||
as Map<String, List<LocalAsset>>;
|
as Map<String, List<LocalAsset>>;
|
||||||
@@ -177,26 +181,6 @@ void main() {
|
|||||||
expect(trashArgs['album-a'], [localAssetToTrash]);
|
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 {
|
test('does not attempt restore when repository has no assets to restore', () async {
|
||||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||||
|
|
||||||
@@ -206,7 +190,7 @@ void main() {
|
|||||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||||
as Iterable<TrashedAsset>;
|
as Iterable<TrashedAsset>;
|
||||||
expect(trashedSnapshot, isEmpty);
|
expect(trashedSnapshot, isEmpty);
|
||||||
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
|
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +199,7 @@ void main() {
|
|||||||
|
|
||||||
await sut.processTrashedAssets({});
|
await sut.processTrashedAssets({});
|
||||||
|
|
||||||
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
|
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -231,7 +215,7 @@ void main() {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
createdAt: 1700000000,
|
createdAt: 1700000000,
|
||||||
updatedAt: 1732000000,
|
updatedAt: 1732000000,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||||
);
|
);
|
||||||
|
|
||||||
final localAsset = platformAsset.toLocalAsset();
|
final localAsset = platformAsset.toLocalAsset();
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.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/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/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.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/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@@ -25,6 +26,7 @@ import '../../api.mocks.dart';
|
|||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../fixtures/sync_stream.stub.dart';
|
import '../../fixtures/sync_stream.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
|
import '../../mocks/asset_entity.mock.dart';
|
||||||
import '../../repository.mocks.dart';
|
import '../../repository.mocks.dart';
|
||||||
import '../../service.mocks.dart';
|
import '../../service.mocks.dart';
|
||||||
|
|
||||||
@@ -50,8 +52,8 @@ void main() {
|
|||||||
late SyncApiRepository mockSyncApiRepo;
|
late SyncApiRepository mockSyncApiRepo;
|
||||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||||
late AssetMediaRepository mockAssetMediaRepo;
|
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
||||||
late MockPermissionRepository mockPermissionRepo;
|
late StorageRepository mockStorageRepo;
|
||||||
late MockApiService mockApi;
|
late MockApiService mockApi;
|
||||||
late MockServerApi mockServerApi;
|
late MockServerApi mockServerApi;
|
||||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||||
@@ -84,8 +86,8 @@ void main() {
|
|||||||
mockSyncApiRepo = MockSyncApiRepository();
|
mockSyncApiRepo = MockSyncApiRepository();
|
||||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||||
mockAssetMediaRepo = MockAssetMediaRepository();
|
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
||||||
mockPermissionRepo = MockPermissionRepository();
|
mockStorageRepo = MockStorageRepository();
|
||||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
mockApi = MockApiService();
|
mockApi = MockApiService();
|
||||||
@@ -157,8 +159,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
assetMediaRepository: mockAssetMediaRepo,
|
localFilesManager: mockLocalFilesManagerRepo,
|
||||||
permissionRepository: mockPermissionRepo,
|
storageRepository: mockStorageRepo,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
);
|
);
|
||||||
@@ -168,12 +170,10 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||||
hasManageMediaPermission = false;
|
hasManageMediaPermission = false;
|
||||||
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
||||||
final ids = invocation.positionalArguments.first as List<String>;
|
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||||
return ids;
|
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
||||||
});
|
|
||||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,8 +241,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
assetMediaRepository: mockAssetMediaRepo,
|
localFilesManager: mockLocalFilesManagerRepo,
|
||||||
permissionRepository: mockPermissionRepo,
|
storageRepository: mockStorageRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
@@ -282,8 +282,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
assetMediaRepository: mockAssetMediaRepo,
|
localFilesManager: mockLocalFilesManagerRepo,
|
||||||
permissionRepository: mockPermissionRepo,
|
storageRepository: mockStorageRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
@@ -424,10 +424,18 @@ void main() {
|
|||||||
return assetsByAlbum;
|
return assetsByAlbum;
|
||||||
});
|
});
|
||||||
|
|
||||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
final localEntity = MockAssetEntity();
|
||||||
final ids = invocation.positionalArguments.first as List<String>;
|
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
||||||
expect(ids, unorderedEquals(['local-only', 'merged-local']));
|
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
||||||
return ids;
|
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
final events = [
|
final events = [
|
||||||
@@ -453,51 +461,10 @@ void main() {
|
|||||||
|
|
||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
final trashArgs =
|
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
||||||
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);
|
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 {
|
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||||
final events = [
|
final events = [
|
||||||
SyncStreamStub.assetTrashed(
|
SyncStreamStub.assetTrashed(
|
||||||
@@ -511,7 +478,7 @@ void main() {
|
|||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -527,7 +494,7 @@ void main() {
|
|||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -538,7 +505,7 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||||
|
|
||||||
final restoredIds = ['trashed-1'];
|
final restoredIds = ['trashed-1'];
|
||||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||||
return restoredIds;
|
return restoredIds;
|
||||||
|
|||||||
@@ -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.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||||
|
|
||||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||||
|
|
||||||
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
|
||||||
|
|
||||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||||
|
|
||||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||||
|
|
||||||
|
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
|
||||||
|
|
||||||
class MockTagService extends Mock implements TagService {}
|
class MockTagService extends Mock implements TagService {}
|
||||||
|
|||||||
@@ -4288,6 +4288,351 @@
|
|||||||
"x-immich-state": "Stable"
|
"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": {
|
"/auth/admin-sign-up": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Create the first admin user in the system.",
|
"description": "Create the first admin user in the system.",
|
||||||
@@ -18082,6 +18427,7 @@
|
|||||||
"LibrarySyncFilesQueueAll",
|
"LibrarySyncFilesQueueAll",
|
||||||
"LibrarySyncFiles",
|
"LibrarySyncFiles",
|
||||||
"LibraryScanQueueAll",
|
"LibraryScanQueueAll",
|
||||||
|
"HlsSessionCleanup",
|
||||||
"MemoryCleanup",
|
"MemoryCleanup",
|
||||||
"MemoryGenerate",
|
"MemoryGenerate",
|
||||||
"NotificationsCleanup",
|
"NotificationsCleanup",
|
||||||
@@ -24040,6 +24386,9 @@
|
|||||||
"description": "Preset",
|
"description": "Preset",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"realtime": {
|
||||||
|
"$ref": "#/components/schemas/SystemConfigFFmpegRealtimeDto"
|
||||||
|
},
|
||||||
"refs": {
|
"refs": {
|
||||||
"description": "References",
|
"description": "References",
|
||||||
"maximum": 6,
|
"maximum": 6,
|
||||||
@@ -24090,6 +24439,7 @@
|
|||||||
"maxBitrate",
|
"maxBitrate",
|
||||||
"preferredHwDevice",
|
"preferredHwDevice",
|
||||||
"preset",
|
"preset",
|
||||||
|
"realtime",
|
||||||
"refs",
|
"refs",
|
||||||
"targetAudioCodec",
|
"targetAudioCodec",
|
||||||
"targetResolution",
|
"targetResolution",
|
||||||
@@ -24102,6 +24452,18 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SystemConfigFFmpegRealtimeDto": {
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"description": "Enable real-time HLS transcoding (alpha)",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"enabled"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SystemConfigFacesDto": {
|
"SystemConfigFacesDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
@@ -2227,6 +2227,10 @@ export type DatabaseBackupConfig = {
|
|||||||
export type SystemConfigBackupsDto = {
|
export type SystemConfigBackupsDto = {
|
||||||
database: DatabaseBackupConfig;
|
database: DatabaseBackupConfig;
|
||||||
};
|
};
|
||||||
|
export type SystemConfigFFmpegRealtimeDto = {
|
||||||
|
/** Enable real-time HLS transcoding (alpha) */
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
export type SystemConfigFFmpegDto = {
|
export type SystemConfigFFmpegDto = {
|
||||||
accel: TranscodeHWAccel;
|
accel: TranscodeHWAccel;
|
||||||
/** Accelerated decode */
|
/** Accelerated decode */
|
||||||
@@ -2250,6 +2254,7 @@ export type SystemConfigFFmpegDto = {
|
|||||||
preferredHwDevice: string;
|
preferredHwDevice: string;
|
||||||
/** Preset */
|
/** Preset */
|
||||||
preset: string;
|
preset: string;
|
||||||
|
realtime: SystemConfigFFmpegRealtimeDto;
|
||||||
/** References */
|
/** References */
|
||||||
refs: number;
|
refs: number;
|
||||||
targetAudioCodec: AudioCodec;
|
targetAudioCodec: AudioCodec;
|
||||||
@@ -4184,6 +4189,82 @@ export function playAssetVideo({ id, key, slug }: {
|
|||||||
...opts
|
...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
|
* Register admin
|
||||||
*/
|
*/
|
||||||
@@ -7082,6 +7163,7 @@ export enum JobName {
|
|||||||
LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll",
|
LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll",
|
||||||
LibrarySyncFiles = "LibrarySyncFiles",
|
LibrarySyncFiles = "LibrarySyncFiles",
|
||||||
LibraryScanQueueAll = "LibraryScanQueueAll",
|
LibraryScanQueueAll = "LibraryScanQueueAll",
|
||||||
|
HlsSessionCleanup = "HlsSessionCleanup",
|
||||||
MemoryCleanup = "MemoryCleanup",
|
MemoryCleanup = "MemoryCleanup",
|
||||||
MemoryGenerate = "MemoryGenerate",
|
MemoryGenerate = "MemoryGenerate",
|
||||||
NotificationsCleanup = "NotificationsCleanup",
|
NotificationsCleanup = "NotificationsCleanup",
|
||||||
|
|||||||
Generated
+5
-5
@@ -758,8 +758,8 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/sdk
|
version: link:../packages/sdk
|
||||||
'@immich/ui':
|
'@immich/ui':
|
||||||
specifier: ^0.79.0
|
specifier: ^0.77.0
|
||||||
version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
version: 0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||||
'@mapbox/mapbox-gl-rtl-text':
|
'@mapbox/mapbox-gl-rtl-text':
|
||||||
specifier: 0.4.0
|
specifier: 0.4.0
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
@@ -3204,8 +3204,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@immich/ui@0.79.0':
|
'@immich/ui@0.77.3':
|
||||||
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
|
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@sveltejs/kit': ^2.13.0
|
'@sveltejs/kit': ^2.13.0
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
@@ -15879,7 +15879,7 @@ snapshots:
|
|||||||
pg-connection-string: 2.13.0
|
pg-connection-string: 2.13.0
|
||||||
postgres: 3.4.9
|
postgres: 3.4.9
|
||||||
|
|
||||||
'@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
'@immich/ui@0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@internationalized/date': 3.12.1
|
'@internationalized/date': 3.12.1
|
||||||
'@mdi/js': 7.4.47
|
'@mdi/js': 7.4.47
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export type SystemConfig = {
|
|||||||
accel: TranscodeHardwareAcceleration;
|
accel: TranscodeHardwareAcceleration;
|
||||||
accelDecode: boolean;
|
accelDecode: boolean;
|
||||||
tonemap: ToneMapping;
|
tonemap: ToneMapping;
|
||||||
|
realtime: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
job: Record<ConcurrentQueueName, { concurrency: number }>;
|
job: Record<ConcurrentQueueName, { concurrency: number }>;
|
||||||
logging: {
|
logging: {
|
||||||
@@ -224,6 +227,9 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
tonemap: ToneMapping.Hable,
|
tonemap: ToneMapping.Hable,
|
||||||
accel: TranscodeHardwareAcceleration.Disabled,
|
accel: TranscodeHardwareAcceleration.Disabled,
|
||||||
accelDecode: true,
|
accelDecode: true,
|
||||||
|
realtime: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
job: {
|
job: {
|
||||||
[QueueName.BackgroundTask]: { concurrency: 5 },
|
[QueueName.BackgroundTask]: { concurrency: 5 },
|
||||||
|
|||||||
+38
-1
@@ -1,7 +1,15 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { SemVer } from 'semver';
|
import { SemVer } from 'semver';
|
||||||
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
import {
|
||||||
|
ApiTag,
|
||||||
|
AudioCodec,
|
||||||
|
DatabaseExtension,
|
||||||
|
ExifOrientation,
|
||||||
|
TranscodeHardwareAcceleration,
|
||||||
|
VectorIndex,
|
||||||
|
VideoCodec,
|
||||||
|
} from 'src/enum';
|
||||||
|
|
||||||
export const IMMICH_SERVER_START = 'Immich Server is listening';
|
export const IMMICH_SERVER_START = 'Immich Server is listening';
|
||||||
|
|
||||||
@@ -202,3 +210,32 @@ export const AUDIO_ENCODER: Record<AudioCodec, string> = {
|
|||||||
[AudioCodec.Opus]: 'libopus',
|
[AudioCodec.Opus]: 'libopus',
|
||||||
[AudioCodec.PcmS16le]: 'pcm_s16le',
|
[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,6 +35,7 @@ import { TimelineController } from 'src/controllers/timeline.controller';
|
|||||||
import { TrashController } from 'src/controllers/trash.controller';
|
import { TrashController } from 'src/controllers/trash.controller';
|
||||||
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||||
import { UserController } from 'src/controllers/user.controller';
|
import { UserController } from 'src/controllers/user.controller';
|
||||||
|
import { VideoStreamController } from 'src/controllers/video-stream.controller';
|
||||||
import { ViewController } from 'src/controllers/view.controller';
|
import { ViewController } from 'src/controllers/view.controller';
|
||||||
import { WorkflowController } from 'src/controllers/workflow.controller';
|
import { WorkflowController } from 'src/controllers/workflow.controller';
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export const controllers = [
|
|||||||
TrashController,
|
TrashController,
|
||||||
UserAdminController,
|
UserAdminController,
|
||||||
UserController,
|
UserController,
|
||||||
|
VideoStreamController,
|
||||||
ViewController,
|
ViewController,
|
||||||
WorkflowController,
|
WorkflowController,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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,6 +35,10 @@ export interface MoveRequest {
|
|||||||
|
|
||||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
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 };
|
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
|
||||||
|
|
||||||
let instance: StorageCore | null;
|
let instance: StorageCore | null;
|
||||||
@@ -125,6 +129,14 @@ export class StorageCore {
|
|||||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
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) {
|
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
|
||||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${uuid}-MP.mp4`);
|
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${uuid}-MP.mp4`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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,6 +79,11 @@ const SystemConfigFFmpegSchema = z
|
|||||||
accel: TranscodeHardwareAccelerationSchema,
|
accel: TranscodeHardwareAccelerationSchema,
|
||||||
accelDecode: configBool.describe('Accelerated decode'),
|
accelDecode: configBool.describe('Accelerated decode'),
|
||||||
tonemap: ToneMappingSchema,
|
tonemap: ToneMappingSchema,
|
||||||
|
realtime: z
|
||||||
|
.object({
|
||||||
|
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
|
||||||
})
|
})
|
||||||
.meta({ id: 'SystemConfigFFmpegDto' });
|
.meta({ id: 'SystemConfigFFmpegDto' });
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -452,11 +452,7 @@ export enum VideoCodec {
|
|||||||
|
|
||||||
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
|
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
|
||||||
|
|
||||||
export enum VideoSegmentCodec {
|
export type VideoSegmentCodec = VideoCodec.Av1 | VideoCodec.Hevc | VideoCodec.H264;
|
||||||
Av1 = 'av1',
|
|
||||||
Hevc = 'hevc',
|
|
||||||
H264 = 'h264',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AudioCodec {
|
export enum AudioCodec {
|
||||||
Mp3 = 'mp3',
|
Mp3 = 'mp3',
|
||||||
@@ -826,6 +822,8 @@ export enum JobName {
|
|||||||
LibrarySyncFiles = 'LibrarySyncFiles',
|
LibrarySyncFiles = 'LibrarySyncFiles',
|
||||||
LibraryScanQueueAll = 'LibraryScanQueueAll',
|
LibraryScanQueueAll = 'LibraryScanQueueAll',
|
||||||
|
|
||||||
|
HlsSessionCleanup = 'HlsSessionCleanup',
|
||||||
|
|
||||||
MemoryCleanup = 'MemoryCleanup',
|
MemoryCleanup = 'MemoryCleanup',
|
||||||
MemoryGenerate = 'MemoryGenerate',
|
MemoryGenerate = 'MemoryGenerate',
|
||||||
|
|
||||||
@@ -919,6 +917,7 @@ export enum DatabaseLock {
|
|||||||
MaintenanceOperation = 621,
|
MaintenanceOperation = 621,
|
||||||
MemoryCreation = 777,
|
MemoryCreation = 777,
|
||||||
VersionCheck = 800,
|
VersionCheck = 800,
|
||||||
|
HlsSessionCleanup = 850,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MaintenanceAction {
|
export enum MaintenanceAction {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from
|
|||||||
"video_stream_session"
|
"video_stream_session"
|
||||||
where
|
where
|
||||||
"id" = $1
|
"id" = $1
|
||||||
|
and "expiresAt" > $2
|
||||||
|
|
||||||
-- VideoStreamRepository.getVariant
|
-- VideoStreamRepository.getVariant
|
||||||
select
|
select
|
||||||
@@ -27,11 +28,13 @@ where
|
|||||||
|
|
||||||
-- VideoStreamRepository.getExpiredSessions
|
-- VideoStreamRepository.getExpiredSessions
|
||||||
select
|
select
|
||||||
"id"
|
"video_stream_session"."id",
|
||||||
|
"asset"."ownerId"
|
||||||
from
|
from
|
||||||
"video_stream_session"
|
"video_stream_session"
|
||||||
|
inner join "asset" on "asset"."id" = "video_stream_session"."assetId"
|
||||||
where
|
where
|
||||||
"expiresAt" <= $1
|
"video_stream_session"."expiresAt" <= $1
|
||||||
|
|
||||||
-- VideoStreamRepository.extendSession
|
-- VideoStreamRepository.extendSession
|
||||||
update "video_stream_session"
|
update "video_stream_session"
|
||||||
@@ -44,3 +47,253 @@ where
|
|||||||
delete from "video_stream_session"
|
delete from "video_stream_session"
|
||||||
where
|
where
|
||||||
"id" = $1
|
"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,6 +92,14 @@ type EventMap = {
|
|||||||
|
|
||||||
AuthChangePassword: [{ userId: string; currentSessionId?: string; invalidateSessions?: boolean }];
|
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
|
// websocket events
|
||||||
WebsocketConnect: [{ userId: string }];
|
WebsocketConnect: [{ userId: string }];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -490,18 +490,43 @@ export class MediaRepository {
|
|||||||
return this.parseInt(b.bit_rate) - this.parseInt(a.bit_rate);
|
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) {
|
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);
|
packets.sort((a, b) => a.pts - b.pts);
|
||||||
const firstPts = packets[0].pts;
|
const firstPts = packets[0].pts;
|
||||||
let outputFrames = 0;
|
let outputFrames = 0;
|
||||||
let nextPts = 0;
|
let nextPts = 0;
|
||||||
|
const history = [0, 0, 0];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const delta = (pkt.pts - firstPts) * slotsPerTick - nextPts + pkt.duration * slotsPerTick;
|
const syncIpts = (pkt.pts - firstPts) * slotsPerTick;
|
||||||
const nb = delta < -1.1 ? 0 : delta > 1.1 ? Math.round(delta) : 1;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
outputFrames += nb;
|
outputFrames += nb;
|
||||||
nextPts += nb;
|
nextPts += nb;
|
||||||
|
history[2] = history[1];
|
||||||
|
history[1] = history[0];
|
||||||
|
history[0] = nbPrev;
|
||||||
}
|
}
|
||||||
return outputFrames;
|
const median = history.sort((a, b) => a - b)[1];
|
||||||
|
return outputFrames + median;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ChildProcessWithoutNullStreams, fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
import { fork, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||||
import { Duplex } from 'node:stream';
|
import { Duplex } from 'node:stream';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProcessRepository {
|
export class ProcessRepository {
|
||||||
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
spawn = spawn;
|
||||||
return spawn(command, args, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
||||||
let stdinClosed = false;
|
let stdinClosed = false;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
ReadOptionsWithBuffer,
|
ReadOptionsWithBuffer,
|
||||||
|
watch,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -277,6 +278,8 @@ export class StorageRepository {
|
|||||||
return () => watcher.close();
|
return () => watcher.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watchDir = watch; // Native fs.watch without chokidar overhead
|
||||||
|
|
||||||
private asGlob(pathToCrawl: string): string {
|
private asGlob(pathToCrawl: string): string {
|
||||||
const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]');
|
const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]');
|
||||||
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
|
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
VideoStreamSessionTable,
|
VideoStreamSessionTable,
|
||||||
VideoStreamVariantTable,
|
VideoStreamVariantTable,
|
||||||
} from 'src/schema/tables/video-stream.table';
|
} from 'src/schema/tables/video-stream.table';
|
||||||
|
import { withAudioStream, withVideoFormat, withVideoPackets, withVideoStream } from 'src/utils/database';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoStreamRepository {
|
export class VideoStreamRepository {
|
||||||
@@ -27,7 +28,12 @@ export class VideoStreamRepository {
|
|||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getSession(id: string) {
|
getSession(id: string) {
|
||||||
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
|
return this.db
|
||||||
|
.selectFrom('video_stream_session')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', id)
|
||||||
|
.where('expiresAt', '>', new Date())
|
||||||
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
@@ -47,7 +53,12 @@ export class VideoStreamRepository {
|
|||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
getExpiredSessions() {
|
getExpiredSessions() {
|
||||||
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
|
||||||
@@ -59,4 +70,50 @@ export class VideoStreamRepository {
|
|||||||
async deleteSession(id: string) {
|
async deleteSession(id: string) {
|
||||||
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
|
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,7 +16,16 @@ import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event
|
|||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { handlePromiseError } from 'src/utils/misc';
|
import { handlePromiseError } from 'src/utils/misc';
|
||||||
|
|
||||||
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
|
export const serverEvents = [
|
||||||
|
'ConfigUpdate',
|
||||||
|
'AppRestart',
|
||||||
|
'HlsSegmentRequest',
|
||||||
|
'HlsSegmentResult',
|
||||||
|
'HlsHeartbeat',
|
||||||
|
'HlsSessionRequest',
|
||||||
|
'HlsSessionResult',
|
||||||
|
'HlsSessionEnd',
|
||||||
|
] as const;
|
||||||
export type ServerEvents = (typeof serverEvents)[number];
|
export type ServerEvents = (typeof serverEvents)[number];
|
||||||
|
|
||||||
export interface ClientEventMap {
|
export interface ClientEventMap {
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { registerEnum } from '@immich/sql-tools';
|
import { registerEnum } from '@immich/sql-tools';
|
||||||
import {
|
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType, VideoCodec } from 'src/enum';
|
||||||
AlbumUserRole,
|
|
||||||
AssetStatus,
|
|
||||||
AssetVisibility,
|
|
||||||
ChecksumAlgorithm,
|
|
||||||
SourceType,
|
|
||||||
VideoSegmentCodec,
|
|
||||||
} from 'src/enum';
|
|
||||||
|
|
||||||
export const album_user_role_enum = registerEnum({
|
export const album_user_role_enum = registerEnum({
|
||||||
name: 'album_user_role_enum',
|
name: 'album_user_role_enum',
|
||||||
@@ -35,5 +28,5 @@ export const asset_checksum_algorithm_enum = registerEnum({
|
|||||||
|
|
||||||
export const video_stream_variant_codec_enum = registerEnum({
|
export const video_stream_variant_codec_enum = registerEnum({
|
||||||
name: 'video_stream_variant_codec_enum',
|
name: 'video_stream_variant_codec_enum',
|
||||||
values: Object.values(VideoSegmentCodec),
|
values: [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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,6 +11,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service';
|
|||||||
import { DatabaseService } from 'src/services/database.service';
|
import { DatabaseService } from 'src/services/database.service';
|
||||||
import { DownloadService } from 'src/services/download.service';
|
import { DownloadService } from 'src/services/download.service';
|
||||||
import { DuplicateService } from 'src/services/duplicate.service';
|
import { DuplicateService } from 'src/services/duplicate.service';
|
||||||
|
import { HlsService } from 'src/services/hls.service';
|
||||||
import { JobService } from 'src/services/job.service';
|
import { JobService } from 'src/services/job.service';
|
||||||
import { LibraryService } from 'src/services/library.service';
|
import { LibraryService } from 'src/services/library.service';
|
||||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||||
@@ -39,6 +40,7 @@ import { SystemMetadataService } from 'src/services/system-metadata.service';
|
|||||||
import { TagService } from 'src/services/tag.service';
|
import { TagService } from 'src/services/tag.service';
|
||||||
import { TelemetryService } from 'src/services/telemetry.service';
|
import { TelemetryService } from 'src/services/telemetry.service';
|
||||||
import { TimelineService } from 'src/services/timeline.service';
|
import { TimelineService } from 'src/services/timeline.service';
|
||||||
|
import { TranscodingService } from 'src/services/transcoding.service';
|
||||||
import { TrashService } from 'src/services/trash.service';
|
import { TrashService } from 'src/services/trash.service';
|
||||||
import { UserAdminService } from 'src/services/user-admin.service';
|
import { UserAdminService } from 'src/services/user-admin.service';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
@@ -61,6 +63,7 @@ export const services = [
|
|||||||
DatabaseService,
|
DatabaseService,
|
||||||
DownloadService,
|
DownloadService,
|
||||||
DuplicateService,
|
DuplicateService,
|
||||||
|
HlsService,
|
||||||
JobService,
|
JobService,
|
||||||
LibraryService,
|
LibraryService,
|
||||||
MaintenanceService,
|
MaintenanceService,
|
||||||
@@ -89,6 +92,7 @@ export const services = [
|
|||||||
TagService,
|
TagService,
|
||||||
TelemetryService,
|
TelemetryService,
|
||||||
TimelineService,
|
TimelineService,
|
||||||
|
TranscodingService,
|
||||||
TrashService,
|
TrashService,
|
||||||
UserAdminService,
|
UserAdminService,
|
||||||
UserService,
|
UserService,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe(QueueService.name, () => {
|
|||||||
{ name: JobName.PersonCleanup },
|
{ name: JobName.PersonCleanup },
|
||||||
{ name: JobName.MemoryCleanup },
|
{ name: JobName.MemoryCleanup },
|
||||||
{ name: JobName.SessionCleanup },
|
{ name: JobName.SessionCleanup },
|
||||||
|
{ name: JobName.HlsSessionCleanup },
|
||||||
{ name: JobName.AuditTableCleanup },
|
{ name: JobName.AuditTableCleanup },
|
||||||
{ name: JobName.MemoryGenerate },
|
{ name: JobName.MemoryGenerate },
|
||||||
{ name: JobName.UserSyncUsage },
|
{ name: JobName.UserSyncUsage },
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ export class QueueService extends BaseService {
|
|||||||
{ name: JobName.PersonCleanup },
|
{ name: JobName.PersonCleanup },
|
||||||
{ name: JobName.MemoryCleanup },
|
{ name: JobName.MemoryCleanup },
|
||||||
{ name: JobName.SessionCleanup },
|
{ name: JobName.SessionCleanup },
|
||||||
|
{ name: JobName.HlsSessionCleanup },
|
||||||
{ name: JobName.AuditTableCleanup },
|
{ name: JobName.AuditTableCleanup },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||||||
accel: TranscodeHardwareAcceleration.Disabled,
|
accel: TranscodeHardwareAcceleration.Disabled,
|
||||||
accelDecode: true,
|
accelDecode: true,
|
||||||
tonemap: ToneMapping.Hable,
|
tonemap: ToneMapping.Hable,
|
||||||
|
realtime: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
logging: {
|
logging: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import {
|
||||||
|
HLS_BACKPRESSURE_PAUSE_SEGMENTS,
|
||||||
|
HLS_BACKPRESSURE_RESUME_SEGMENTS,
|
||||||
|
HLS_CLEANUP_INTERVAL_MS,
|
||||||
|
HLS_INACTIVITY_TIMEOUT_MS,
|
||||||
|
HLS_LEASE_DURATION_MS,
|
||||||
|
} from 'src/constants';
|
||||||
|
import { TranscodingService } from 'src/services/transcoding.service';
|
||||||
|
import { VIDEO_STREAM_SESSION_PK_CONSTRAINT } from 'src/utils/database';
|
||||||
|
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
||||||
|
import { mockSpawn, newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
describe(TranscodingService.name, () => {
|
||||||
|
let sut: TranscodingService;
|
||||||
|
let mocks: ServiceMocks;
|
||||||
|
|
||||||
|
const sessionId = 'session-1';
|
||||||
|
const assetId = 'asset-1';
|
||||||
|
const ownerId = 'user-1';
|
||||||
|
|
||||||
|
const completeSegment = (index: number) => {
|
||||||
|
const listener = vi.mocked(mocks.storage.watchDir).mock.lastCall?.[1];
|
||||||
|
expect(listener).toBeDefined();
|
||||||
|
listener!('rename', `seg_${index}.m4s`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeSegmentsThrough = (start: number, end: number) => {
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
completeSegment(i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ sut, mocks } = newTestService(TranscodingService));
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true } } });
|
||||||
|
mocks.videoStream.getForTranscoding.mockResolvedValue(eiffelTower);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onSessionRequest', () => {
|
||||||
|
it('creates the session row and emits HlsSessionResult on success', async () => {
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
|
||||||
|
expect(mocks.videoStream.createSession).toHaveBeenCalledWith({
|
||||||
|
id: sessionId,
|
||||||
|
assetId,
|
||||||
|
expiresAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionResult', { sessionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a primary-key conflict as a no-op for replay tolerance', async () => {
|
||||||
|
mocks.videoStream.createSession.mockRejectedValue({ constraint_name: VIDEO_STREAM_SESSION_PK_CONSTRAINT });
|
||||||
|
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
|
||||||
|
expect(mocks.websocket.serverSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits HlsSessionResult with an error on other DB failures', async () => {
|
||||||
|
mocks.videoStream.createSession.mockRejectedValue(new Error('database is down'));
|
||||||
|
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
|
||||||
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionResult', {
|
||||||
|
sessionId,
|
||||||
|
error: 'Failed to create HLS session',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onSessionEnd', () => {
|
||||||
|
it('removes the session, kills the transcode, and deletes the dir + DB row', async () => {
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
const process = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValue(process);
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
|
||||||
|
|
||||||
|
await sut.onSessionEnd({ sessionId });
|
||||||
|
|
||||||
|
expect(process.kill).toHaveBeenCalled();
|
||||||
|
expect(mocks.storage.unlinkDir).toHaveBeenCalled();
|
||||||
|
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the session is unknown', async () => {
|
||||||
|
await sut.onSessionEnd({ sessionId: 'never-created' });
|
||||||
|
|
||||||
|
expect(mocks.videoStream.deleteSession).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.storage.unlinkDir).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onHeartbeat', () => {
|
||||||
|
it('extends the DB lease when remaining time falls below half', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
vi.setSystemTime(Date.now() + HLS_LEASE_DURATION_MS / 2 + 1);
|
||||||
|
|
||||||
|
await sut.onHeartbeat({ sessionId });
|
||||||
|
|
||||||
|
expect(mocks.videoStream.extendSession).toHaveBeenCalledWith(sessionId, expect.any(Date));
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not extend the lease while it is still fresh', async () => {
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
|
||||||
|
await sut.onHeartbeat({ sessionId });
|
||||||
|
|
||||||
|
expect(mocks.videoStream.extendSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the session is unknown', async () => {
|
||||||
|
await sut.onHeartbeat({ sessionId: 'never-created' });
|
||||||
|
|
||||||
|
expect(mocks.videoStream.extendSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onSegmentRequest', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
mocks.websocket.serverSend.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spawns FFmpeg on the first request', async () => {
|
||||||
|
mocks.process.spawn.mockReturnValue(mockSpawn(0, '', ''));
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(mocks.process.spawn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.process.spawn).toHaveBeenCalledWith('ffmpeg', expect.any(Array), expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills and respawns when the variant changes', async () => {
|
||||||
|
const first = mockSpawn(0, '', '');
|
||||||
|
const second = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 1, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(first.kill).toHaveBeenCalled();
|
||||||
|
expect(mocks.process.spawn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills and respawns when seeking before the start segment', async () => {
|
||||||
|
const first = mockSpawn(0, '', '');
|
||||||
|
const second = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 5 });
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 2 });
|
||||||
|
|
||||||
|
expect(first.kill).toHaveBeenCalled();
|
||||||
|
expect(mocks.process.spawn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills and respawns when the requested segment is too far ahead', async () => {
|
||||||
|
const first = mockSpawn(0, '', '');
|
||||||
|
const second = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 5 });
|
||||||
|
|
||||||
|
expect(first.kill).toHaveBeenCalled();
|
||||||
|
expect(mocks.process.spawn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not spawn when the session is unknown', async () => {
|
||||||
|
await sut.onSegmentRequest({ sessionId: 'never-created', assetId, variantIndex: 0, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(mocks.process.spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts segments from a restart after the previous ffmpeg exited on its own', async () => {
|
||||||
|
const first = mockSpawn(0, '', '');
|
||||||
|
const second = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValueOnce(first).mockReturnValueOnce(second);
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 10 });
|
||||||
|
completeSegment(10);
|
||||||
|
|
||||||
|
const onCalls = vi.mocked(first.on).mock.calls as unknown as [string, (code: number) => void][];
|
||||||
|
const exitHandler = onCalls.find(([event]) => event === 'exit')?.[1];
|
||||||
|
exitHandler?.(0);
|
||||||
|
|
||||||
|
mocks.websocket.serverSend.mockClear();
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 2 });
|
||||||
|
completeSegment(2);
|
||||||
|
|
||||||
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentResult', {
|
||||||
|
sessionId,
|
||||||
|
variantIndex: 0,
|
||||||
|
segmentIndex: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backpressure', () => {
|
||||||
|
let proc: ReturnType<typeof mockSpawn>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
proc = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValue(proc);
|
||||||
|
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pauses the transcode once the lead exceeds HLS_BACKPRESSURE_PAUSE_SEGMENTS', async () => {
|
||||||
|
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
|
||||||
|
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pause when the lead equals the pause threshold', async () => {
|
||||||
|
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS);
|
||||||
|
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(proc.kill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes once the lead drops below HLS_BACKPRESSURE_RESUME_SEGMENTS', async () => {
|
||||||
|
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
|
||||||
|
vi.mocked(proc.kill).mockClear();
|
||||||
|
|
||||||
|
const requested = HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1 - (HLS_BACKPRESSURE_RESUME_SEGMENTS - 1);
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: requested });
|
||||||
|
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith('SIGCONT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays paused while the lead is in the dead-band', async () => {
|
||||||
|
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
vi.mocked(proc.kill).mockClear();
|
||||||
|
|
||||||
|
const requested = HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1 - HLS_BACKPRESSURE_RESUME_SEGMENTS;
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: requested });
|
||||||
|
|
||||||
|
expect(proc.kill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when no segment has completed yet', async () => {
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(proc.kill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the heartbeat omits segmentIndex', async () => {
|
||||||
|
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
|
||||||
|
|
||||||
|
await sut.onHeartbeat({ sessionId });
|
||||||
|
|
||||||
|
expect(proc.kill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes the paused transcode when the client requests the next in-range segment', async () => {
|
||||||
|
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
|
||||||
|
vi.mocked(proc.kill).mockClear();
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex: 1 });
|
||||||
|
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith('SIGCONT');
|
||||||
|
expect(mocks.process.spawn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-pause a freshly spawned transcode after a seek-driven restart', async () => {
|
||||||
|
const newProc = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValueOnce(newProc);
|
||||||
|
|
||||||
|
completeSegmentsThrough(0, HLS_BACKPRESSURE_PAUSE_SEGMENTS + 1);
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
expect(proc.kill).toHaveBeenCalledWith('SIGSTOP');
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 1, segmentIndex: 0 });
|
||||||
|
vi.mocked(newProc.kill).mockClear();
|
||||||
|
|
||||||
|
await sut.onHeartbeat({ sessionId, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(newProc.kill).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale segment events from the prior transcode after a backward seek', async () => {
|
||||||
|
const newProc = mockSpawn(0, '', '');
|
||||||
|
mocks.process.spawn.mockReturnValueOnce(newProc);
|
||||||
|
|
||||||
|
const completedAhead = HLS_BACKPRESSURE_PAUSE_SEGMENTS + 5;
|
||||||
|
completeSegmentsThrough(1, completedAhead); // seg_0 was emitted in beforeEach
|
||||||
|
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 1, segmentIndex: 0 });
|
||||||
|
|
||||||
|
vi.mocked(newProc.kill).mockClear();
|
||||||
|
mocks.websocket.serverSend.mockClear();
|
||||||
|
completeSegment(completedAhead + 1);
|
||||||
|
|
||||||
|
expect(mocks.websocket.serverSend).not.toHaveBeenCalledWith(
|
||||||
|
'HlsSegmentResult',
|
||||||
|
expect.objectContaining({ segmentIndex: completedAhead + 1 }),
|
||||||
|
);
|
||||||
|
expect(newProc.kill).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
completeSegment(0);
|
||||||
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith(
|
||||||
|
'HlsSegmentResult',
|
||||||
|
expect.objectContaining({ segmentIndex: 0 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inactivity sweeper', () => {
|
||||||
|
it('reaps a session whose last activity exceeds the inactivity timeout', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
mocks.websocket.serverSend.mockClear();
|
||||||
|
await vi.advanceTimersByTimeAsync(HLS_INACTIVITY_TIMEOUT_MS + HLS_CLEANUP_INTERVAL_MS);
|
||||||
|
|
||||||
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionEnd', { sessionId });
|
||||||
|
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith(sessionId);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onShutdown', () => {
|
||||||
|
it('ends every active session', async () => {
|
||||||
|
await sut.onSessionRequest({ sessionId: 'session-a', assetId, ownerId });
|
||||||
|
await sut.onSessionRequest({ sessionId: 'session-b', assetId, ownerId });
|
||||||
|
|
||||||
|
await sut.onShutdown();
|
||||||
|
|
||||||
|
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('session-a');
|
||||||
|
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('session-b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onHlsSessionCleanup', () => {
|
||||||
|
it('reaps DB-expired sessions under a database lock', async () => {
|
||||||
|
mocks.database.withLock.mockImplementation(async (_, fn) => fn());
|
||||||
|
mocks.videoStream.getExpiredSessions.mockResolvedValue([
|
||||||
|
{ id: 'expired-1', ownerId: 'user-a' },
|
||||||
|
{ id: 'expired-2', ownerId: 'user-b' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sut.onHlsSessionCleanup();
|
||||||
|
|
||||||
|
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('expired-1');
|
||||||
|
expect(mocks.videoStream.deleteSession).toHaveBeenCalledWith('expired-2');
|
||||||
|
expect(mocks.storage.unlinkDir).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FFmpeg full command', () => {
|
||||||
|
const baseCommand = [
|
||||||
|
'-nostdin',
|
||||||
|
'-nostats',
|
||||||
|
'-i',
|
||||||
|
'eiffel-tower.mp4',
|
||||||
|
'-map',
|
||||||
|
'0:0',
|
||||||
|
'-map_metadata',
|
||||||
|
'-1',
|
||||||
|
'-map',
|
||||||
|
'0:1',
|
||||||
|
'-g',
|
||||||
|
'50',
|
||||||
|
'-keyint_min',
|
||||||
|
'50',
|
||||||
|
'-crf',
|
||||||
|
'23',
|
||||||
|
'-copyts',
|
||||||
|
'-r',
|
||||||
|
'50130000/2012441',
|
||||||
|
'-avoid_negative_ts',
|
||||||
|
'disabled',
|
||||||
|
'-f',
|
||||||
|
'hls',
|
||||||
|
'-hls_time',
|
||||||
|
'2',
|
||||||
|
'-hls_list_size',
|
||||||
|
'0',
|
||||||
|
'-hls_segment_type',
|
||||||
|
'fmp4',
|
||||||
|
'-hls_fmp4_init_filename',
|
||||||
|
'init.mp4',
|
||||||
|
'-hls_segment_options',
|
||||||
|
'movflags=+frag_discont',
|
||||||
|
'-hls_flags',
|
||||||
|
'temp_file',
|
||||||
|
'-start_number',
|
||||||
|
'0',
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
variantIndex: 6,
|
||||||
|
expected: [
|
||||||
|
...baseCommand,
|
||||||
|
'-c:v',
|
||||||
|
'libsvtav1',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
'-preset',
|
||||||
|
'12',
|
||||||
|
'-svtav1-params',
|
||||||
|
'hierarchical-levels=3:lookahead=0:enable-tf=0:mbr=4000k',
|
||||||
|
'-hls_segment_filename',
|
||||||
|
'/data/encoded-video/user-1/se/ss/session-1/6/seg_%d.m4s',
|
||||||
|
'/data/encoded-video/user-1/se/ss/session-1/6/playlist.m3u8',
|
||||||
|
].sort(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variantIndex: 4,
|
||||||
|
expected: [
|
||||||
|
...baseCommand,
|
||||||
|
'-c:v',
|
||||||
|
'hevc',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
'-tag:v',
|
||||||
|
'hvc1',
|
||||||
|
'-preset',
|
||||||
|
'ultrafast',
|
||||||
|
'-maxrate',
|
||||||
|
'2500k',
|
||||||
|
'-bufsize',
|
||||||
|
'5000k',
|
||||||
|
'-x265-params',
|
||||||
|
'no-scenecut=1:no-open-gop=1',
|
||||||
|
'-vf',
|
||||||
|
'scale=720:-2',
|
||||||
|
'-hls_segment_filename',
|
||||||
|
'/data/encoded-video/user-1/se/ss/session-1/4/seg_%d.m4s',
|
||||||
|
'/data/encoded-video/user-1/se/ss/session-1/4/playlist.m3u8',
|
||||||
|
].sort(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variantIndex: 2,
|
||||||
|
expected: [
|
||||||
|
...baseCommand,
|
||||||
|
'-c:v',
|
||||||
|
'h264',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
'-preset',
|
||||||
|
'ultrafast',
|
||||||
|
'-maxrate',
|
||||||
|
'2500k',
|
||||||
|
'-bufsize',
|
||||||
|
'5000k',
|
||||||
|
'-sc_threshold:v',
|
||||||
|
'0',
|
||||||
|
'-vf',
|
||||||
|
'scale=480:-2',
|
||||||
|
'-hls_segment_filename',
|
||||||
|
'/data/encoded-video/user-1/se/ss/session-1/2/seg_%d.m4s',
|
||||||
|
'/data/encoded-video/user-1/se/ss/session-1/2/playlist.m3u8',
|
||||||
|
].sort(),
|
||||||
|
},
|
||||||
|
])('builds the expected FFmpeg command for $codec (variant $variantIndex)', async ({ variantIndex, expected }) => {
|
||||||
|
mocks.process.spawn.mockReturnValue(mockSpawn(0, '', ''));
|
||||||
|
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex, segmentIndex: 0 });
|
||||||
|
|
||||||
|
expect(mocks.process.spawn.mock.calls[0][1].toSorted()).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FFmpeg seek per segment', () => {
|
||||||
|
const eiffelSeeks = [
|
||||||
|
0, 1.987_15, 3.994_372_222_222_222, 6.001_594_444_444_444, 8.008_816_666_666_666, 10.016_038_888_888_888,
|
||||||
|
12.023_261_111_111_111, 14.030_483_333_333_333, 16.037_705_555_555_554, 18.044_927_777_777_776,
|
||||||
|
20.052_149_999_999_997, 22.059_372_222_222_223,
|
||||||
|
];
|
||||||
|
const waterfallSeeks = [
|
||||||
|
0, 1.994_642_826_321_467, 4.006_047_357_065_803, 6.017_451_887_810_139_5, 8.028_856_418_554_476,
|
||||||
|
10.040_260_949_298_812,
|
||||||
|
];
|
||||||
|
const trainSeeks = [
|
||||||
|
0, 1.991_666_666_666_666_7, 3.991_666_666_666_666_7, 5.991_666_666_666_666, 7.991_666_666_666_666,
|
||||||
|
9.991_666_666_666_667, 11.991_666_666_666_667, 13.991_666_666_666_667, 15.991_666_666_666_667,
|
||||||
|
17.991_666_666_666_667, 19.991_666_666_666_667,
|
||||||
|
];
|
||||||
|
const cases = [
|
||||||
|
...eiffelSeeks.map((expected, segmentIndex) => ({
|
||||||
|
name: `${eiffelTower.originalPath} K=${segmentIndex}`,
|
||||||
|
fixture: eiffelTower,
|
||||||
|
segmentIndex,
|
||||||
|
expected,
|
||||||
|
})),
|
||||||
|
...waterfallSeeks.map((expected, segmentIndex) => ({
|
||||||
|
name: `${waterfall.originalPath} K=${segmentIndex}`,
|
||||||
|
fixture: waterfall,
|
||||||
|
segmentIndex,
|
||||||
|
expected,
|
||||||
|
})),
|
||||||
|
...trainSeeks.map((expected, segmentIndex) => ({
|
||||||
|
name: `${train.originalPath} K=${segmentIndex}`,
|
||||||
|
fixture: train,
|
||||||
|
segmentIndex,
|
||||||
|
expected,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(cases)('$name', async ({ fixture, segmentIndex, expected }) => {
|
||||||
|
mocks.videoStream.getForTranscoding.mockResolvedValue(fixture);
|
||||||
|
mocks.process.spawn.mockReturnValue(mockSpawn(0, '', ''));
|
||||||
|
|
||||||
|
await sut.onSessionRequest({ sessionId, assetId, ownerId });
|
||||||
|
await sut.onSegmentRequest({ sessionId, assetId, variantIndex: 0, segmentIndex });
|
||||||
|
|
||||||
|
const args = mocks.process.spawn.mock.calls[0][1] as string[];
|
||||||
|
if (expected === 0) {
|
||||||
|
expect(args).toEqual(expect.arrayContaining(['-copyts', '-avoid_negative_ts', 'disabled']));
|
||||||
|
expect(args).not.toContain('-ss');
|
||||||
|
} else {
|
||||||
|
expect(args).toEqual(
|
||||||
|
expect.arrayContaining(['-ss', String(expected), '-copyts', '-avoid_negative_ts', 'disabled']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ChildProcess } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
HLS_BACKPRESSURE_PAUSE_SEGMENTS,
|
||||||
|
HLS_BACKPRESSURE_RESUME_SEGMENTS,
|
||||||
|
HLS_CLEANUP_INTERVAL_MS,
|
||||||
|
HLS_INACTIVITY_TIMEOUT_MS,
|
||||||
|
HLS_LEASE_DURATION_MS,
|
||||||
|
HLS_SEGMENT_DURATION,
|
||||||
|
HLS_SEGMENT_FILENAME_REGEX,
|
||||||
|
HLS_VARIANTS,
|
||||||
|
} from 'src/constants';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
|
import { DatabaseLock, ImmichWorker, JobName, QueueName, TranscodeTarget } from 'src/enum';
|
||||||
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import { VideoInterfaces } from 'src/types';
|
||||||
|
import { isVideoStreamSessionPkConstraint } from 'src/utils/database';
|
||||||
|
import { BaseConfig } from 'src/utils/media';
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
assetId: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
id: string;
|
||||||
|
lastActivityTime: Date;
|
||||||
|
lastClientRequestedSegment: number | null;
|
||||||
|
lastCompletedSegment: number | null;
|
||||||
|
ownerId: string;
|
||||||
|
paused: boolean;
|
||||||
|
process: ChildProcess | null;
|
||||||
|
startSegment: number | null;
|
||||||
|
variantIndex: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TranscodingService extends BaseService {
|
||||||
|
private sessions = new Map<string, Session>();
|
||||||
|
private videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||||
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
|
||||||
|
async onBootstrap() {
|
||||||
|
const [videoInterfaces] = await Promise.all([this.storageCore.getVideoInterfaces(), this.removeExpiredSessions()]);
|
||||||
|
this.videoInterfaces = videoInterfaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'AppShutdown', workers: [ImmichWorker.Microservices] })
|
||||||
|
onShutdown() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
return Promise.all([...this.sessions.values()].map(({ id }) => this.onSessionEnd({ sessionId: id })));
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnJob({ name: JobName.HlsSessionCleanup, queue: QueueName.BackgroundTask })
|
||||||
|
onHlsSessionCleanup() {
|
||||||
|
return this.removeExpiredSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'HlsSessionRequest', server: true, workers: [ImmichWorker.Microservices] })
|
||||||
|
async onSessionRequest({ assetId, sessionId, ownerId }: ArgOf<'HlsSessionRequest'>) {
|
||||||
|
try {
|
||||||
|
const expiresAt = new Date(Date.now() + HLS_LEASE_DURATION_MS);
|
||||||
|
await this.videoStreamRepository.createSession({ id: sessionId, assetId, expiresAt });
|
||||||
|
this.sessions.set(sessionId, {
|
||||||
|
assetId,
|
||||||
|
expiresAt,
|
||||||
|
id: sessionId,
|
||||||
|
lastActivityTime: new Date(),
|
||||||
|
lastClientRequestedSegment: null,
|
||||||
|
lastCompletedSegment: null,
|
||||||
|
ownerId,
|
||||||
|
paused: false,
|
||||||
|
process: null,
|
||||||
|
startSegment: null,
|
||||||
|
variantIndex: null,
|
||||||
|
});
|
||||||
|
this.cleanupInterval ??= setInterval(() => void this.removeInactiveSessions(), HLS_CLEANUP_INTERVAL_MS);
|
||||||
|
this.websocketRepository.serverSend('HlsSessionResult', { sessionId });
|
||||||
|
} catch (error) {
|
||||||
|
// If insertion failed due to a PK constraint, another worker has already created a session for this ID.
|
||||||
|
if (!isVideoStreamSessionPkConstraint(error)) {
|
||||||
|
this.logger.error(`Failed to create HLS session ${sessionId}: ${error}`);
|
||||||
|
this.websocketRepository.serverSend('HlsSessionResult', { sessionId, error: 'Failed to create HLS session' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'HlsSessionEnd', server: true, workers: [ImmichWorker.Microservices] })
|
||||||
|
async onSessionEnd({ sessionId }: ArgOf<'HlsSessionEnd'>) {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
if (this.cleanupInterval && this.sessions.size === 0) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
this.stopTranscode(session);
|
||||||
|
await this.removeSessionDir(session);
|
||||||
|
await this.videoStreamRepository.deleteSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'HlsHeartbeat', server: true, workers: [ImmichWorker.Microservices] })
|
||||||
|
async onHeartbeat({ sessionId, segmentIndex }: ArgOf<'HlsHeartbeat'>) {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastActivityTime = new Date();
|
||||||
|
|
||||||
|
if (segmentIndex !== undefined) {
|
||||||
|
session.lastClientRequestedSegment = segmentIndex;
|
||||||
|
this.applyBackpressure(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = session.expiresAt.getTime() - Date.now();
|
||||||
|
if (remaining < HLS_LEASE_DURATION_MS / 2) {
|
||||||
|
session.expiresAt = new Date(Date.now() + HLS_LEASE_DURATION_MS);
|
||||||
|
await this.videoStreamRepository.extendSession(sessionId, session.expiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'HlsSegmentRequest', server: true, workers: [ImmichWorker.Microservices] })
|
||||||
|
async onSegmentRequest({ sessionId, variantIndex, segmentIndex }: ArgOf<'HlsSegmentRequest'>) {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.variantIndex ??= variantIndex;
|
||||||
|
session.startSegment ??= segmentIndex;
|
||||||
|
const curSegment = session.lastCompletedSegment === null ? session.startSegment : session.lastCompletedSegment + 1;
|
||||||
|
const needsRestart =
|
||||||
|
session.variantIndex !== variantIndex || segmentIndex < session.startSegment || segmentIndex > curSegment + 1;
|
||||||
|
if (needsRestart) {
|
||||||
|
this.stopTranscode(session);
|
||||||
|
session.variantIndex = variantIndex;
|
||||||
|
session.startSegment = segmentIndex;
|
||||||
|
} else if (session.process) {
|
||||||
|
this.resumeTranscode(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const process = await this.startTranscode(session, variantIndex, segmentIndex);
|
||||||
|
if (process) {
|
||||||
|
session.process = process;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyBackpressure(session: Session) {
|
||||||
|
if (session.lastCompletedSegment === null || session.lastClientRequestedSegment === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lead = session.lastCompletedSegment - session.lastClientRequestedSegment;
|
||||||
|
this.logger.debug(`Session ${session.id} lead is ${lead} segments`);
|
||||||
|
if (!session.paused && lead > HLS_BACKPRESSURE_PAUSE_SEGMENTS) {
|
||||||
|
this.pauseTranscode(session);
|
||||||
|
} else if (session.paused && lead < HLS_BACKPRESSURE_RESUME_SEGMENTS) {
|
||||||
|
this.resumeTranscode(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startTranscode(session: Session, variantIndex: number, startSegment: number) {
|
||||||
|
const { ffmpeg } = await this.getConfig({ withCache: true });
|
||||||
|
|
||||||
|
const asset = await this.videoStreamRepository.getForTranscoding(session.assetId);
|
||||||
|
if (!asset) {
|
||||||
|
this.logger.error(`Asset ${session.assetId} not found for HLS transcoding`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.variantIndex !== variantIndex || session.startSegment !== startSegment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant = HLS_VARIANTS[variantIndex];
|
||||||
|
if (!variant) {
|
||||||
|
this.logger.error(`Variant ${variantIndex} out of range for asset ${session.assetId}`);
|
||||||
|
await this.failSession(session, `Invalid variant index ${variantIndex}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantDir = StorageCore.getHlsVariantFolder({
|
||||||
|
ownerId: session.ownerId,
|
||||||
|
sessionId: session.id,
|
||||||
|
variantIndex,
|
||||||
|
});
|
||||||
|
this.storageRepository.mkdirSync(variantDir);
|
||||||
|
|
||||||
|
// Encoder runs at fps = packetCount × timeBase / totalDuration with
|
||||||
|
// gop = ceil(SEGMENT_DURATION × fps). To start segment K's content at
|
||||||
|
// exactly cfr slot K × gop, seek to the midpoint between slots K×gop−1 and
|
||||||
|
// K×gop. accurate_seek's "discard < target" then keeps the source frame
|
||||||
|
// that quantizes to slot K×gop and discards the one quantizing to K×gop−1.
|
||||||
|
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
|
||||||
|
const gop = Math.ceil(HLS_SEGMENT_DURATION * fps);
|
||||||
|
const seekSeconds = startSegment > 0 ? (startSegment * gop - 0.5) / fps : 0;
|
||||||
|
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = BaseConfig.create(
|
||||||
|
{
|
||||||
|
...ffmpeg,
|
||||||
|
targetVideoCodec: variant.codec,
|
||||||
|
targetResolution: String(variant.resolution),
|
||||||
|
maxBitrate: `${Math.round(variant.bitrate / 1000)}k`,
|
||||||
|
gopSize: gop,
|
||||||
|
},
|
||||||
|
this.videoInterfaces,
|
||||||
|
{ strictGop: true, lowLatency: true },
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create transcode config for variant ${variantIndex} asset ${session.assetId}: ${error?.message ?? error}`,
|
||||||
|
);
|
||||||
|
await this.failSession(session, `Failed to start transcode: ${error?.message ?? 'unknown error'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const args = config.getHlsCommand(
|
||||||
|
{
|
||||||
|
initFilename: 'init.mp4',
|
||||||
|
inputPath: asset.originalPath,
|
||||||
|
packetCount: asset.packets.packetCount,
|
||||||
|
playlistFilename: join(variantDir, 'playlist.m3u8'),
|
||||||
|
seekSeconds,
|
||||||
|
segmentDuration: HLS_SEGMENT_DURATION,
|
||||||
|
segmentFilename: join(variantDir, 'seg_%d.m4s'),
|
||||||
|
startSegment,
|
||||||
|
target: TranscodeTarget.All,
|
||||||
|
timeBase: asset.videoStream.timeBase,
|
||||||
|
totalDuration: asset.packets.totalDuration,
|
||||||
|
},
|
||||||
|
asset.videoStream,
|
||||||
|
asset.audioStream ?? undefined,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Starting HLS transcode for asset ${session.assetId} variant ${variantIndex} with command: ffmpeg ${args.join(' ')}`,
|
||||||
|
);
|
||||||
|
const process = this.processRepository.spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
this.attachProcessHandlers(process, session, variantIndex);
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
private failSession(session: Session, error: string) {
|
||||||
|
this.websocketRepository.serverSend('HlsSessionResult', { sessionId: session.id, error });
|
||||||
|
return this.onSessionEnd({ sessionId: session.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachProcessHandlers(process: ChildProcess, session: Session, variantIndex: number) {
|
||||||
|
let stderr = '';
|
||||||
|
const variantDir = StorageCore.getHlsVariantFolder({
|
||||||
|
ownerId: session.ownerId,
|
||||||
|
sessionId: session.id,
|
||||||
|
variantIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
// hlsenc writes each segment as `seg_K.m4s.tmp` then renames to
|
||||||
|
// `seg_K.m4s`. The rename event fires the moment the renamed file is
|
||||||
|
// observable — the only signal we need to tell the API worker the
|
||||||
|
// segment is ready to serve.
|
||||||
|
const watcher = this.storageRepository.watchDir(variantDir, (eventType, filename) => {
|
||||||
|
if (eventType !== 'rename' || !filename || session.process !== process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = HLS_SEGMENT_FILENAME_REGEX.exec(filename);
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const segmentIndex = Number.parseInt(match[1]);
|
||||||
|
const expected = session.lastCompletedSegment === null ? session.startSegment : session.lastCompletedSegment + 1;
|
||||||
|
// Ignore stale events from old process after seek
|
||||||
|
if (expected === null || segmentIndex !== expected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.lastCompletedSegment = segmentIndex;
|
||||||
|
this.websocketRepository.serverSend('HlsSegmentResult', {
|
||||||
|
sessionId: session.id,
|
||||||
|
variantIndex,
|
||||||
|
segmentIndex,
|
||||||
|
});
|
||||||
|
this.applyBackpressure(session);
|
||||||
|
});
|
||||||
|
watcher.on('error', (error) => {
|
||||||
|
this.logger.error(`watcher error for ${variantDir}: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.stderr!.on('data', (chunk: Buffer) => {
|
||||||
|
if (session.process !== process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('exit', (code) => {
|
||||||
|
watcher.close();
|
||||||
|
if (session.process !== process || session.variantIndex !== variantIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.paused = false;
|
||||||
|
session.process = null;
|
||||||
|
session.lastCompletedSegment = null;
|
||||||
|
if (code) {
|
||||||
|
this.logger.error(
|
||||||
|
`FFmpeg exited with code ${code} for variant ${variantIndex} asset ${session.assetId}\n${stderr}`,
|
||||||
|
);
|
||||||
|
void this.failSession(session, `Transcoding process exited unexpectedly with code ${code}`).catch((error) =>
|
||||||
|
this.logger.error(`Failed to end session ${session.id} after ffmpeg exit: ${error}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopTranscode(session: Session) {
|
||||||
|
if (!session.process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SIGTERM makes it rename .tmp segments to .m4s even if they're still incomplete
|
||||||
|
session.process.kill('SIGKILL');
|
||||||
|
session.process = null;
|
||||||
|
session.lastCompletedSegment = null;
|
||||||
|
session.paused = false;
|
||||||
|
this.logger.debug(`Stopped transcoding for session ${session.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pauseTranscode(session: Session) {
|
||||||
|
if (session.paused || !session.process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.process.kill('SIGSTOP');
|
||||||
|
session.paused = true;
|
||||||
|
this.logger.debug(`Paused transcoding for session ${session.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resumeTranscode(session: Session) {
|
||||||
|
if (!session.paused || !session.process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.process.kill('SIGCONT');
|
||||||
|
session.paused = false;
|
||||||
|
this.logger.debug(`Resumed transcoding for session ${session.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removeSessionDir(session: { ownerId: string; id: string }) {
|
||||||
|
const dir = StorageCore.getHlsSessionFolder({ ownerId: session.ownerId, sessionId: session.id });
|
||||||
|
try {
|
||||||
|
await this.storageRepository.unlinkDir(dir, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger.warn(`Session dir ${dir} does not exist.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeInactiveSessions() {
|
||||||
|
const cutoff = Date.now() - HLS_INACTIVITY_TIMEOUT_MS;
|
||||||
|
const inactiveSessions = [...this.sessions.values()].filter((s) => s.lastActivityTime.getTime() < cutoff);
|
||||||
|
return Promise.all(
|
||||||
|
inactiveSessions.map(async (session) => {
|
||||||
|
try {
|
||||||
|
this.websocketRepository.serverSend('HlsSessionEnd', { sessionId: session.id });
|
||||||
|
await this.onSessionEnd({ sessionId: session.id });
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to sweep inactive HLS session ${session.id}: ${error}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeExpiredSessions() {
|
||||||
|
return this.databaseRepository.withLock(DatabaseLock.HlsSessionCleanup, async () => {
|
||||||
|
const expiredSessions = await this.videoStreamRepository.getExpiredSessions();
|
||||||
|
await Promise.all(
|
||||||
|
expiredSessions.map(async (session) => {
|
||||||
|
await this.removeSessionDir(session);
|
||||||
|
await this.videoStreamRepository.deleteSession(session.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-7
@@ -28,7 +28,6 @@ import {
|
|||||||
SystemMetadataKey,
|
SystemMetadataKey,
|
||||||
TranscodeTarget,
|
TranscodeTarget,
|
||||||
UserMetadataKey,
|
UserMetadataKey,
|
||||||
VideoCodec,
|
|
||||||
WorkflowTrigger,
|
WorkflowTrigger,
|
||||||
WorkflowType,
|
WorkflowType,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
@@ -162,6 +161,25 @@ export interface TranscodeCommand {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoTuning {
|
||||||
|
strictGop: boolean;
|
||||||
|
lowLatency: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HlsCommandOptions {
|
||||||
|
initFilename: string;
|
||||||
|
inputPath: string;
|
||||||
|
packetCount: number;
|
||||||
|
playlistFilename: string;
|
||||||
|
seekSeconds?: number;
|
||||||
|
segmentDuration: number;
|
||||||
|
segmentFilename: string;
|
||||||
|
startSegment: number;
|
||||||
|
target: TranscodeTarget;
|
||||||
|
timeBase: number;
|
||||||
|
totalDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BitrateDistribution {
|
export interface BitrateDistribution {
|
||||||
max: number;
|
max: number;
|
||||||
target: number;
|
target: number;
|
||||||
@@ -177,14 +195,11 @@ export interface ImageBuffer {
|
|||||||
export interface VideoCodecSWConfig {
|
export interface VideoCodecSWConfig {
|
||||||
getCommand(
|
getCommand(
|
||||||
target: TranscodeTarget,
|
target: TranscodeTarget,
|
||||||
videoStream: VideoStreamInfo,
|
video: VideoStreamInfo,
|
||||||
audioStream?: AudioStreamInfo,
|
audio?: AudioStreamInfo,
|
||||||
format?: VideoFormat,
|
format?: VideoFormat,
|
||||||
): TranscodeCommand;
|
): TranscodeCommand;
|
||||||
}
|
getHlsCommand(options: HlsCommandOptions, video: VideoStreamInfo, audio?: AudioStreamInfo): string[];
|
||||||
|
|
||||||
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
|
||||||
getSupportedCodecs(): Array<VideoCodec>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProbeOptions {
|
export interface ProbeOptions {
|
||||||
@@ -371,6 +386,7 @@ export type JobItem =
|
|||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
| { name: JobName.SessionCleanup; data?: IBaseJob }
|
| { name: JobName.SessionCleanup; data?: IBaseJob }
|
||||||
|
| { name: JobName.HlsSessionCleanup; data?: IBaseJob }
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
| { name: JobName.TagCleanup; data?: IBaseJob }
|
| { name: JobName.TagCleanup; data?: IBaseJob }
|
||||||
|
|||||||
@@ -71,10 +71,13 @@ export const removeUndefinedKeys = <T extends object>(update: T, template: unkno
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
|
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
|
||||||
|
export const VIDEO_STREAM_SESSION_PK_CONSTRAINT = 'video_stream_session_pkey';
|
||||||
|
|
||||||
export const isAssetChecksumConstraint = (error: unknown) => {
|
export const isAssetChecksumConstraint = (error: unknown) =>
|
||||||
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
|
(error as PostgresError)?.constraint_name === ASSET_CHECKSUM_CONSTRAINT;
|
||||||
};
|
|
||||||
|
export const isVideoStreamSessionPkConstraint = (error: unknown) =>
|
||||||
|
(error as PostgresError)?.constraint_name === VIDEO_STREAM_SESSION_PK_CONSTRAINT;
|
||||||
|
|
||||||
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ArgOf, EmitEvent } from 'src/repositories/event.repository';
|
||||||
|
|
||||||
|
export class PendingEvents<T extends { [T in EmitEvent]: ArgOf<T> extends { error?: string } ? T : never }[EmitEvent]> {
|
||||||
|
private pending = new Map<string, { completers: PromiseWithResolvers<ArgOf<T>>[]; timeout: NodeJS.Timeout }>();
|
||||||
|
private timeoutMs: number;
|
||||||
|
|
||||||
|
constructor({ timeoutMs }: { timeoutMs: number }) {
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
wait(key: string): Promise<ArgOf<T>> {
|
||||||
|
const completer = Promise.withResolvers<ArgOf<T>>();
|
||||||
|
const existing = this.pending.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.completers.push(completer);
|
||||||
|
return completer.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => this.complete(key, { error: 'Request timed out' }), this.timeoutMs);
|
||||||
|
this.pending.set(key, { completers: [completer], timeout });
|
||||||
|
return completer.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(key: string, value: ArgOf<T> | { error: string }) {
|
||||||
|
const pending = this.pending.get(key);
|
||||||
|
if (!pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pending.delete(key);
|
||||||
|
if ('error' in value) {
|
||||||
|
const error = new Error(value.error);
|
||||||
|
for (const completer of pending.completers) {
|
||||||
|
completer.reject(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const completer of pending.completers) {
|
||||||
|
completer.resolve(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectByPrefix(prefix: string, error: string) {
|
||||||
|
for (const key of this.pending.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
this.complete(key, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
-136
@@ -1,4 +1,4 @@
|
|||||||
import { AUDIO_ENCODER } from 'src/constants';
|
import { AUDIO_ENCODER, SUPPORTED_HWA_CODECS } from 'src/constants';
|
||||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||||
import {
|
import {
|
||||||
ColorMatrix,
|
ColorMatrix,
|
||||||
@@ -13,38 +13,56 @@ import {
|
|||||||
import {
|
import {
|
||||||
AudioStreamInfo,
|
AudioStreamInfo,
|
||||||
BitrateDistribution,
|
BitrateDistribution,
|
||||||
|
HlsCommandOptions,
|
||||||
TranscodeCommand,
|
TranscodeCommand,
|
||||||
VideoCodecHWConfig,
|
|
||||||
VideoCodecSWConfig,
|
VideoCodecSWConfig,
|
||||||
VideoFormat,
|
VideoFormat,
|
||||||
VideoInterfaces,
|
VideoInterfaces,
|
||||||
VideoStreamInfo,
|
VideoStreamInfo,
|
||||||
|
VideoTuning,
|
||||||
} from 'src/types';
|
} from 'src/types';
|
||||||
|
|
||||||
|
export const isVideoRotated = (videoStream: VideoStreamInfo): boolean => Math.abs(videoStream.rotation) === 90;
|
||||||
|
|
||||||
|
export const isVideoVertical = (videoStream: VideoStreamInfo): boolean =>
|
||||||
|
videoStream.height > videoStream.width || isVideoRotated(videoStream);
|
||||||
|
|
||||||
|
export const getOutputSize = (videoStream: VideoStreamInfo, targetRes: number) => {
|
||||||
|
const factor = Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width);
|
||||||
|
let larger = Math.round(targetRes * factor);
|
||||||
|
if (larger % 2 !== 0) {
|
||||||
|
larger -= 1;
|
||||||
|
}
|
||||||
|
return isVideoVertical(videoStream) ? { width: targetRes, height: larger } : { width: larger, height: targetRes };
|
||||||
|
};
|
||||||
|
|
||||||
export class BaseConfig implements VideoCodecSWConfig {
|
export class BaseConfig implements VideoCodecSWConfig {
|
||||||
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||||
protected constructor(protected config: SystemConfigFFmpegDto) {}
|
protected constructor(
|
||||||
|
protected config: SystemConfigFFmpegDto,
|
||||||
|
protected tune: VideoTuning = { strictGop: false, lowLatency: false },
|
||||||
|
) {}
|
||||||
|
|
||||||
static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces): VideoCodecSWConfig {
|
static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
|
||||||
if (config.accel === TranscodeHardwareAcceleration.Disabled) {
|
if (config.accel === TranscodeHardwareAcceleration.Disabled) {
|
||||||
return this.getSWCodecConfig(config);
|
return this.getSWCodecConfig(config, tune);
|
||||||
}
|
}
|
||||||
return this.getHWCodecConfig(config, interfaces);
|
return this.getHWCodecConfig(config, interfaces, tune);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getSWCodecConfig(config: SystemConfigFFmpegDto) {
|
private static getSWCodecConfig(config: SystemConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig {
|
||||||
switch (config.targetVideoCodec) {
|
switch (config.targetVideoCodec) {
|
||||||
case VideoCodec.H264: {
|
case VideoCodec.H264: {
|
||||||
return new H264Config(config);
|
return new H264Config(config, tune);
|
||||||
}
|
}
|
||||||
case VideoCodec.Hevc: {
|
case VideoCodec.Hevc: {
|
||||||
return new HEVCConfig(config);
|
return new HEVCConfig(config, tune);
|
||||||
}
|
}
|
||||||
case VideoCodec.Vp9: {
|
case VideoCodec.Vp9: {
|
||||||
return new VP9Config(config);
|
return new VP9Config(config, tune);
|
||||||
}
|
}
|
||||||
case VideoCodec.Av1: {
|
case VideoCodec.Av1: {
|
||||||
return new AV1Config(config);
|
return new AV1Config(config, tune);
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Codec '${config.targetVideoCodec}' is unsupported`);
|
throw new Error(`Codec '${config.targetVideoCodec}' is unsupported`);
|
||||||
@@ -52,72 +70,122 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces) {
|
private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) {
|
||||||
let handler: VideoCodecHWConfig;
|
if (!SUPPORTED_HWA_CODECS[config.accel].includes(config.targetVideoCodec)) {
|
||||||
|
throw new Error(
|
||||||
|
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${SUPPORTED_HWA_CODECS[config.accel]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler: VideoCodecSWConfig;
|
||||||
switch (config.accel) {
|
switch (config.accel) {
|
||||||
case TranscodeHardwareAcceleration.Nvenc: {
|
case TranscodeHardwareAcceleration.Nvenc: {
|
||||||
handler = config.accelDecode
|
handler = config.accelDecode
|
||||||
? new NvencHwDecodeConfig(config, interfaces)
|
? new NvencHwDecodeConfig(config, interfaces, tune)
|
||||||
: new NvencSwDecodeConfig(config, interfaces);
|
: new NvencSwDecodeConfig(config, interfaces, tune);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TranscodeHardwareAcceleration.Qsv: {
|
case TranscodeHardwareAcceleration.Qsv: {
|
||||||
handler = config.accelDecode
|
handler = config.accelDecode
|
||||||
? new QsvHwDecodeConfig(config, interfaces)
|
? new QsvHwDecodeConfig(config, interfaces, tune)
|
||||||
: new QsvSwDecodeConfig(config, interfaces);
|
: new QsvSwDecodeConfig(config, interfaces, tune);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TranscodeHardwareAcceleration.Vaapi: {
|
case TranscodeHardwareAcceleration.Vaapi: {
|
||||||
handler = config.accelDecode
|
handler = config.accelDecode
|
||||||
? new VaapiHwDecodeConfig(config, interfaces)
|
? new VaapiHwDecodeConfig(config, interfaces, tune)
|
||||||
: new VaapiSwDecodeConfig(config, interfaces);
|
: new VaapiSwDecodeConfig(config, interfaces, tune);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TranscodeHardwareAcceleration.Rkmpp: {
|
case TranscodeHardwareAcceleration.Rkmpp: {
|
||||||
handler = config.accelDecode
|
handler = config.accelDecode
|
||||||
? new RkmppHwDecodeConfig(config, interfaces)
|
? new RkmppHwDecodeConfig(config, interfaces, tune)
|
||||||
: new RkmppSwDecodeConfig(config, interfaces);
|
: new RkmppSwDecodeConfig(config, interfaces, tune);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`${config.accel.toUpperCase()} acceleration is unsupported`);
|
throw new Error(`${config.accel.toUpperCase()} acceleration is unsupported`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
|
|
||||||
throw new Error(
|
|
||||||
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommand(
|
getCommand(target: TranscodeTarget, video: VideoStreamInfo, audio?: AudioStreamInfo, format?: VideoFormat) {
|
||||||
target: TranscodeTarget,
|
|
||||||
videoStream: VideoStreamInfo,
|
|
||||||
audioStream?: AudioStreamInfo,
|
|
||||||
format?: VideoFormat,
|
|
||||||
) {
|
|
||||||
const options = {
|
const options = {
|
||||||
inputOptions: this.getBaseInputOptions(videoStream, format),
|
inputOptions: this.getBaseInputOptions(video, format),
|
||||||
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v', 'verbose'],
|
outputOptions: [
|
||||||
|
...this.getBaseOutputOptions(target, video, audio),
|
||||||
|
...this.getPresetOptions(),
|
||||||
|
...this.getBitrateOptions(),
|
||||||
|
...this.getEncoderOptions(),
|
||||||
|
'-movflags',
|
||||||
|
'faststart',
|
||||||
|
'-fps_mode',
|
||||||
|
'passthrough',
|
||||||
|
'-v',
|
||||||
|
'verbose',
|
||||||
|
],
|
||||||
twoPass: this.eligibleForTwoPass(),
|
twoPass: this.eligibleForTwoPass(),
|
||||||
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
|
progress: { frameCount: video.frameCount, percentInterval: 5 },
|
||||||
} as TranscodeCommand;
|
} as TranscodeCommand;
|
||||||
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
|
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
|
||||||
const filters = this.getFilterOptions(videoStream);
|
const filters = this.getFilterOptions(video);
|
||||||
if (filters.length > 0) {
|
if (filters.length > 0) {
|
||||||
options.outputOptions.push('-vf', filters.join(','));
|
options.outputOptions.push('-vf', filters.join(','));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
options.outputOptions.push(
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHlsCommand(options: HlsCommandOptions, video: VideoStreamInfo, audio?: AudioStreamInfo) {
|
||||||
|
const args: string[] = this.getBaseInputOptions(video);
|
||||||
|
if (options.seekSeconds) {
|
||||||
|
args.push('-ss', String(options.seekSeconds));
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
'-nostdin',
|
||||||
|
'-nostats',
|
||||||
|
'-i',
|
||||||
|
options.inputPath,
|
||||||
|
...this.getBaseOutputOptions(options.target, video, audio),
|
||||||
...this.getPresetOptions(),
|
...this.getPresetOptions(),
|
||||||
...this.getOutputThreadOptions(),
|
|
||||||
...this.getBitrateOptions(),
|
...this.getBitrateOptions(),
|
||||||
|
...this.getEncoderOptions(),
|
||||||
|
'-copyts',
|
||||||
|
'-r',
|
||||||
|
`${options.packetCount * options.timeBase}/${options.totalDuration}`,
|
||||||
|
'-avoid_negative_ts',
|
||||||
|
'disabled',
|
||||||
|
'-f',
|
||||||
|
'hls',
|
||||||
|
'-hls_time',
|
||||||
|
String(options.segmentDuration),
|
||||||
|
'-hls_list_size',
|
||||||
|
'0',
|
||||||
|
'-hls_segment_type',
|
||||||
|
'fmp4',
|
||||||
|
'-hls_fmp4_init_filename',
|
||||||
|
options.initFilename,
|
||||||
|
'-hls_segment_options',
|
||||||
|
'movflags=+frag_discont',
|
||||||
|
'-hls_flags',
|
||||||
|
'temp_file',
|
||||||
|
'-hls_segment_filename',
|
||||||
|
options.segmentFilename,
|
||||||
|
'-start_number',
|
||||||
|
String(options.startSegment),
|
||||||
);
|
);
|
||||||
|
|
||||||
return options;
|
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(options.target)) {
|
||||||
|
const filters = this.getFilterOptions(video);
|
||||||
|
if (filters.length > 0) {
|
||||||
|
args.push('-vf', filters.join(','));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.push(options.playlistFilename);
|
||||||
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@@ -129,23 +197,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy';
|
const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy';
|
||||||
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';
|
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';
|
||||||
|
|
||||||
const options = [
|
const options = ['-c:v', videoCodec, '-c:a', audioCodec, '-map', `0:${videoStream.index}`, '-map_metadata', '-1'];
|
||||||
'-c:v',
|
|
||||||
videoCodec,
|
|
||||||
'-c:a',
|
|
||||||
audioCodec,
|
|
||||||
// Makes a second pass moving the moov atom to the
|
|
||||||
// beginning of the file for improved playback speed.
|
|
||||||
'-movflags',
|
|
||||||
'faststart',
|
|
||||||
'-fps_mode',
|
|
||||||
'passthrough',
|
|
||||||
'-map',
|
|
||||||
`0:${videoStream.index}`,
|
|
||||||
'-map_metadata',
|
|
||||||
'-1',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (audioStream) {
|
if (audioStream) {
|
||||||
options.push('-map', `0:${audioStream.index}`);
|
options.push('-map', `0:${audioStream.index}`);
|
||||||
}
|
}
|
||||||
@@ -157,18 +209,22 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
}
|
}
|
||||||
if (this.getGopSize() > 0) {
|
if (this.getGopSize() > 0) {
|
||||||
options.push('-g', `${this.getGopSize()}`);
|
options.push('-g', `${this.getGopSize()}`);
|
||||||
|
if (this.tune.strictGop) {
|
||||||
|
options.push('-keyint_min', `${this.getGopSize()}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const isHvc = (videoCodec === 'copy' ? videoStream.codecName : videoCodec) === VideoCodec.Hevc;
|
||||||
if (
|
if (isHvc) {
|
||||||
this.config.targetVideoCodec === VideoCodec.Hevc &&
|
|
||||||
(videoCodec !== 'copy' || videoStream.codecName === 'hevc')
|
|
||||||
) {
|
|
||||||
options.push('-tag:v', 'hvc1');
|
options.push('-tag:v', 'hvc1');
|
||||||
}
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEncoderOptions(): string[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
getFilterOptions(videoStream: VideoStreamInfo) {
|
getFilterOptions(videoStream: VideoStreamInfo) {
|
||||||
const options = [];
|
const options = [];
|
||||||
if (this.shouldScale(videoStream)) {
|
if (this.shouldScale(videoStream)) {
|
||||||
@@ -272,25 +328,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
|
|
||||||
getScaling(videoStream: VideoStreamInfo, mult = 2) {
|
getScaling(videoStream: VideoStreamInfo, mult = 2) {
|
||||||
const targetResolution = this.getTargetResolution(videoStream);
|
const targetResolution = this.getTargetResolution(videoStream);
|
||||||
return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
return isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
||||||
}
|
|
||||||
|
|
||||||
getSize(videoStream: VideoStreamInfo) {
|
|
||||||
const smaller = this.getTargetResolution(videoStream);
|
|
||||||
const factor = Math.max(videoStream.height, videoStream.width) / Math.min(videoStream.height, videoStream.width);
|
|
||||||
let larger = Math.round(smaller * factor);
|
|
||||||
if (larger % 2 !== 0) {
|
|
||||||
larger -= 1;
|
|
||||||
}
|
|
||||||
return this.isVideoVertical(videoStream) ? { width: smaller, height: larger } : { width: larger, height: smaller };
|
|
||||||
}
|
|
||||||
|
|
||||||
isVideoRotated(videoStream: VideoStreamInfo) {
|
|
||||||
return Math.abs(videoStream.rotation) === 90;
|
|
||||||
}
|
|
||||||
|
|
||||||
isVideoVertical(videoStream: VideoStreamInfo) {
|
|
||||||
return videoStream.height > videoStream.width || this.isVideoRotated(videoStream);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isBitrateConstrained() {
|
isBitrateConstrained() {
|
||||||
@@ -353,23 +391,18 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
|
export class BaseHWConfig extends BaseConfig {
|
||||||
protected device: string;
|
protected device: string;
|
||||||
protected interfaces: VideoInterfaces;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected config: SystemConfigFFmpegDto,
|
protected config: SystemConfigFFmpegDto,
|
||||||
interfaces: VideoInterfaces,
|
protected interfaces: VideoInterfaces,
|
||||||
|
tune?: VideoTuning,
|
||||||
) {
|
) {
|
||||||
super(config);
|
super(config, tune);
|
||||||
this.interfaces = interfaces;
|
|
||||||
this.device = this.getDevice(interfaces);
|
this.device = this.getDevice(interfaces);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSupportedCodecs() {
|
|
||||||
return [VideoCodec.H264, VideoCodec.Hevc];
|
|
||||||
}
|
|
||||||
|
|
||||||
validateDevices(devices: string[]) {
|
validateDevices(devices: string[]) {
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
throw new Error('No /dev/dri devices found. If using Docker, make sure at least one /dev/dri device is mounted');
|
throw new Error('No /dev/dri devices found. If using Docker, make sure at least one /dev/dri device is mounted');
|
||||||
@@ -474,24 +507,32 @@ export class ThumbnailConfig extends BaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class H264Config extends BaseConfig {
|
export class H264Config extends BaseConfig {
|
||||||
getOutputThreadOptions() {
|
getEncoderOptions(): string[] {
|
||||||
const options = super.getOutputThreadOptions();
|
const out = this.getOutputThreadOptions();
|
||||||
if (this.config.threads === 1) {
|
if (this.tune.strictGop) {
|
||||||
options.push('-x264-params', 'frame-threads=1:pools=none');
|
out.push('-sc_threshold:v', '0');
|
||||||
}
|
}
|
||||||
|
if (this.config.threads === 1) {
|
||||||
return options;
|
out.push('-x264-params', 'frame-threads=1:pools=none');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HEVCConfig extends BaseConfig {
|
export class HEVCConfig extends BaseConfig {
|
||||||
getOutputThreadOptions() {
|
getEncoderOptions(): string[] {
|
||||||
const options = super.getOutputThreadOptions();
|
const out: string[] = this.getOutputThreadOptions();
|
||||||
if (this.config.threads === 1) {
|
const params: string[] = [];
|
||||||
options.push('-x265-params', 'frame-threads=1:pools=none');
|
if (this.tune.strictGop) {
|
||||||
|
params.push('no-scenecut=1', 'no-open-gop=1');
|
||||||
}
|
}
|
||||||
|
if (this.config.threads === 1) {
|
||||||
return options;
|
params.push('frame-threads=1', 'pools=none');
|
||||||
|
}
|
||||||
|
if (params.length > 0) {
|
||||||
|
out.push('-x265-params', params.join(':'));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,8 +561,8 @@ export class VP9Config extends BaseConfig {
|
|||||||
return [`-${this.useCQP() ? 'q:v' : 'crf'}`, `${this.config.crf}`, '-b:v', `${bitrates.max}${bitrates.unit}`];
|
return [`-${this.useCQP() ? 'q:v' : 'crf'}`, `${this.config.crf}`, '-b:v', `${bitrates.max}${bitrates.unit}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutputThreadOptions() {
|
getEncoderOptions(): string[] {
|
||||||
return ['-row-mt', '1', ...super.getOutputThreadOptions()];
|
return ['-row-mt', '1', ...this.getOutputThreadOptions()];
|
||||||
}
|
}
|
||||||
|
|
||||||
eligibleForTwoPass() {
|
eligibleForTwoPass() {
|
||||||
@@ -543,23 +584,22 @@ export class AV1Config extends BaseConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getBitrateOptions() {
|
getBitrateOptions() {
|
||||||
const options = ['-crf', `${this.config.crf}`];
|
return ['-crf', `${this.config.crf}`];
|
||||||
const bitrates = this.getBitrateDistribution();
|
|
||||||
const svtparams = [];
|
|
||||||
if (this.config.threads > 0) {
|
|
||||||
svtparams.push(`lp=${this.config.threads}`);
|
|
||||||
}
|
|
||||||
if (bitrates.max > 0) {
|
|
||||||
svtparams.push(`mbr=${bitrates.max}${bitrates.unit}`);
|
|
||||||
}
|
|
||||||
if (svtparams.length > 0) {
|
|
||||||
options.push('-svtav1-params', svtparams.join(':'));
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutputThreadOptions() {
|
getEncoderOptions(): string[] {
|
||||||
return []; // Already set above with svtav1-params
|
const params: string[] = [];
|
||||||
|
if (this.tune.lowLatency) {
|
||||||
|
params.push('hierarchical-levels=3', 'lookahead=0', 'enable-tf=0');
|
||||||
|
}
|
||||||
|
if (this.config.threads > 0) {
|
||||||
|
params.push(`lp=${this.config.threads}`);
|
||||||
|
}
|
||||||
|
const bitrates = this.getBitrateDistribution();
|
||||||
|
if (bitrates.max > 0) {
|
||||||
|
params.push(`mbr=${bitrates.max}${bitrates.unit}`);
|
||||||
|
}
|
||||||
|
return params.length > 0 ? ['-svtav1-params', params.join(':')] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
eligibleForTwoPass() {
|
eligibleForTwoPass() {
|
||||||
@@ -572,10 +612,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
|
|||||||
return '0';
|
return '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
getSupportedCodecs() {
|
|
||||||
return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Av1];
|
|
||||||
}
|
|
||||||
|
|
||||||
getBaseInputOptions() {
|
getBaseInputOptions() {
|
||||||
return ['-init_hw_device', `cuda=cuda:${this.device}`, '-filter_hw_device', 'cuda'];
|
return ['-init_hw_device', `cuda=cuda:${this.device}`, '-filter_hw_device', 'cuda'];
|
||||||
}
|
}
|
||||||
@@ -652,6 +688,14 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEncoderOptions(): string[] {
|
||||||
|
const out = this.getOutputThreadOptions();
|
||||||
|
if (this.tune.strictGop) {
|
||||||
|
out.push('-forced-idr', '1');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
getRefs() {
|
getRefs() {
|
||||||
const bframes = this.getBFrames();
|
const bframes = this.getBFrames();
|
||||||
if (bframes > 0 && bframes < 3 && this.config.refs < 3) {
|
if (bframes > 0 && bframes < 3 && this.config.refs < 3) {
|
||||||
@@ -703,8 +747,8 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
|
|||||||
return ['-threads', '1'];
|
return ['-threads', '1'];
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutputThreadOptions() {
|
getEncoderOptions(): string[] {
|
||||||
return [];
|
return this.tune.strictGop ? ['-forced-idr', '1'] : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,10 +793,6 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSupportedCodecs() {
|
|
||||||
return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md
|
// recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md
|
||||||
getBFrames() {
|
getBFrames() {
|
||||||
if (this.config.bframes < 0) {
|
if (this.config.bframes < 0) {
|
||||||
@@ -775,6 +815,14 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
|
|||||||
getScaling(videoStream: VideoStreamInfo): string {
|
getScaling(videoStream: VideoStreamInfo): string {
|
||||||
return super.getScaling(videoStream, 1);
|
return super.getScaling(videoStream, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEncoderOptions(): string[] {
|
||||||
|
const out = this.getOutputThreadOptions();
|
||||||
|
if (this.tune.strictGop) {
|
||||||
|
out.push('-idr_interval', '0');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
|
export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
|
||||||
@@ -888,13 +936,17 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSupportedCodecs() {
|
|
||||||
return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1];
|
|
||||||
}
|
|
||||||
|
|
||||||
useCQP() {
|
useCQP() {
|
||||||
return this.config.cqMode !== CQMode.Icq || this.config.targetVideoCodec === VideoCodec.Vp9;
|
return this.config.cqMode !== CQMode.Icq || this.config.targetVideoCodec === VideoCodec.Vp9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEncoderOptions(): string[] {
|
||||||
|
const out = this.getOutputThreadOptions();
|
||||||
|
if (this.tune.strictGop) {
|
||||||
|
out.push('-idr_interval', '0');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
|
export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
|
||||||
@@ -988,10 +1040,6 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
|
|||||||
return ['-rc_mode', 'CQP', '-qp_init', `${this.config.crf}`];
|
return ['-rc_mode', 'CQP', '-qp_init', `${this.config.crf}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
getSupportedCodecs() {
|
|
||||||
return [VideoCodec.H264, VideoCodec.Hevc];
|
|
||||||
}
|
|
||||||
|
|
||||||
getVideoCodec(): string {
|
getVideoCodec(): string {
|
||||||
return `${this.config.targetVideoCodec}_rkmpp`;
|
return `${this.config.targetVideoCodec}_rkmpp`;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -597,7 +597,7 @@ export const train = {
|
|||||||
packets: {
|
packets: {
|
||||||
totalDuration: 12_290,
|
totalDuration: 12_290,
|
||||||
packetCount: 1229,
|
packetCount: 1229,
|
||||||
outputFrames: 1303,
|
outputFrames: 1304,
|
||||||
keyframePts: [
|
keyframePts: [
|
||||||
0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210, 10_811,
|
0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210, 10_811,
|
||||||
11_411, 12_062, 12_703,
|
11_411, 12_062, 12_703,
|
||||||
|
|||||||
@@ -75,5 +75,6 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
|||||||
copyFile: vitest.fn(),
|
copyFile: vitest.fn(),
|
||||||
utimes: vitest.fn(),
|
utimes: vitest.fn(),
|
||||||
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
|
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
|
||||||
|
watchDir: vitest.fn().mockImplementation(() => ({ close: vitest.fn(), on: vitest.fn() })),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -181,7 +181,11 @@ export const automock = <T>(
|
|||||||
const mocks: Mock[] = [];
|
const mocks: Mock[] = [];
|
||||||
|
|
||||||
const instance = new Dependency(...args);
|
const instance = new Dependency(...args);
|
||||||
for (const property of Object.getOwnPropertyNames(Dependency.prototype)) {
|
const propertyNames = new Set([
|
||||||
|
...Object.getOwnPropertyNames(Dependency.prototype),
|
||||||
|
...Object.getOwnPropertyNames(instance),
|
||||||
|
]);
|
||||||
|
for (const property of propertyNames) {
|
||||||
if (property === 'constructor') {
|
if (property === 'constructor') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -346,7 +350,7 @@ export const getMocks = () => {
|
|||||||
trash: automock(TrashRepository),
|
trash: automock(TrashRepository),
|
||||||
user: automock(UserRepository, { strict: false }),
|
user: automock(UserRepository, { strict: false }),
|
||||||
versionHistory: automock(VersionHistoryRepository),
|
versionHistory: automock(VersionHistoryRepository),
|
||||||
videoStream: automock(VideoStreamRepository),
|
videoStream: automock(VideoStreamRepository, { strict: false }),
|
||||||
view: automock(ViewRepository),
|
view: automock(ViewRepository),
|
||||||
// eslint-disable-next-line no-sparse-arrays
|
// eslint-disable-next-line no-sparse-arrays
|
||||||
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
|
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
|
||||||
@@ -500,6 +504,7 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st
|
|||||||
callback(exitCode);
|
callback(exitCode);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
kill: vitest.fn(),
|
||||||
} as unknown as ChildProcessWithoutNullStreams;
|
} as unknown as ChildProcessWithoutNullStreams;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"target": "es2022",
|
"target": "es2024",
|
||||||
"moduleResolution": "node16",
|
"moduleResolution": "node16",
|
||||||
"lib": ["dom", "es2023"],
|
"lib": ["dom", "es2024"],
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||||
"@immich/justified-layout-wasm": "^0.4.3",
|
"@immich/justified-layout-wasm": "^0.4.3",
|
||||||
"@immich/sdk": "workspace:*",
|
"@immich/sdk": "workspace:*",
|
||||||
"@immich/ui": "^0.79.0",
|
"@immich/ui": "^0.77.0",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar>
|
<ControlAppBar showBackButton={false}>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<header>
|
<header>
|
||||||
<ControlAppBar>
|
<ControlAppBar showBackButton={false}>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant="inline" />
|
<Logo variant="inline" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { IconButton, Logo, toastManager } from '@immich/ui';
|
import { IconButton, Logo, toastManager } from '@immich/ui';
|
||||||
import { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
|
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
|
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
|
||||||
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
|
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar>
|
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||||
|
|||||||
@@ -1,49 +1,97 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
|
import { browser } from '$app/environment';
|
||||||
|
import { IconButton } from '@immich/ui';
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiClose } from '@mdi/js';
|
||||||
import type { Snippet } from 'svelte';
|
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
showBackButton?: boolean;
|
||||||
backIcon?: string;
|
backIcon?: string;
|
||||||
class?: ClassValue;
|
tailwindClasses?: string;
|
||||||
|
forceDark?: boolean;
|
||||||
|
multiRow?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
title?: Snippet | string;
|
|
||||||
leading?: Snippet;
|
leading?: Snippet;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
trailing?: Snippet;
|
trailing?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
|
let {
|
||||||
|
showBackButton = true,
|
||||||
|
backIcon = mdiClose,
|
||||||
|
tailwindClasses = '',
|
||||||
|
forceDark = false,
|
||||||
|
multiRow = false,
|
||||||
|
onClose = () => {},
|
||||||
|
leading,
|
||||||
|
children,
|
||||||
|
trailing,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let appBarBorder = $state('border border-subtle');
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (window.scrollY > 80) {
|
||||||
|
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
|
||||||
|
|
||||||
|
if (forceDark) {
|
||||||
|
appBarBorder = 'border border-gray-600';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
appBarBorder = 'border border-subtle';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (browser) {
|
||||||
|
document.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (browser) {
|
||||||
|
document.removeEventListener('scroll', onScroll);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={['absolute top-0 w-full bg-transparent p-2']}>
|
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
|
||||||
<ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
|
<nav
|
||||||
{#if title || leading}
|
id="asset-selection-app-bar"
|
||||||
<ControlBarHeader>
|
class={[
|
||||||
{#if title}
|
'grid',
|
||||||
<ControlBarTitle>
|
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]',
|
||||||
{#if typeof title === 'string'}
|
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
|
||||||
{title}
|
'justify-between lg:grid-cols-[25%_50%_25%]',
|
||||||
{:else}
|
appBarBorder,
|
||||||
{@render title()}
|
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
|
||||||
{/if}
|
tailwindClasses,
|
||||||
</ControlBarTitle>
|
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
|
||||||
{/if}
|
]}
|
||||||
{@render leading?.()}
|
>
|
||||||
</ControlBarHeader>
|
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
|
||||||
{/if}
|
{#if showBackButton}
|
||||||
|
<IconButton
|
||||||
|
aria-label={$t('close')}
|
||||||
|
onclick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
icon={backIcon}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{@render leading?.()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if children}
|
<div class="w-full">
|
||||||
<ControlBarContent>
|
{@render children?.()}
|
||||||
{@render children()}
|
</div>
|
||||||
</ControlBarContent>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if trailing}
|
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0">
|
||||||
<ControlBarOverflow>
|
{@render trailing?.()}
|
||||||
{@render trailing()}
|
</div>
|
||||||
</ControlBarOverflow>
|
</nav>
|
||||||
{/if}
|
|
||||||
</ControlBar>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,18 +7,19 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
forceDark?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children, forceDark }: Props = $props();
|
||||||
|
|
||||||
const onClose = () => assetMultiSelectManager.clear();
|
const onClose = () => assetMultiSelectManager.clear();
|
||||||
|
|
||||||
const assets = $derived(assetMultiSelectManager.assets);
|
const assets = $derived(assetMultiSelectManager.assets);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ControlAppBar {onClose} backIcon={mdiClose}>
|
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<div class="font-medium text-primary">
|
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}">
|
||||||
<p class="block sm:hidden">{assets.length}</p>
|
<p class="block sm:hidden">{assets.length}</p>
|
||||||
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, invalidate, onNavigate } from '$app/navigation';
|
import { goto, invalidate, onNavigate } from '$app/navigation';
|
||||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
|
import AlbumDescription from './AlbumDescription.svelte';
|
||||||
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
|
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
|
||||||
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
|
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
|
||||||
|
import AlbumTitle from './AlbumTitle.svelte';
|
||||||
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
|
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
|
||||||
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
|
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
|
||||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||||
@@ -76,8 +78,6 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import AlbumDescription from './AlbumDescription.svelte';
|
|
||||||
import AlbumTitle from './AlbumTitle.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@@ -499,7 +499,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
{#if viewMode === AlbumPageViewMode.VIEW}
|
{#if viewMode === AlbumPageViewMode.VIEW}
|
||||||
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<ActionButton action={Cast} />
|
<ActionButton action={Cast} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
|
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
|
||||||
|
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||||
|
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
|
||||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||||
@@ -34,7 +37,6 @@
|
|||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiChevronUp,
|
mdiChevronUp,
|
||||||
mdiClose,
|
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
mdiHeart,
|
mdiHeart,
|
||||||
mdiHeartOutline,
|
mdiHeartOutline,
|
||||||
@@ -52,8 +54,6 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { Attachment } from 'svelte/attachments';
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
|
|
||||||
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
|
|
||||||
|
|
||||||
let memoryGallery: HTMLElement | undefined = $state();
|
let memoryGallery: HTMLElement | undefined = $state();
|
||||||
let memoryWrapper: HTMLElement | undefined = $state();
|
let memoryWrapper: HTMLElement | undefined = $state();
|
||||||
@@ -327,8 +327,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{#if assetMultiSelectManager.selectionActive}
|
{#if assetMultiSelectManager.selectionActive}
|
||||||
<div class="sticky top-0 z-1 dark">
|
<div class="dark sticky top-0 z-1">
|
||||||
<AssetSelectControlBar>
|
<AssetSelectControlBar forceDark>
|
||||||
{@const Actions = getAssetBulkActions($t)}
|
{@const Actions = getAssetBulkActions($t)}
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -365,33 +365,22 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="memory-viewer"
|
id="memory-viewer"
|
||||||
class="dark w-full text-white bg-immich-dark-gray"
|
class="w-full bg-immich-dark-gray"
|
||||||
bind:this={memoryWrapper}
|
bind:this={memoryWrapper}
|
||||||
bind:clientHeight={viewport.height}
|
bind:clientHeight={viewport.height}
|
||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<div
|
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||||
class="max-md:h-auto max-md:flex-col dark grid grid-cols-[100%] md:grid-cols-[25%_50%_25%] px-2 py-2 md:px-4 md:py-4"
|
{#snippet leading()}
|
||||||
>
|
{#if current}
|
||||||
{#if current}
|
|
||||||
<div class="flex gap-2 md:gap-6 items-center">
|
|
||||||
<IconButton
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
icon={mdiClose}
|
|
||||||
aria-label={$t('close')}
|
|
||||||
size="large"
|
|
||||||
onclick={() => goto(Route.photos())}
|
|
||||||
/>
|
|
||||||
<p class="text-lg">
|
<p class="text-lg">
|
||||||
{$memoryLaneTitle(current.memory)}
|
{$memoryLaneTitle(current.memory)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/snippet}
|
||||||
|
|
||||||
<div class="dark flex w-full place-content-center place-items-center gap-2">
|
<div class="dark flex place-content-center place-items-center gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -449,7 +438,7 @@
|
|||||||
</media-mute-button>
|
</media-mute-button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ControlAppBar>
|
||||||
|
|
||||||
{#if galleryInView}
|
{#if galleryInView}
|
||||||
<div
|
<div
|
||||||
@@ -473,7 +462,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Viewer -->
|
<!-- Viewer -->
|
||||||
<section class="overflow-hidden pt-6 md:pt-0" bind:clientHeight={viewerHeight}>
|
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}>
|
||||||
<div
|
<div
|
||||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
|
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@
|
|||||||
<DownloadAction />
|
<DownloadAction />
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
|
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
|
||||||
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
|
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
|
||||||
|
|||||||
+4
-4
@@ -5,6 +5,9 @@
|
|||||||
import { listNavigation } from '$lib/actions/list-navigation';
|
import { listNavigation } from '$lib/actions/list-navigation';
|
||||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||||
|
import EditNameInput from './EditNameInput.svelte';
|
||||||
|
import MergeFaceSelector from './MergeFaceSelector.svelte';
|
||||||
|
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
|
||||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||||
@@ -51,9 +54,6 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import EditNameInput from './EditNameInput.svelte';
|
|
||||||
import MergeFaceSelector from './MergeFaceSelector.svelte';
|
|
||||||
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||||
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<ContextMenuButton
|
<ContextMenuButton
|
||||||
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
||||||
|
|||||||
@@ -387,7 +387,8 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="fixed inset-s-0 top-0 z-2 w-full">
|
<div class="fixed inset-s-0 top-0 z-2 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="mx-auto w-full max-w-2xl pe-2">
|
<div class="absolute bg-light"></div>
|
||||||
|
<div class="w-full flex-1 ps-4">
|
||||||
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||||
</div>
|
</div>
|
||||||
</ControlAppBar>
|
</ControlAppBar>
|
||||||
|
|||||||
@@ -388,6 +388,22 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
<SettingAccordion
|
||||||
|
key="realtime-transcoding"
|
||||||
|
title={$t('admin.transcoding_realtime')}
|
||||||
|
subtitle={$t('admin.transcoding_realtime_description')}
|
||||||
|
>
|
||||||
|
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||||
|
<SettingSwitch
|
||||||
|
title={$t('admin.transcoding_realtime_enabled')}
|
||||||
|
subtitle={$t('admin.transcoding_realtime_enabled_description')}
|
||||||
|
bind:checked={configToEdit.ffmpeg.realtime.enabled}
|
||||||
|
isEdited={configToEdit.ffmpeg.realtime.enabled !== configToEdit.ffmpeg.realtime.enabled}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingAccordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-4">
|
<div class="ms-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user