diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml deleted file mode 100644 index 6a698a3..0000000 --- a/.github/workflows/documentation.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Build and publish documentation - -on: - workflow_dispatch: - release: - types: [ published ] - -jobs: - update-api-documentation: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Java - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'adopt' - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Generate API documentation - run: ./gradlew dokkaHtmlMultiModule - - name: Deploy API documentation to Github Pages - uses: JamesIves/github-pages-deploy-action@v4 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BRANCH: gh-pages - FOLDER: build/dokka/htmlMultiModule \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9fdbe7..216319e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,8 +21,8 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.OSSRH_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.OSSRH_TOKEN }} + ORG_GRADLE_PROJECT_centralPortalUsername: ${{ secrets.CENTRAL_PORTAL_USERNAME }} + ORG_GRADLE_PROJECT_centralPortalPassword: ${{ secrets.CENTRAL_PORTAL_PASSWORD }} - name: Generate documentation run: ./gradlew dokkaHtmlMultiModule - name: Deploy API documentation to Github Pages diff --git a/README.md b/README.md index 92eb56d..bfc7d96 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,12 @@ implementation("io.github.thibaultbee.krtmp:rtmp:1.0.0") ## Usage -Creates a RTMP publish client with the Factory `RtmpClientConnectionFactory`: +### Client + +Creates a RTMP client with the Factory `RtmpClient`: ```kotlin -val client = RtmpPublishClientConnectionFactory().create( +val client = RtmpClient( "rtmp://my.server.com/app/streamkey" // Your RTMP server URL (incl app name and stream key) ) ``` @@ -44,15 +46,6 @@ client.createStream() client.publish(Command.Publish.Type.LIVE) ``` -If you have raw audio and video frames, you need to mux them into FLV tag headers. You can use -the `FlvMuxer` class for that. - -```kotlin -val flvMuxer = client.flvMuxer -``` - -See [FLV](#flv) for more details to write audio and video frames.. - If you already have FLV data, write your video/audio data: ```kotlin @@ -72,7 +65,21 @@ try { } ``` -For advanced configuration, see `RtmpClientSettings`. +See [FLV](#flv) for more details to write audio and video frames.. + +### Server + +Use the `RtmpServer` to create a RTMP server: + +```kotlin +val server = RtmpServer("0.0.0.0:1935") // Listening on port 1935 +``` + +Then start the server: + +```kotlin +server.listen() +``` # FLV @@ -84,8 +91,9 @@ Features: - [x] Demuxer for FLV - [x] AMF0 metadata - [ ] AMF3 metadata -- [x] Supported audio codec: AAC -- [x] Supported video codec: AVC/H.264 and enhanced RTMP codecs: HEVC/H.265, VP9, AV1 +- [x] Support for legacy RTMP +- [x] Support for enhanced RTMP v1: AV1, HEVC, VP8, VP9 +- [x] Support for enhanced RTMP v2: Multitrack, Opus,... ## Installation @@ -100,36 +108,36 @@ implementation("io.github.thibaultbee.krtmp:flv:1.0.0") Creates a FLV muxer and add audio/video data: ```kotlin -val muxer = FlvMuxer() +val muxer = FLVMuxer(path = "/path/to/file.flv") + +// Write FLV header +flvMuxer.encodeFlvHeader(hasAudio, hasVideo) // Register audio configurations (if any) -val audioConfig = FlvAudioConfig( +val audioConfig = FLVAudioConfig( FlvAudioConfig.SoundFormat.AAC, FlvAudioConfig.SoundRate.KHZ44, FlvAudioConfig.SoundSize.SND8BIT, FlvAudioConfig.SoundType.STEREO ) -val audioId = muxer.addStream(audioConfig) - -// Register audio configurations (if any) -val videoConfig = FlvVideoConfig( - +// Register video configurations (if any) +val videoConfig = FLVVideoConfig( ) -val videoId = muxer.addStream(videoConfig) -// Start the muxer (write FlvTag (if needed) and onMetaData) -muxer.startStream() +// Write onMetadata +muxer.encode(0, OnMetadata(audioConfig, videoConfig)) // Write audio/video data -muxer.write(audioFrame) -muxer.write(videoFrame) -muxer.write(audioFrame) -muxer.write(videoFrame) -// till you're done +muxer.encode(audioFrame) +muxer.encode(videoFrame) +muxer.encode(audioFrame) +muxer.encode(videoFrame) -// Stop the muxer -muxer.stopStream() +// Till you're done, then +muxer.flush() +// Close the output +muxer.close() ``` # AMF @@ -140,7 +148,7 @@ Features: - [x] Serializer for AMF0 - [ ] Serializer for AMF3 -- [ ] Deserializer for AMF0 +- [x] Deserializer for AMF0 - [ ] Deserializer for AMF3 ## Installation @@ -172,7 +180,7 @@ val array = Amf.encodeToByteArray(MyData.serializer(), data) # TODO -- [x] A FLV/RTMP parameter for supported level: (legacy, enhanced v1, enhanced v2,...) +- [ ] More tests (missing tests samples) # Licence diff --git a/amf/build.gradle.kts b/amf/build.gradle.kts index 5a36d7f..65bd783 100644 --- a/amf/build.gradle.kts +++ b/amf/build.gradle.kts @@ -27,7 +27,7 @@ kotlin { compilations.all { compileTaskProvider.configure { compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) + jvmTarget.set(JvmTarget.JVM_18) } } } @@ -68,7 +68,7 @@ kotlin { android { namespace = "io.github.thibaultbee.krtmp.amf" - compileSdk = 34 + compileSdk = 36 defaultConfig { minSdk = 21 } diff --git a/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfContainer.kt b/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfContainer.kt index f8f888d..e7694f5 100644 --- a/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfContainer.kt +++ b/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfContainer.kt @@ -64,5 +64,5 @@ class AmfContainer internal constructor(private val elements: MutableList buildString { append(k) - append(':') + append('=') append(v) } } diff --git a/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfObject.kt b/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfObject.kt index 0e4ea2b..788dc85 100644 --- a/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfObject.kt +++ b/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfObject.kt @@ -76,13 +76,13 @@ class AmfObject internal constructor(private val elements: MutableMap buildString { append(k) - append(':') + append('=') append(v) } } diff --git a/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfStrictArray.kt b/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfStrictArray.kt index b8074ac..57f5b11 100644 --- a/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfStrictArray.kt +++ b/amf/src/commonMain/kotlin/io/github/thibaultbee/krtmp/amf/elements/containers/AmfStrictArray.kt @@ -34,7 +34,8 @@ fun amf0StrictArrayFrom(source: Source): AmfStrictArray { return amf0StrictArray } -fun amfStrictArrayOf(initialElements: List) = AmfStrictArray().apply { addAll(initialElements) } +fun amfStrictArrayOf(initialElements: List) = + AmfStrictArray().apply { addAll(initialElements) } class AmfStrictArray internal constructor(private val elements: MutableList = mutableListOf()) : AmfElement(), MutableList by elements { @@ -59,5 +60,5 @@ class AmfStrictArray internal constructor(private val elements: MutableList String = { if (it.startsWith("~/")) { // the value is a path to file on disk. Read its contents @@ -35,5 +34,5 @@ fun Project.loadFileContents( val projectPropertiesValue = project.properties[projectPropertyName]?.toString() if (projectPropertiesValue != null) return decodeIfNeeded(projectPropertiesValue) - return "" + return null } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/krtmp-publish.gradle.kts b/buildSrc/src/main/kotlin/krtmp-publish.gradle.kts index 2aedded..ed92554 100644 --- a/buildSrc/src/main/kotlin/krtmp-publish.gradle.kts +++ b/buildSrc/src/main/kotlin/krtmp-publish.gradle.kts @@ -9,18 +9,18 @@ val javadocJar by tasks.registering(Jar::class) { publishing { repositories { - maven(url = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") { - name = "mavenCentral" + maven(url = "https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") { + name = "centralPortal" credentials { - username = project.loadProperty("ossrh_username") - password = project.loadProperty("ossrh_password") + username = project.loadProperty("centralPortalUsername") + password = project.loadProperty("centralPortalPassword") } } - maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/") { - name = "mavenCentralSnapshots" + maven(url = "https://central.sonatype.com/repository/maven-snapshots/") { + name = "centralPortalSnapshots" credentials { - username = project.loadProperty("ossrh_username") - password = project.loadProperty("ossrh_password") + username = project.loadProperty("centralPortalUsername") + password = project.loadProperty("centralPortalPassword") } } } @@ -68,9 +68,9 @@ if (project.hasProperty("signing_key_id") && project.hasProperty("signing_key") ) { signing { useInMemoryPgpKeys( - project.loadProperty("signing_key_id"), - project.loadFileContents("signing_key"), - project.loadProperty("signing_password") + project.loadProperty("signingInMemoryKeyId"), + project.loadFileContents("signingInMemoryKey"), + project.loadProperty("signingInMemoryKeyPassword") ) sign(publishing.publications) } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index bd25079..a465265 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -26,7 +26,7 @@ kotlin { compilations.all { compileTaskProvider.configure { compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) + jvmTarget.set(JvmTarget.JVM_18) } } } @@ -63,7 +63,7 @@ kotlin { android { namespace = "io.github.thibaultbee.krtmp.common" - compileSdk = 34 + compileSdk = 36 defaultConfig { minSdk = 21 } diff --git a/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/DefaultLogger.kt b/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/DefaultLogger.kt index a17e395..fdeb647 100644 --- a/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/DefaultLogger.kt +++ b/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/DefaultLogger.kt @@ -3,7 +3,7 @@ package io.github.thibaultbee.krtmp.common.logger /** * Default logger implementation. */ -class DefaultLogger: ILogger { +class DefaultLogger: IKrtmpLogger { override fun e(tag: String, message: String, tr: Throwable?) { println("E/$tag: $message") tr?.printStackTrace() diff --git a/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/ILogger.kt b/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/IKrtmpLogger.kt similarity index 98% rename from common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/ILogger.kt rename to common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/IKrtmpLogger.kt index ec2e5ed..3ba45bc 100644 --- a/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/ILogger.kt +++ b/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/IKrtmpLogger.kt @@ -1,6 +1,6 @@ package io.github.thibaultbee.krtmp.common.logger -interface ILogger { +interface IKrtmpLogger { /** * Logs an error. * diff --git a/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/Logger.kt b/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/KrtmpLogger.kt similarity index 91% rename from common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/Logger.kt rename to common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/KrtmpLogger.kt index 99b6f52..b37eb80 100644 --- a/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/Logger.kt +++ b/common/src/commonMain/kotlin/io/github/thibaultbee/krtmp/common/logger/KrtmpLogger.kt @@ -1,11 +1,11 @@ package io.github.thibaultbee.krtmp.common.logger -object Logger { +object KrtmpLogger { /** * The logger implementation. - * Customize it by setting a new [ILogger] implementation. + * Customize it by setting a new [IKrtmpLogger] implementation. */ - var logger: ILogger = DefaultLogger() + var logger: IKrtmpLogger = DefaultLogger() /** * Logs an error. diff --git a/flv/build.gradle.kts b/flv/build.gradle.kts index 32886cc..095235e 100644 --- a/flv/build.gradle.kts +++ b/flv/build.gradle.kts @@ -26,7 +26,7 @@ kotlin { compilations.all { compileTaskProvider.configure { compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) + jvmTarget.set(JvmTarget.JVM_18) } } } @@ -47,15 +47,21 @@ kotlin { commonMain.dependencies { implementation(libs.kotlinx.io.core) implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.coroutines.core) api(project(":amf")) api(project(":common")) } commonTest.dependencies { implementation(libs.kotlin.test) } + androidMain { + kotlin.srcDir("src/commonJvmAndroid/kotlin") + } + jvmMain { + kotlin.srcDir("src/commonJvmAndroid/kotlin") + } jvmTest.dependencies { implementation(libs.kotlin.test) - implementation(libs.jcodec) } } @@ -68,7 +74,7 @@ kotlin { android { namespace = "io.github.thibaultbee.krtmp.flv" - compileSdk = 34 + compileSdk = 36 defaultConfig { minSdk = 21 } diff --git a/flv/src/commonJvmAndroid/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteBufferBackedRawSource.kt b/flv/src/commonJvmAndroid/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteBufferBackedRawSource.kt new file mode 100644 index 0000000..6883f7b --- /dev/null +++ b/flv/src/commonJvmAndroid/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteBufferBackedRawSource.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.flv.sources + +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.write +import java.nio.ByteBuffer + +/** + * A [RawSource] that reads from a [ByteBuffer]. + * + * @param buffer the [ByteBuffer] to wrap + */ +class ByteBufferBackedRawSource(private val buffer: ByteBuffer) : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + if (byteCount < 0) { + throw IllegalArgumentException("byteCount < 0: $byteCount") + } + + if (!buffer.hasRemaining()) { + return -1 + } + + val bytesToRead = minOf(byteCount, buffer.remaining().toLong()) + val previousLimit = buffer.limit() + buffer.limit(buffer.position() + bytesToRead.toInt()) + + sink.write(buffer) + + buffer.limit(previousLimit) + return bytesToRead + } + + override fun close() { + // Nothing to do + } +} \ No newline at end of file diff --git a/flv/src/commonJvmAndroid/kotlin/io/github/thibaultbee/krtmp/flv/sources/test/ByteBufferBackedRawSource.kt b/flv/src/commonJvmAndroid/kotlin/io/github/thibaultbee/krtmp/flv/sources/test/ByteBufferBackedRawSource.kt new file mode 100644 index 0000000..0b856b2 --- /dev/null +++ b/flv/src/commonJvmAndroid/kotlin/io/github/thibaultbee/krtmp/flv/sources/test/ByteBufferBackedRawSource.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.flv.sources.test + +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.write +import java.nio.ByteBuffer + +/** + * A [RawSource] that reads from a [ByteBuffer]. + * + * @param buffer the [ByteBuffer] to wrap + */ +class ByteBufferBackedRawSource(private val buffer: ByteBuffer) : RawSource { + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { + if (byteCount < 0) { + throw IllegalArgumentException("byteCount < 0: $byteCount") + } + + if (!buffer.hasRemaining()) { + return -1 + } + + val bytesToRead = minOf(byteCount, buffer.remaining().toLong()) + val previousLimit = buffer.limit() + buffer.limit(buffer.position() + bytesToRead.toInt()) + + sink.write(buffer) + + buffer.limit(previousLimit) + return bytesToRead + } + + override fun close() { + // Nothing to do + } +} \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVDemuxer.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVDemuxer.kt index ccb191b..a50d5a3 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVDemuxer.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVDemuxer.kt @@ -19,6 +19,7 @@ import io.github.thibaultbee.krtmp.amf.AmfVersion import io.github.thibaultbee.krtmp.flv.tags.FLVTag import io.github.thibaultbee.krtmp.flv.tags.RawFLVTag import io.github.thibaultbee.krtmp.flv.util.FLVHeader +import kotlinx.coroutines.coroutineScope import kotlinx.io.Source import kotlinx.io.buffered import kotlinx.io.files.Path @@ -102,6 +103,11 @@ class FLVDemuxer(private val source: Source, private val amfVersion: AmfVersion return block(source) } + /** + * Decodes the FLV header. + * + * @return the decoded [FLVHeader] + */ fun decodeFlvHeader(): FLVHeader { val peek = source.peek() val isHeader = try { @@ -115,5 +121,38 @@ class FLVDemuxer(private val source: Source, private val amfVersion: AmfVersion throw IllegalStateException("Not a FLV header") } } + + /** + * Closes the demuxer and releases any resources. + */ + fun close() { + source.close() + } +} + +/** + * Decodes all the FLV tags from the source. + * + * @param block the block to execute for each FLV tag + */ +suspend fun FLVDemuxer.decodeAll(block: suspend (FLVTag) -> Unit) { + coroutineScope { + while (!isEmpty) { + block(decode()) + } + } +} + +/** + * Decodes all the raw FLV tags from the source. + * + * @param block the block to execute for each raw FLV tag + */ +suspend fun FLVDemuxer.decodeAllRaw(block: suspend (RawFLVTag) -> Unit) { + coroutineScope { + while (!isEmpty) { + block(decodeRaw()) + } + } } diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVMuxer.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVMuxer.kt index 24978fa..ead8caf 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVMuxer.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/FLVMuxer.kt @@ -19,9 +19,9 @@ import io.github.thibaultbee.krtmp.amf.AmfVersion import io.github.thibaultbee.krtmp.flv.tags.FLVData import io.github.thibaultbee.krtmp.flv.tags.FLVTag import io.github.thibaultbee.krtmp.flv.tags.RawFLVTag -import io.github.thibaultbee.krtmp.flv.tags.aacAudioData -import io.github.thibaultbee.krtmp.flv.tags.avcHeaderLegacyVideoData -import io.github.thibaultbee.krtmp.flv.tags.avcLegacyVideoData +import io.github.thibaultbee.krtmp.flv.tags.audio.aacAudioData +import io.github.thibaultbee.krtmp.flv.tags.video.avcHeaderVideoData +import io.github.thibaultbee.krtmp.flv.tags.video.avcVideoData import io.github.thibaultbee.krtmp.flv.util.FLVHeader import kotlinx.io.Sink import kotlinx.io.buffered @@ -48,9 +48,9 @@ fun FLVMuxer( * * Usage: * ``` - * val muxer = FlvMuxer(sink, AmfVersion.AMF0) // or FlvMuxer(path, AmfVersion.AMF0) to write to a file + * val muxer = FLVMuxer(sink, AmfVersion.AMF0) // or FlvMuxer(path, AmfVersion.AMF0) to write to a file * // Encode FLV header if needed - * muxer.encodeFlvHeader(hasAudio = true, hasVideo = true) + * muxer.encodeFLVHeader(hasAudio = true, hasVideo = true) * // Encode onMetadata * muxer.encode(0, OnMetadata(...)) * // Encode video and audio frames @@ -111,7 +111,7 @@ class FLVMuxer(private val output: Sink, private val amfVersion: AmfVersion = Am * @param hasAudio true if the FLV file contains audio data, false otherwise * @param hasVideo true if the FLV file contains video data, false otherwise */ - fun encodeFlvHeader(hasAudio: Boolean, hasVideo: Boolean) { + fun encodeFLVHeader(hasAudio: Boolean, hasVideo: Boolean) { FLVHeader(hasAudio, hasVideo).encode(output) } @@ -121,13 +121,20 @@ class FLVMuxer(private val output: Sink, private val amfVersion: AmfVersion = Am fun flush() { output.flush() } + + /** + * Closes the [output] stream. + */ + fun close() { + output.close() + } } /** * Encodes a [FLVData] to the muxer. * * This method is a convenience method that wraps the [FLVTag] encoding. - * The project comes with [FLVData] factories such as [avcHeaderLegacyVideoData], [avcLegacyVideoData], [aacAudioData], etc. + * The project comes with [FLVData] factories such as [avcHeaderVideoData], [avcVideoData], [aacAudioData], etc. * * @param timestampMs the timestamp in milliseconds * @param data the [FLVData] to encode diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVAudioConfig.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVAudioConfig.kt index c8e50d8..1e737c3 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVAudioConfig.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVAudioConfig.kt @@ -37,6 +37,7 @@ enum class SoundFormat(val value: Byte) { NELLYMOSER(6), G711_ALAW(7), G711_MLAW(8), + EX_HEADER(9), AAC(10), SPEEX(11), MP3_8K(14), @@ -81,8 +82,9 @@ enum class AudioFourCC(val value: FourCC) { ); companion object { + @OptIn(ExperimentalStdlibApi::class) fun codeOf(value: Int) = entries.firstOrNull { it.value.code == value } - ?: throw IllegalArgumentException("Invalid audio FourCC code: $value") + ?: throw IllegalArgumentException("Invalid audio FourCC code: ${value.toHexString()}") } } diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVVideoConfig.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVVideoConfig.kt index 6875f31..2fe5ffe 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVVideoConfig.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/config/FLVVideoConfig.kt @@ -42,6 +42,8 @@ enum class CodecID(val value: Byte) { } } +interface Test + /** * FourCC object * @@ -86,7 +88,15 @@ enum class VideoMediaType(val codecID: CodecID?, val fourCCs: VideoFourCC?) { VP6_ALPHA(CodecID.VP6_ALPHA, null), SCREEN_2(CodecID.SCREEN_2, null), SORENSON_H263(CodecID.SORENSON_H263, null), + + /** + * AVC/H.264 + */ AVC(CodecID.AVC, VideoFourCC.AVC), + + /** + * HEVC/H.265 + */ HEVC(null, VideoFourCC.HEVC), VP8(null, VideoFourCC.VP8), VP9(null, VideoFourCC.VP9), diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayRawSource.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayBackedRawSource.kt similarity index 67% rename from flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayRawSource.kt rename to flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayBackedRawSource.kt index 8d80c13..6c64ce3 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayRawSource.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayBackedRawSource.kt @@ -19,13 +19,21 @@ import kotlinx.io.Buffer import kotlinx.io.RawSource import kotlin.math.min -class ByteArrayRawSource(private val array: ByteArray, startIndex: Long = 0) : RawSource { +/** + * A [RawSource] that reads from a [ByteArray]. + */ +class ByteArrayBackedRawSource(private val array: ByteArray, startIndex: Long = 0) : RawSource { private var position = startIndex private val size = array.size.toLong() val isExhausted: Boolean get() = position >= (size - 1) + init { + require(startIndex >= 0) { "Start index must be a positive value" } + require(size >= 0) { "size must be a positive value" } + } + private fun checkBounds(index: Long, byteCount: Long) { if (byteCount < 0) { throw IllegalArgumentException("byteCount < 0: $byteCount") @@ -36,16 +44,18 @@ class ByteArrayRawSource(private val array: ByteArray, startIndex: Long = 0) : R } override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { - checkBounds(position, byteCount) + if (byteCount < 0) { + throw IllegalArgumentException("byteCount < 0: $byteCount") + } if (isExhausted) { return -1 } - val clampedByteCount = min(size - position, position + byteCount) - sink.write(array, position.toInt(), (position + clampedByteCount).toInt()) - position += clampedByteCount.toInt() - return clampedByteCount + val bytesToRead = min(size - position, position + byteCount) + sink.write(array, position.toInt(), (position + bytesToRead).toInt()) + position += bytesToRead.toInt() + return bytesToRead } override fun close() { diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/EmptyRawSource.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/EmptyRawSource.kt deleted file mode 100644 index fc61da9..0000000 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/EmptyRawSource.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.github.thibaultbee.krtmp.flv.sources - -import kotlinx.io.Buffer -import kotlinx.io.RawSource - -class EmptyRawSource : RawSource { - override fun close() = Unit - - override fun readAtMostTo(sink: Buffer, byteCount: Long): Long { - return 0L - } -} \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/MultiRawSource.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/MultiRawSource.kt index 55d9903..18b1072 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/MultiRawSource.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/MultiRawSource.kt @@ -15,13 +15,14 @@ */ package io.github.thibaultbee.krtmp.flv.sources -import io.github.thibaultbee.krtmp.common.logger.Logger import kotlinx.io.Buffer import kotlinx.io.RawSource - -fun MultiRawSource(source: RawSource) = - MultiRawSource(listOf(source)) +/** + * Creates a [MultiRawSource] from a single [RawSource]. + */ +fun MultiRawSource(vararg sources: RawSource) = + MultiRawSource(sources.toList()) /** * A [RawSource] that reads from multiple [RawSource]s in sequence. diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSource.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSource.kt index a23cddd..7db3e85 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSource.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSource.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.github.thibaultbee.krtmp.flv.sources import io.github.thibaultbee.krtmp.flv.util.extensions.isAvcc @@ -17,14 +32,15 @@ private const val AVCC_HEADER_SIZE = 4 fun NaluRawSource(array: ByteArray): NaluRawSource { if (array.isAvcc) { return NaluRawSource( - ByteArrayRawSource(array, AVCC_HEADER_SIZE.toLong()), array.size - AVCC_HEADER_SIZE + ByteArrayBackedRawSource(array, AVCC_HEADER_SIZE.toLong()), + array.size - AVCC_HEADER_SIZE ) } // Convert AnnexB start code to AVCC format val startCodeSize = array.startCodeSize return NaluRawSource( - ByteArrayRawSource(array, startCodeSize.toLong()), array.size - startCodeSize + ByteArrayBackedRawSource(array, startCodeSize.toLong()), array.size - startCodeSize ) } @@ -89,6 +105,6 @@ class NaluRawSource internal constructor( val nalu: RawSource, val naluSize: Int ) : RawSourceWithSize( - MultiRawSource(listOf(Buffer().apply { writeInt(naluSize) }, nalu)), + MultiRawSource(Buffer().apply { writeInt(naluSize) }, nalu), naluSize + AVCC_HEADER_SIZE.toLong() ) diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/RawSourceWithSize.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/RawSourceWithSize.kt index 1e90193..4e92057 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/RawSourceWithSize.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/sources/RawSourceWithSize.kt @@ -33,7 +33,7 @@ fun RawSourceWithSize(buffer: Buffer) = * @param array the [ByteArray] to wrap */ fun RawSourceWithSize(array: ByteArray) = - RawSourceWithSize(ByteArrayRawSource(array), array.size.toLong()) + RawSourceWithSize(ByteArrayBackedRawSource(array), array.size.toLong()) fun RawSourceWithSize(source: RawSource, byteCount: Int) = RawSourceWithSize(source, byteCount.toLong()) @@ -44,7 +44,7 @@ fun RawSourceWithSize(source: RawSource, byteCount: Int) = * @param source the [RawSource] to wrap. * @param byteCount the number of bytes to read in the [source] */ -open class RawSourceWithSize(internal val source: RawSource, internal val byteCount: Long) : +open class RawSourceWithSize(private val source: RawSource, val byteCount: Long) : RawSource { private var bytesRead = 0L diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioData.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioData.kt deleted file mode 100644 index 9991b42..0000000 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioData.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (C) 2022 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.flv.tags - -import io.github.thibaultbee.krtmp.amf.AmfVersion -import io.github.thibaultbee.krtmp.flv.config.SoundFormat -import io.github.thibaultbee.krtmp.flv.config.SoundRate -import io.github.thibaultbee.krtmp.flv.config.SoundSize -import io.github.thibaultbee.krtmp.flv.config.SoundType -import kotlinx.io.Sink -import kotlinx.io.Source - -class AudioData( - val soundFormat: SoundFormat, - val soundRate: SoundRate, - val soundSize: SoundSize, - val soundType: SoundType, - val body: DefaultAudioTagBody, - val aacPacketType: AACPacketType? = null, -) : - FLVData { - private val size = body.size + if (soundFormat == SoundFormat.AAC) 2 else 1 - - override fun getSize(amfVersion: AmfVersion) = size - - override fun encode(output: Sink, amfVersion: AmfVersion, isEncrypted: Boolean) { - output.writeByte( - ((soundFormat.value.toInt() shl 4) or - (soundRate.value.toInt() shl 2) or - (soundSize.value.toInt() shl 1) or - (soundType.value).toInt()).toByte() - ) - if (soundFormat == SoundFormat.AAC) { - output.writeByte(aacPacketType!!.value) - } - body.encode(output) - } - - override fun toString(): String { - return "AudioData(soundFormat=$soundFormat, soundRate=$soundRate, soundSize=$soundSize, soundType=$soundType, body=$body)" - } - - companion object { - fun decode(source: Source, sourceSize: Int, isEncrypted: Boolean): AudioData { - val byte = source.readByte() - val soundFormat = SoundFormat.entryOf(((byte.toInt() and 0xF0) shr 4).toByte()) - val soundRate = SoundRate.entryOf(((byte.toInt() and 0x0C) shr 2).toByte()) - val soundSize = SoundSize.entryOf(((byte.toInt() and 0x02) shr 1).toByte()) - val soundType = SoundType.entryOf((byte.toInt() and 0x01).toByte()) - return if (soundFormat == SoundFormat.AAC) { - val aacPacketType = AACPacketType.entryOf(source.readByte()) - val remainingSize = sourceSize - 2 - require(!isEncrypted) { "Encrypted audio is not supported." } - val body = DefaultAudioTagBody.decode(source, remainingSize) - AudioData(soundFormat, soundRate, soundSize, soundType, body, aacPacketType) - } else { - val remainingSize = sourceSize - 1 - require(!isEncrypted) { "Encrypted audio is not supported." } - val body = DefaultAudioTagBody.decode(source, remainingSize) - AudioData(soundFormat, soundRate, soundSize, soundType, body) - } - } - } -} - -enum class AACPacketType(val value: Byte) { - SEQUENCE_HEADER(0), - RAW(1); - - companion object { - fun entryOf(value: Byte) = entries.first { it.value == value } - } -} \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioTagBody.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioTagBody.kt deleted file mode 100644 index 0ab2504..0000000 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioTagBody.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.thibaultbee.krtmp.flv.tags - -import kotlinx.io.RawSource -import kotlinx.io.Sink -import kotlinx.io.Source - -/** - * Interface for audio tag body. - */ -interface IAudioTagBody { - val size: Int - fun encode(output: Sink) -} - -class DefaultAudioTagBody( - val data: RawSource, - val dataSize: Int -) : IAudioTagBody { - override val size = dataSize - - override fun encode(output: Sink) { - output.write(data, dataSize.toLong()) - } - - companion object { - fun decode(source: Source, sourceSize: Int): DefaultAudioTagBody { - return DefaultAudioTagBody(source, sourceSize) - } - } -} \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVAudioDatas.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVAudioDatas.kt deleted file mode 100644 index bef2fb1..0000000 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVAudioDatas.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2024 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.flv.tags - -import io.github.thibaultbee.krtmp.flv.config.SoundFormat -import io.github.thibaultbee.krtmp.flv.config.SoundRate -import io.github.thibaultbee.krtmp.flv.config.SoundSize -import io.github.thibaultbee.krtmp.flv.config.SoundType -import io.github.thibaultbee.krtmp.flv.util.av.AudioSpecificConfig -import io.github.thibaultbee.krtmp.flv.util.av.aac.AAC -import io.github.thibaultbee.krtmp.flv.util.av.aac.ADTS -import io.github.thibaultbee.krtmp.flv.util.readBuffer -import kotlinx.io.RawSource - -/** - * Creates a legacy AAC [AudioData] from the [AAC.ADTS]. - * - * @param adts the [ADTS] header - * @return the [AudioData] with the [ADTS] header - */ -fun aacHeaderAudioData(adts: ADTS): AudioData { - val audioSpecificConfig = AudioSpecificConfig(adts).readBuffer() - - return AudioData( - soundFormat = SoundFormat.AAC, - soundRate = SoundRate.fromSampleRate(adts.sampleRate), - soundSize = SoundSize.S_16BITS, - soundType = SoundType.fromNumOfChannels(adts.channelConfiguration.numOfChannel), - aacPacketType = AACPacketType.SEQUENCE_HEADER, - body = DefaultAudioTagBody( - data = audioSpecificConfig, - dataSize = audioSpecificConfig.size.toInt() - ) - ) -} - -/** - * Creates a legacy AAC audio frame from a [RawSource] and its size. - * - * @param soundRate the sound rate - * @param soundSize the sound size - * @param soundType the sound type - * @param aacPacketType the AAC packet type - * @param data the coded AAC [RawSource] - * @param dataSize the size of the coded AAC [RawSource] - * @return the [AudioData] with the AAC frame - */ -fun aacAudioData( - soundRate: SoundRate, - soundSize: SoundSize, - soundType: SoundType, - aacPacketType: AACPacketType, - data: RawSource, - dataSize: Int -) = AudioData( - soundFormat = SoundFormat.AAC, - soundRate = soundRate, - soundSize = soundSize, - soundType = soundType, - aacPacketType = aacPacketType, - body = DefaultAudioTagBody( - data = data, dataSize = dataSize - ) -) \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVData.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVData.kt index c046b05..3cedb85 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVData.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVData.kt @@ -17,14 +17,14 @@ package io.github.thibaultbee.krtmp.flv.tags import io.github.thibaultbee.krtmp.amf.AmfVersion import kotlinx.io.Buffer +import kotlinx.io.RawSource import kotlinx.io.Sink import kotlinx.io.readByteArray - /** * Interface representing a frame data in FLV format. */ -sealed interface FLVData { +interface FLVData { /** * Gets the size of the data in bytes. * @@ -41,6 +41,16 @@ sealed interface FLVData { * @param isEncrypted Indicates whether the data is encrypted or not. */ fun encode(output: Sink, amfVersion: AmfVersion, isEncrypted: Boolean) + + /** + * Reads the raw source of the data, including its size. + * + * A special API that avoid copying large data. + * + * @param amfVersion The AMF version to use for encoding. Only for [ScriptDataObject]. + * @param isEncrypted Indicates whether the data is encrypted or not. + */ + fun readRawSource(amfVersion: AmfVersion, isEncrypted: Boolean): RawSource } /** diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVTag.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVTag.kt index 4aaec22..63fa19a 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVTag.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVTag.kt @@ -19,6 +19,9 @@ import io.github.thibaultbee.krtmp.amf.AmfVersion import io.github.thibaultbee.krtmp.amf.internal.utils.readInt24 import io.github.thibaultbee.krtmp.amf.internal.utils.writeInt24 import io.github.thibaultbee.krtmp.flv.tags.FLVTag.Type +import io.github.thibaultbee.krtmp.flv.tags.audio.AudioData +import io.github.thibaultbee.krtmp.flv.tags.script.ScriptDataObject +import io.github.thibaultbee.krtmp.flv.tags.video.VideoData import io.github.thibaultbee.krtmp.flv.util.extensions.readSource import io.github.thibaultbee.krtmp.flv.util.extensions.shl import kotlinx.io.Buffer @@ -33,7 +36,7 @@ import kotlinx.io.readByteArray * @property data The data contained in the tag, which can be audio, video, or script data. * @property streamId The stream ID of the tag, default is 0. */ -class FLVTag( +data class FLVTag( val timestampMs: Int, val data: FLVData, val streamId: Int = 0, @@ -42,6 +45,7 @@ class FLVTag( is AudioData -> Type.AUDIO is VideoData -> Type.VIDEO is ScriptDataObject -> Type.SCRIPT + else -> throw IllegalArgumentException("Unknown FLV data type: ${data::class.simpleName}") } /** @@ -128,7 +132,7 @@ fun FLVTag.readByteArray(amfVersion: AmfVersion = AmfVersion.AMF0): ByteArray { /** * Represents a raw FLV tag. Raw means that the body is not decoded. */ -class RawFLVTag internal constructor( +data class RawFLVTag internal constructor( val isEncrypted: Boolean, val type: Type, val bodySize: Int, @@ -136,9 +140,6 @@ class RawFLVTag internal constructor( val body: Source, val streamId: Int = 0 ) { - fun peek() = RawFLVTag(isEncrypted, type, bodySize, timestampMs, body.peek(), streamId) - - fun decode(amfVersion: AmfVersion = AmfVersion.AMF0): FLVTag { val data = when (type) { Type.AUDIO -> AudioData.decode(body, bodySize, isEncrypted) @@ -182,4 +183,13 @@ class RawFLVTag internal constructor( } } +/** + * Peeks the [body] of the [RawFLVTag] without decoding it. + * + * Use it when you want to read multiple times the same tag. + */ +fun RawFLVTag.peek() = copy(body = body.peek()) + + + diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/ModEx.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/ModEx.kt index ac05cc9..7973f2f 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/ModEx.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/ModEx.kt @@ -16,48 +16,136 @@ package io.github.thibaultbee.krtmp.flv.tags import io.github.thibaultbee.krtmp.flv.util.WithValue +import io.github.thibaultbee.krtmp.flv.util.extensions.readSource import io.github.thibaultbee.krtmp.flv.util.extensions.shl import io.github.thibaultbee.krtmp.flv.util.extensions.writeByte import io.github.thibaultbee.krtmp.flv.util.extensions.writeShort import kotlinx.io.Sink import kotlinx.io.Source -import kotlinx.io.readByteArray import kotlin.experimental.and -open class ModEx>( - val type: T, - val size: Int, - val encode: (Sink) -> Unit +open class ModEx, V>(val type: T, val value: V) + +internal interface ModExCodec, V> { + val type: T + val size: Int + fun encode(output: Sink, value: V) + fun decode(source: Source): ModEx +} + +internal class ModExEncoder>( + private val codec: Set>, + private val modExPacketType: Byte ) { - fun encode(output: Sink, framePacketType: VideoPacketType) { - val writtenSize = size - 1 + private fun codecOf(type: Byte): ModExCodec { + return codec.firstOrNull { it.type.value == type } + ?: throw IllegalArgumentException("No codec found for type: $type") + } + + @Suppress("UNCHECKED_CAST") + private fun codecOf(type: T): ModExCodec { + return (codec.firstOrNull { it.type == type } + ?: throw IllegalArgumentException("No codec found for type: $type")) as ModExCodec + } + + /** + * Gets the size of a set of [ModEx] objects. + */ + internal fun getSize( + modExs: Set>, + ) = modExs.sumOf { getSize(it) } + + private fun getSize( + modEx: ModEx, + ): Int { + val modExSize = codecOf(modEx.type).size + return modExSize + 1 + if (modExSize >= 255) { + 3 + } else { + 1 + } + } + + /** + * Encodes a set of ModEx objects. + */ + internal fun encode(output: Sink, modExs: Set>, nextPacketType: Byte) { + modExs.forEachIndexed { index, modEx -> + encodeOne( + output, modEx, if (index == modExs.size - 1) { + nextPacketType + } else { + modExPacketType + } + ) + } + } + + /** + * Encodes a single ModEx object. + */ + private fun encodeOne( + output: Sink, modEx: ModEx, nextPacketType: Byte + ) { + val codec = codecOf(modEx.type) + val writtenSize = codec.size - 1 if (writtenSize >= 255) { output.writeByte(0xFF.toByte()) output.writeShort(writtenSize) } else { output.writeByte((writtenSize).toByte()) } - encode(output) - output.writeByte((type.value shl 4) or framePacketType.value.toInt()) + codec.encode(output, modEx.value) + output.writeByte((modEx.type.value shl 4) or nextPacketType.toInt()) } - companion object { - fun > decode( - source: Source, - ): ModEx { - var modExDataSize = source.readByte() + 1 - if (modExDataSize == 0xFF) { - modExDataSize = source.readShort() + 1 - } - val modExData = source.readByteArray(modExDataSize) - val byte = source.readByte() - val videoPacketModExType = (byte and 0xF0.toByte()) shl 4 - val framePacketType = VideoPacketType.entryOf(byte and 0x0F.toByte()) - throw NotImplementedError("ModEx decoding not implemented yet") + /** + * A data class that holds a set of decoded ModEx data and the next packet type. + */ + data class ModExDatas>( + val modExs: Set>, + val nextPacketType: Byte + ) + + internal fun decode( + source: Source, + ): ModExDatas { + val modExs = mutableSetOf>() + var nextPacketType = modExPacketType + + while (nextPacketType == modExPacketType) { + val modEx = decodeOne(source) + modExs.add(modEx.modExs) + nextPacketType = modEx.nextPacketType } + + return ModExDatas(modExs, nextPacketType) } -} -abstract class ModExFactory, U>(val type: T) { - abstract fun create(value: U): ModEx + /** + * A data class that holds a single decoded ModEx data and the next packet type. + */ + data class ModExData, V>( + val modExs: ModEx, + val nextPacketType: Byte, + ) + + private fun decodeOne( + source: Source, + ): ModExData { + val modExDataSize = source.readByte() + 1 + if (modExDataSize == 0xFF) { + source.readShort() + } + val modExData = source.readSource(modExDataSize.toLong()) + + val byte = source.readByte() + val packetModExType = ((byte and 0xF0.toByte()) shl 4).toByte() + val codec = codecOf(packetModExType) + + return ModExData( + codec.decode(modExData), + (byte and 0x0F) + ) + } } diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/MultitrackType.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/MultitrackType.kt index bfcf71c..d135a79 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/MultitrackType.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/MultitrackType.kt @@ -1,3 +1,18 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.github.thibaultbee.krtmp.flv.tags /** diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoTagBody.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoTagBody.kt deleted file mode 100644 index ff083d6..0000000 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoTagBody.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright (C) 2025 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.flv.tags - -import io.github.thibaultbee.krtmp.amf.internal.utils.readInt24 -import io.github.thibaultbee.krtmp.amf.internal.utils.writeInt24 -import io.github.thibaultbee.krtmp.flv.config.VideoFourCC -import io.github.thibaultbee.krtmp.flv.tags.ExtendedVideoData.SingleVideoPacketDescriptor.Companion.decodeBody -import io.github.thibaultbee.krtmp.flv.util.extensions.readSource -import io.github.thibaultbee.krtmp.flv.util.extensions.writeByte -import kotlinx.io.RawSource -import kotlinx.io.Sink -import kotlinx.io.Source - -/** - * Interface for video tag body. - */ -interface VideoTagBody { - val size: Int - fun encode(output: Sink) -} - -interface SingleVideoTagBody : VideoTagBody - -/** - * Default video tag body. - */ -class RawVideoTagBody( - val data: RawSource, - val dataSize: Int -) : SingleVideoTagBody { - override val size = dataSize - - override fun encode(output: Sink) { - output.write(data, dataSize.toLong()) - } - - override fun toString(): String { - return "RawVideoTagBody(dataSize=$dataSize)" - } - - companion object { - fun decode(source: Source, sourceSize: Int): RawVideoTagBody { - return RawVideoTagBody(source.readSource(sourceSize.toLong()), sourceSize) - } - } -} - -internal class CommandLegacyVideoTagBody( - val command: VideoCommand -) : VideoTagBody { - override val size = 1 - - override fun encode(output: Sink) { - output.writeByte(command.value) - } - - companion object { - fun decode(source: Source, sourceSize: Int): CommandLegacyVideoTagBody { - require(sourceSize >= 1) { "Command video tag body must have at least 1 byte" } - val command = source.readByte() - return CommandLegacyVideoTagBody(VideoCommand.entryOf(command)) - } - } -} - -/** - * Empty video tag body. - */ -internal class EmptyVideoTagBody : SingleVideoTagBody { - override val size = 0 - override fun encode(output: Sink) = Unit // End of sequence does not have a body - - override fun toString(): String { - return "Empty" - } -} - - -/** - * AVC HEVC coded frame video tag body. - * - * Only to be used with extended AVC and HEVC codec when packet type is - * [VideoPacketType.CODED_FRAMES]. - * - * @param compositionTime 24 bits composition time - * @param data The raw source data of the video frame. - * @param dataSize The size of the data in bytes. - */ -class CompositionTimeExtendedVideoTagBody( - val compositionTime: Int, // 24 bits - val data: RawSource, - val dataSize: Int -) : SingleVideoTagBody { - override val size = 3 + dataSize - - override fun encode(output: Sink) { - output.writeInt24(compositionTime) - output.write(data, dataSize.toLong()) - } - - override fun toString(): String { - return "ExtendedWithCompositionTimeVideoTagBody(compositionTime=$compositionTime, dataSize=$dataSize)" - } - - companion object { - fun decode( - source: Source, sourceSize: Int - ): CompositionTimeExtendedVideoTagBody { - val compositionTime = source.readInt24() - val remainingSize = sourceSize - 3 - return CompositionTimeExtendedVideoTagBody( - compositionTime, - source.readSource(remainingSize.toLong()), - remainingSize - ) - } - } -} - - -interface MultitrackVideoTagBody : VideoTagBody -interface OneCodecMultitrackVideoTagBody : MultitrackVideoTagBody - -class OneTrackVideoTagBody( - val trackId: Byte, - val body: SingleVideoTagBody -) : OneCodecMultitrackVideoTagBody { - override val size = 1 + body.size - - override fun encode(output: Sink) { - output.writeByte(trackId) - body.encode(output) - } - - override fun toString(): String { - return "OneTrackVideoTagBody(trackId=$trackId, body=$body)" - } - - companion object { - fun decode( - packetType: VideoPacketType, fourCC: VideoFourCC, source: Source, sourceSize: Int - ): OneTrackVideoTagBody { - require(sourceSize >= 1) { "One track video tag body must have at least 1 byte" } - val trackId = source.readByte() - val body = decodeBody(packetType, fourCC, source, sourceSize - 1) - return OneTrackVideoTagBody(trackId, body) - } - } -} - -class ManyTrackOneCodecVideoTagBody internal constructor( - val tracks: Set -) : OneCodecMultitrackVideoTagBody { - override val size = 1 + tracks.sumOf { it.size } - - override fun encode(output: Sink) { - tracks.forEach { track -> - output.writeByte(track.trackId) - output.writeInt24(track.body.size) - track.body.encode(output) - } - } - - override fun toString(): String { - return "ManyTrackOneCodecVideoTagBody(tracks=$tracks)" - } - - companion object { - fun decode( - packetType: VideoPacketType, fourCC: VideoFourCC, source: Source, sourceSize: Int - ): ManyTrackOneCodecVideoTagBody { - val tracks = mutableSetOf() - var remainingSize = sourceSize - while (remainingSize > 0) { - val track = OneTrackVideoTagBody.decode(packetType, fourCC, source, remainingSize) - tracks.add(track) - remainingSize -= track.size - } - return ManyTrackOneCodecVideoTagBody(tracks) - } - } -} - -class ManyTrackManyCodecVideoTagBody( - val tracks: Set -) : MultitrackVideoTagBody { - override val size = 1 + tracks.sumOf { it.size } - - override fun encode(output: Sink) { - tracks.forEach { track -> - track.encode(output) - } - } - - override fun toString(): String { - return "ManyTrackManyCodecVideoTagBody(tracks=$tracks)" - } - - companion object { - fun decode( - packetType: VideoPacketType, source: Source, sourceSize: Int - ): ManyTrackManyCodecVideoTagBody { - val tracks = mutableSetOf() - var remainingSize = sourceSize - while (remainingSize > 0) { - val track = OneTrackMultiCodecVideoTagBody.decode(packetType, source, remainingSize) - tracks.add(track) - remainingSize -= track.size - } - return ManyTrackManyCodecVideoTagBody(tracks) - } - } - - data class OneTrackMultiCodecVideoTagBody( - val fourCC: VideoFourCC, - val trackId: Byte = 0, - val body: SingleVideoTagBody - ) { - val size = 1 + body.size - - fun encode(output: Sink) { - output.writeByte(fourCC.value.code) - output.writeByte(trackId) - output.writeInt24(body.size) - body.encode(output) - } - - companion object { - fun decode( - packetType: VideoPacketType, source: Source, sourceSize: Int - ): OneTrackMultiCodecVideoTagBody { - throw NotImplementedError("OneTrackMultiCodecVideoTagBody decoding not implemented yet") - } - } - } -} diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioData.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioData.kt new file mode 100644 index 0000000..816a5c2 --- /dev/null +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioData.kt @@ -0,0 +1,510 @@ +/* + * Copyright (C) 2022 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.flv.tags.audio + +import io.github.thibaultbee.krtmp.amf.AmfVersion +import io.github.thibaultbee.krtmp.amf.internal.utils.readInt24 +import io.github.thibaultbee.krtmp.amf.internal.utils.writeInt24 +import io.github.thibaultbee.krtmp.flv.config.AudioFourCC +import io.github.thibaultbee.krtmp.flv.config.SoundFormat +import io.github.thibaultbee.krtmp.flv.config.SoundRate +import io.github.thibaultbee.krtmp.flv.config.SoundSize +import io.github.thibaultbee.krtmp.flv.config.SoundType +import io.github.thibaultbee.krtmp.flv.sources.MultiRawSource +import io.github.thibaultbee.krtmp.flv.tags.FLVData +import io.github.thibaultbee.krtmp.flv.tags.ModEx +import io.github.thibaultbee.krtmp.flv.tags.ModExCodec +import io.github.thibaultbee.krtmp.flv.tags.ModExEncoder +import io.github.thibaultbee.krtmp.flv.tags.MultitrackType +import io.github.thibaultbee.krtmp.flv.util.SinkEncoder +import io.github.thibaultbee.krtmp.flv.util.WithValue +import io.github.thibaultbee.krtmp.flv.util.extensions.shl +import io.github.thibaultbee.krtmp.flv.util.extensions.shr +import io.github.thibaultbee.krtmp.flv.util.extensions.writeByte +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlin.experimental.and + +/** + * Represents audio data in legacy FLV format. + */ +class LegacyAudioData( + soundFormat: SoundFormat, + val soundRate: SoundRate, + val soundSize: SoundSize, + val soundType: SoundType, + body: RawAudioTagBody, + val aacPacketType: AACPacketType? = null, +) : AudioData( + soundFormat, + (soundRate.value.toInt() shl 2) or (soundSize.value.toInt() shl 1) or (soundType.value).toInt(), + body +) { + override val size = super.size + if (soundFormat == SoundFormat.AAC) 1 else 0 + + override fun encodeHeaderImpl(output: Sink) { + if (soundFormat == SoundFormat.AAC) { + output.writeByte(aacPacketType!!.value) + } + } + + override fun toString(): String { + return "LegacyAudioData(soundFormat=$soundFormat, soundRate=$soundRate, soundSize=$soundSize, soundType=$soundType, body=$body)" + } + + companion object { + fun decode(source: Source, sourceSize: Int, isEncrypted: Boolean): LegacyAudioData { + val byte = source.readByte() + val soundFormat = SoundFormat.entryOf(((byte.toInt() and 0xF0) shr 4).toByte()) + val soundRate = SoundRate.entryOf(((byte.toInt() and 0x0C) shr 2).toByte()) + val soundSize = SoundSize.entryOf(((byte.toInt() and 0x02) shr 1).toByte()) + val soundType = SoundType.entryOf((byte.toInt() and 0x01).toByte()) + return decode( + soundFormat, soundRate, soundSize, soundType, source, sourceSize - 1, isEncrypted + ) + } + + internal fun decode( + soundFormat: SoundFormat, + soundRate: SoundRate, + soundSize: SoundSize, + soundType: SoundType, + source: Source, + sourceSize: Int, + isEncrypted: Boolean + ): LegacyAudioData { + return if (soundFormat == SoundFormat.AAC) { + val aacPacketType = AACPacketType.entryOf(source.readByte()) + val remainingSize = sourceSize - 1 + require(!isEncrypted) { "Encrypted audio is not supported." } + val body = RawAudioTagBody.decode(source, remainingSize) + LegacyAudioData(soundFormat, soundRate, soundSize, soundType, body, aacPacketType) + } else { + require(!isEncrypted) { "Encrypted audio is not supported." } + val body = RawAudioTagBody.decode(source, sourceSize) + LegacyAudioData(soundFormat, soundRate, soundSize, soundType, body) + } + } + } +} + +/** + * Creates an [ExtendedAudioData] from the given [AudioPacketType], [AudioFourCC], and [SingleAudioTagBody]. + * + * @param packetType the [AudioPacketType] of the audio data + * @param fourCC the [AudioFourCC] of the audio data + * @param body the [SingleAudioTagBody] of the audio data + */ +fun ExtendedAudioData( + packetType: AudioPacketType, + fourCC: AudioFourCC, + body: SingleAudioTagBody +) = ExtendedAudioData( + packetDescriptor = ExtendedAudioData.SingleAudioDataDescriptor( + packetType = packetType, + fourCC = fourCC, + body = body + ) +) + +class ExtendedAudioData internal constructor( + val packetDescriptor: AudioDataDescriptor, + val modExs: Set> = emptySet() +) : AudioData( + SoundFormat.EX_HEADER, if (modExs.isEmpty()) { + packetDescriptor.packetType.value + } else { + AudioPacketType.MOD_EX.value + }.toInt(), packetDescriptor.body +) { + override val size = + super.size + packetDescriptor.size + AudioModExCodec.encoder.getSize(modExs) + val packetType = packetDescriptor.packetType + + override fun encodeHeaderImpl(output: Sink) { + AudioModExCodec.encoder.encode(output, modExs, packetType.value) + packetDescriptor.encode(output) + } + + override fun toString(): String { + return "ExtendedAudioData(packetType=$packetType, packetDescriptor=$packetDescriptor, modExs=$modExs)" + } + + companion object { + fun decode(source: Source, sourceSize: Int): ExtendedAudioData { + val byte = source.readByte() + val soundFormat = SoundFormat.entryOf(((byte.toInt() and 0xF0) shr 4).toByte()) + val packetType = AudioPacketType.entryOf((byte.toInt() and 0x0F).toByte()) + return decode(soundFormat, packetType, source, sourceSize - 1) + } + + fun decode( + soundFormat: SoundFormat, + packetType: AudioPacketType, + source: Source, + sourceSize: Int + ): ExtendedAudioData { + require(soundFormat == SoundFormat.EX_HEADER) { "Invalid sound format: $soundFormat. Only EX_HEADER is supported" } + var nextPacketType = packetType + val modExs = if (packetType == AudioPacketType.MOD_EX) { + val modExDatas = AudioModExCodec.encoder.decode(source) + nextPacketType = AudioPacketType.entryOf(modExDatas.nextPacketType) + modExDatas.modExs + } else { + emptySet() + } + + val remainingSize = sourceSize - AudioModExCodec.encoder.getSize(modExs) + val packetDescriptor = when (packetType) { + AudioPacketType.MULTITRACK -> MultitrackAudioDataDescriptor.decode( + source, + remainingSize + ) + + else -> SingleAudioDataDescriptor.decode(nextPacketType, source, remainingSize) + } + return ExtendedAudioData(packetDescriptor) + } + } + + interface OneAudioCodec { + val fourCC: AudioFourCC + } + + interface AudioDataDescriptor : SinkEncoder { + val packetType: AudioPacketType + val body: AudioTagBody + } + + class SingleAudioDataDescriptor internal constructor( + override val packetType: AudioPacketType, + val fourCC: AudioFourCC, + override val body: SingleAudioTagBody + ) : AudioDataDescriptor { + override val size = 4 + + init { + require(packetType != AudioPacketType.MULTITRACK) { + "Invalid packet type for single audio: $packetType. Use MultitrackAudioPacketDescriptor instead." + } + require(packetType != AudioPacketType.MOD_EX) { + "Invalid packet type for single audio: $packetType. MOD_EX is not a valid packet type." + } + } + + override fun encode(output: Sink) { + output.writeInt(fourCC.value.code) + } + + companion object { + fun decode( + packetType: AudioPacketType, + source: Source, + sourceSize: Int + ): SingleAudioDataDescriptor { + val fourCC = AudioFourCC.codeOf(source.readInt()) + val body = RawAudioTagBody.decode(source, sourceSize - 4) + return SingleAudioDataDescriptor( + packetType = packetType, + fourCC = fourCC, + body = body + ) + } + } + } + + /** + * The multitrack extended audio packet descriptor. + */ + sealed class MultitrackAudioDataDescriptor( + val multitrackType: MultitrackType, + val framePacketType: AudioPacketType + ) : + AudioDataDescriptor { + override val packetType = AudioPacketType.MULTITRACK + override val size = 1 + + init { + require(framePacketType != AudioPacketType.MULTITRACK) { + "Invalid packet type for multitrack: $framePacketType." + } + } + + abstract fun encodeImpl(output: Sink) + + override fun encode(output: Sink) { + output.writeByte((multitrackType.value shl 4) or framePacketType.value.toInt()) + encodeImpl(output) + } + + companion object { + fun decode( + source: Source, + sourceSize: Int + ): MultitrackAudioDataDescriptor { + val byte = source.readByte() + val multitrackType = + MultitrackType.entryOf(((byte and 0xF0.toByte()) shr 4).toByte()) + val framePacketType = AudioPacketType.entryOf(byte and 0x0F.toByte()) + val remainingSize = sourceSize - 1 + return when (multitrackType) { + MultitrackType.ONE_TRACK -> OneTrackAudioDataDescriptor.decode( + framePacketType, + source, + remainingSize + ) + + MultitrackType.MANY_TRACK -> ManyTrackAudioDataDescriptor.decode( + framePacketType, + source, + remainingSize + ) + + MultitrackType.MANY_TRACK_MANY_CODEC -> ManyTrackManyCodecAudioDataDescriptor.decode( + framePacketType, + source, + remainingSize + ) + } + } + } + + class OneTrackAudioDataDescriptor internal constructor( + framePacketType: AudioPacketType, + override val fourCC: AudioFourCC, + override val body: OneTrackAudioTagBody + ) : MultitrackAudioDataDescriptor(MultitrackType.ONE_TRACK, framePacketType), + OneAudioCodec { + override val size = super.size + 4 + + override fun encodeImpl(output: Sink) { + output.writeInt(fourCC.value.code) + } + + override fun toString(): String { + return "OneTrackAudioPacketDescriptor(packetType=$framePacketType, fourCC=$fourCC, body=$body)" + } + + companion object { + fun decode( + packetType: AudioPacketType, + source: Source, + sourceSize: Int + ): OneTrackAudioDataDescriptor { + val fourCC = AudioFourCC.codeOf(source.readInt()) + val remainingSize = sourceSize - 4 + val body = + OneTrackAudioTagBody.decode(source, remainingSize) + return OneTrackAudioDataDescriptor(packetType, fourCC, body) + } + } + } + + class ManyTrackAudioDataDescriptor internal constructor( + framePacketType: AudioPacketType, + override val fourCC: AudioFourCC, + override val body: ManyTrackOneCodecAudioTagBody + ) : MultitrackAudioDataDescriptor(MultitrackType.MANY_TRACK, framePacketType), + OneAudioCodec { + override val size = super.size + 4 + + override fun encodeImpl(output: Sink) { + output.writeInt(fourCC.value.code) + } + + override fun toString(): String { + return "ManyTrackAudioPacketDescriptor(packetType=$framePacketType, fourCC=$fourCC, body=$body)" + } + + companion object { + fun decode( + packetType: AudioPacketType, + source: Source, + sourceSize: Int + ): ManyTrackAudioDataDescriptor { + val fourCC = AudioFourCC.codeOf(source.readInt()) + val remainingSize = sourceSize - 4 + val body = + ManyTrackOneCodecAudioTagBody.decode( + source, + remainingSize + ) + return ManyTrackAudioDataDescriptor(packetType, fourCC, body) + } + } + } + + class ManyTrackManyCodecAudioDataDescriptor internal constructor( + framePacketType: AudioPacketType, + override val body: ManyTrackManyCodecAudioTagBody + ) : MultitrackAudioDataDescriptor( + MultitrackType.MANY_TRACK_MANY_CODEC, + framePacketType + ) { + override val size = super.size + + override fun encodeImpl(output: Sink) = Unit + + override fun toString(): String { + return "ManyTrackManyCodecAudioPacketDescriptor(framePacketType=$framePacketType, body=$body)" + } + + companion object { + fun decode( + packetType: AudioPacketType, + source: Source, + sourceSize: Int + ): ManyTrackManyCodecAudioDataDescriptor { + val body = ManyTrackManyCodecAudioTagBody.decode(source, sourceSize) + return ManyTrackManyCodecAudioDataDescriptor( + packetType, + body + ) + } + } + } + } +} + +sealed class AudioData( + val soundFormat: SoundFormat, val next4bitsValue: Int, val body: AudioTagBody +) : FLVData { + open val size = body.size + 1 + override fun getSize(amfVersion: AmfVersion) = size + + abstract fun encodeHeaderImpl( + output: Sink + ) + + private fun encodeHeader(output: Sink) { + output.writeByte( + ((soundFormat.value shl 4) or // SoundFormat + next4bitsValue).toByte() + ) + encodeHeaderImpl(output) + } + + private fun encodeBody(output: Sink) { + body.encode(output) + } + + override fun encode(output: Sink, amfVersion: AmfVersion, isEncrypted: Boolean) { + encodeHeader(output) + encodeBody(output) + } + + override fun readRawSource(amfVersion: AmfVersion, isEncrypted: Boolean): RawSource { + val header = Buffer().apply { encodeHeader(this) } + val body = body.readRawSource() + return MultiRawSource(header, body) + } + + companion object { + fun decode(source: Source, sourceSize: Int, isEncrypted: Boolean): AudioData { + val byte = source.readByte() + val soundFormat = SoundFormat.entryOf(((byte.toInt() and 0xF0) shr 4).toByte()) + val remainingSize = sourceSize - 1 + return if (soundFormat != SoundFormat.EX_HEADER) { + val soundRate = SoundRate.entryOf(((byte.toInt() and 0x0C) shr 2).toByte()) + val soundSize = SoundSize.entryOf(((byte.toInt() and 0x02) shr 1).toByte()) + val soundType = SoundType.entryOf((byte.toInt() and 0x01).toByte()) + LegacyAudioData.decode( + soundFormat, + soundRate, + soundSize, + soundType, + source, + remainingSize, + isEncrypted + ) + } else { + val packetType = AudioPacketType.entryOf((byte.toInt() and 0x0F).toByte()) + ExtendedAudioData.decode(soundFormat, packetType, source, remainingSize) + } + } + } +} + +enum class AACPacketType(val value: Byte) { + SEQUENCE_HEADER(0), RAW(1); + + companion object { + fun entryOf(value: Byte) = entries.first { it.value == value } + } +} + +enum class AudioPacketType(val value: Byte) { + SEQUENCE_START(0), CODED_FRAME(1), SEQUENCE_END(2), MULTICHANNEL_CONFIG(4), MULTITRACK(5), MOD_EX( + 7 + ); + + companion object { + fun entryOf(value: Byte) = + entries.firstOrNull { it.value == value } ?: throw IllegalArgumentException( + "Invalid AudioPacketType value: $value" + ) + } +} + +enum class AudioPacketModExType(override val value: Byte) : WithValue { + TIMESTAMP_OFFSET_NANO(0); + + companion object { + fun entryOf(value: Byte) = + entries.firstOrNull { it.value == value } ?: throw IllegalArgumentException( + "Invalid AudioPacketModExType value: $value" + ) + } +} + +sealed class AudioModEx(type: AudioPacketModExType, value: T) : + ModEx(type, value) { + override fun toString(): String { + return "AudioModEx(type=$type, value=$value)" + } + + class TimestampOffsetNano(value: Int) : AudioModEx( + AudioPacketModExType.TIMESTAMP_OFFSET_NANO, + value + ) +} + +internal sealed class AudioModExCodec : ModExCodec { + object timestampOffsetNano : AudioModExCodec() { + override val type = AudioPacketModExType.TIMESTAMP_OFFSET_NANO + override val size = 3 + override fun encode(output: Sink, value: Int) { + output.writeInt24(value) + } + + override fun decode(source: Source): AudioModEx.TimestampOffsetNano { + return AudioModEx.TimestampOffsetNano(source.readInt24()) + } + } + + companion object { + private val codecs = + setOf(timestampOffsetNano) + + internal fun codecOf(type: AudioPacketModExType) = + codecs.firstOrNull { it.type == type } + ?: throw IllegalArgumentException("Invalid AudioModExCodec type: $type") + + internal val encoder = ModExEncoder(codecs, AudioPacketType.MOD_EX.value) + } +} \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioDatas.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioDatas.kt new file mode 100644 index 0000000..03a92d5 --- /dev/null +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioDatas.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2024 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.flv.tags.audio + +import io.github.thibaultbee.krtmp.flv.config.AudioFourCC +import io.github.thibaultbee.krtmp.flv.config.SoundFormat +import io.github.thibaultbee.krtmp.flv.config.SoundRate +import io.github.thibaultbee.krtmp.flv.config.SoundSize +import io.github.thibaultbee.krtmp.flv.config.SoundType +import io.github.thibaultbee.krtmp.flv.tags.audio.ManyTrackManyCodecAudioTagBody.OneTrackMultiCodecAudioTagBody +import io.github.thibaultbee.krtmp.flv.util.av.AudioSpecificConfig +import io.github.thibaultbee.krtmp.flv.util.av.aac.AAC +import io.github.thibaultbee.krtmp.flv.util.av.aac.ADTS +import io.github.thibaultbee.krtmp.flv.util.readBuffer +import kotlinx.io.RawSource + +/** + * Factories to create [AudioData]. + */ + +/** + * Creates a legacy AAC [LegacyAudioData] from the [AAC.ADTS]. + * + * @param adts the [ADTS] header + * @return the [LegacyAudioData] with the [ADTS] header + */ +fun aacHeaderAudioData(adts: ADTS): LegacyAudioData { + val audioSpecificConfig = AudioSpecificConfig(adts).readBuffer() + + return LegacyAudioData( + soundFormat = SoundFormat.AAC, + soundRate = SoundRate.fromSampleRate(adts.sampleRate), + soundSize = SoundSize.S_16BITS, + soundType = SoundType.fromNumOfChannels(adts.channelConfiguration.numOfChannel), + aacPacketType = AACPacketType.SEQUENCE_HEADER, + body = RawAudioTagBody( + data = audioSpecificConfig, + dataSize = audioSpecificConfig.size.toInt() + ) + ) +} + +/** + * Creates a legacy AAC audio frame from a [RawSource] and its size. + * + * @param soundRate the sound rate + * @param soundSize the sound size + * @param soundType the sound type + * @param aacPacketType the AAC packet type + * @param data the coded AAC [RawSource] + * @param dataSize the size of the coded AAC [RawSource] + * @return the [LegacyAudioData] with the AAC frame + */ +fun aacAudioData( + soundRate: SoundRate, + soundSize: SoundSize, + soundType: SoundType, + aacPacketType: AACPacketType, + data: RawSource, + dataSize: Int +) = LegacyAudioData( + soundFormat = SoundFormat.AAC, + soundRate = soundRate, + soundSize = soundSize, + soundType = soundType, + aacPacketType = aacPacketType, + body = RawAudioTagBody( + data = data, dataSize = dataSize + ) +) + +/** + * Creates an [ExtendedAudioData] for multichannel config audio data. + * + * @param packetType the packet type + * @param fourCC the FourCCs + * @param channelCount the number of channels + */ +fun unspecifiedMultiChannelConfigExtendedAudioData( + packetType: AudioPacketType, + fourCC: AudioFourCC, + channelCount: Byte +) = ExtendedAudioData( + packetDescriptor = ExtendedAudioData.SingleAudioDataDescriptor( + packetType = packetType, + fourCC = fourCC, + body = MultichannelConfigAudioTagBody.UnspecifiedMultichannelConfigAudioTagBody( + channelCount = channelCount + ) + ) +) + +/** + * Creates a [MultitrackAudioTagBody] for one track audio data. + * + * @param fourCC the FourCCs + * @param framePacketType the frame packet type + * @param trackID the track ID + * @param body the coded [RawSource] + * @param bodySize the size of the coded [RawSource] + */ +fun oneTrackMultitrackExtendedAudioData( + fourCC: AudioFourCC, + framePacketType: AudioPacketType, + trackID: Byte, + body: RawSource, + bodySize: Int +) = ExtendedAudioData( + packetDescriptor = ExtendedAudioData.MultitrackAudioDataDescriptor.OneTrackAudioDataDescriptor( + fourCC = fourCC, + framePacketType = framePacketType, + body = OneTrackAudioTagBody( + trackId = trackID, body = RawAudioTagBody(data = body, dataSize = bodySize) + ) + ) +) + +/** + * Creates a [MultitrackAudioTagBody] for a one codec multitrack audio data (either one or many tracks). + * + * @param fourCC the FourCCs + * @param framePacketType the frame packet type + * @param tracks the set of [OneTrackAudioTagBody]. If there is only one track in the set it is considered as a one track audio data. + */ +fun oneCodecMultitrackExtendedAudioData( + fourCC: AudioFourCC, + framePacketType: AudioPacketType, + tracks: Set +): ExtendedAudioData { + val packetDescriptor = if (tracks.size == 1) { + ExtendedAudioData.MultitrackAudioDataDescriptor.OneTrackAudioDataDescriptor( + fourCC = fourCC, + framePacketType = framePacketType, + body = tracks.first() + ) + } else if (tracks.size > 1) { + ExtendedAudioData.MultitrackAudioDataDescriptor.ManyTrackAudioDataDescriptor( + fourCC = fourCC, + framePacketType = framePacketType, + body = ManyTrackOneCodecAudioTagBody(tracks) + ) + } else { + throw IllegalArgumentException("No track in the set") + } + + return ExtendedAudioData( + packetDescriptor = packetDescriptor + ) +} + +/** + * Creates a [MultitrackAudioTagBody] for a many codec and many track audio data. + * + * @param framePacketType the frame packet type + * @param tracks the set of [OneTrackMultiCodecAudioTagBody] + */ +fun manyCodecMultitrackExtendedAudioData( + framePacketType: AudioPacketType, + tracks: Set +) = ExtendedAudioData( + packetDescriptor = ExtendedAudioData.MultitrackAudioDataDescriptor.ManyTrackManyCodecAudioDataDescriptor( + framePacketType = framePacketType, + body = ManyTrackManyCodecAudioTagBody(tracks) + ) +) \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioTagBody.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioTagBody.kt new file mode 100644 index 0000000..21ebb71 --- /dev/null +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/AudioTagBody.kt @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.flv.tags.audio + +import io.github.thibaultbee.krtmp.amf.internal.utils.readInt24 +import io.github.thibaultbee.krtmp.amf.internal.utils.writeInt24 +import io.github.thibaultbee.krtmp.flv.config.AudioFourCC +import io.github.thibaultbee.krtmp.flv.sources.MultiRawSource +import io.github.thibaultbee.krtmp.flv.util.extensions.readSource +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.Sink +import kotlinx.io.Source + +/** + * Interface for audio tag body. + */ +interface AudioTagBody { + val size: Int + fun encode(output: Sink) + fun readRawSource(): RawSource +} + +interface SingleAudioTagBody : AudioTagBody + +class RawAudioTagBody( + val data: RawSource, + val dataSize: Int +) : SingleAudioTagBody { + override val size = dataSize + + override fun encode(output: Sink) { + output.write(data, dataSize.toLong()) + } + + override fun readRawSource() = data + + override fun toString(): String { + return "RawAudioTagBody(dataSize=$dataSize)" + } + + companion object { + fun decode(source: Source, sourceSize: Int): RawAudioTagBody { + return RawAudioTagBody(source.readSource(sourceSize.toLong()), sourceSize) + } + } +} + +sealed class MultichannelConfigAudioTagBody( + val channelOrder: AudioChannelOrder, + val channelCount: Byte, +) : SingleAudioTagBody { + override val size = 8 + + abstract fun encodeImpl(output: Sink) + + override fun encode(output: Sink) { + output.writeByte(channelOrder.value) + output.writeByte(channelCount) + } + + override fun readRawSource(): RawSource { + return Buffer().apply { + encode(this) + } + } + + override fun toString(): String { + return "MultichannelConfigAudioTagBody(channelOrder=$channelOrder, channelCount=$channelCount)" + } + + companion object { + fun decode( + source: Source, + sourceSize: Int + ): MultichannelConfigAudioTagBody { + require(sourceSize >= 2) { "Multichannel audio tag body must have at least 2 bytes" } + val channelOrder = AudioChannelOrder.entryOf(source.readByte()) + val channelCount = source.readByte() + val remainingSize = sourceSize - 2 + return when (channelOrder) { + AudioChannelOrder.NATIVE -> NativeMultichannelConfigAudioTagBody.decode( + channelCount, + source, + remainingSize + ) + + AudioChannelOrder.CUSTOM -> CustomMultichannelConfigAudioTagBody.decode( + channelCount, + source, + remainingSize + ) + + AudioChannelOrder.UNSPECIFIED -> UnspecifiedMultichannelConfigAudioTagBody( + channelCount + ) + } + } + } + + class UnspecifiedMultichannelConfigAudioTagBody( + channelCount: Byte, + ) : MultichannelConfigAudioTagBody( + channelOrder = AudioChannelOrder.UNSPECIFIED, + channelCount = channelCount, + ) { + override fun encodeImpl(output: Sink) = Unit + } + + class NativeMultichannelConfigAudioTagBody( + channelCount: Byte, + ) : MultichannelConfigAudioTagBody( + channelOrder = AudioChannelOrder.NATIVE, + channelCount = channelCount, + ) { + override fun encodeImpl(output: Sink) = TODO("Not yet implemented") + + companion object { + fun decode( + channelCount: Byte, + source: Source, + sourceSize: Int + ): NativeMultichannelConfigAudioTagBody { + require(sourceSize >= 4) { "Native multichannel audio tag body must have at least 4 bytes" } + source.skip(4) + return NativeMultichannelConfigAudioTagBody(channelCount) + } + } + } + + class CustomMultichannelConfigAudioTagBody( + channelCount: Byte, + ) : MultichannelConfigAudioTagBody( + channelOrder = AudioChannelOrder.CUSTOM, + channelCount = channelCount, + ) { + override fun encodeImpl(output: Sink) = TODO("Not yet implemented") + + companion object { + fun decode( + channelCount: Byte, + source: Source, + sourceSize: Int + ): CustomMultichannelConfigAudioTagBody { + require(sourceSize == channelCount.toInt()) { "Custom multichannel audio tag body must have at least $channelCount bytes" } + source.skip(channelCount.toLong()) + return CustomMultichannelConfigAudioTagBody(channelCount) + } + } + } + + + enum class AudioChannelOrder(val value: Byte) { + /** + * Only the channel count is specified + */ + UNSPECIFIED(0), + + /** + * The native channel order + */ + NATIVE(1), + + /** + * The channel order does not correspond to any predefined order and is stored as an explicit map. + */ + CUSTOM(2); + + companion object { + fun entryOf(value: Byte) = + entries.firstOrNull { it.value == value } ?: throw IllegalArgumentException( + "Invalid AudioChannelOrder value: $value" + ) + } + } +} + + +interface MultitrackAudioTagBody : AudioTagBody +interface OneCodecMultitrackAudioTagBody : MultitrackAudioTagBody + +/** + * One track audio tag body. + * + * @param trackId The track id of the audio. + * @param body The audio tag body. + */ +class OneTrackAudioTagBody( + val trackId: Byte, + val body: SingleAudioTagBody +) : OneCodecMultitrackAudioTagBody { + override val size = 1 + body.size + + private fun encodeHeader(output: Sink) { + output.writeByte(trackId) + } + + override fun encode(output: Sink) { + encodeHeader(output) + body.encode(output) + } + + override fun readRawSource(): RawSource { + return MultiRawSource(Buffer().apply { encodeHeader(this) }, body.readRawSource()) + } + + override fun toString(): String { + return "OneTrackAudioTagBody(trackId=$trackId, body=$body)" + } + + companion object { + fun decode( + source: Source, sourceSize: Int + ): OneTrackAudioTagBody { + require(sourceSize >= 1) { "One track audio tag body must have at least 1 byte" } + val trackId = source.readByte() + val body = RawAudioTagBody.decode(source, sourceSize - 1) + return OneTrackAudioTagBody(trackId, body) + } + } +} + +/** + * Many track audio tag body with one codec. + * + * @param tracks The set of tracks. + */ +class ManyTrackOneCodecAudioTagBody internal constructor( + val tracks: Set +) : OneCodecMultitrackAudioTagBody { + override val size = tracks.sumOf { it.size + 3 } // +3 for sizeOfAudioTrack + + override fun encode(output: Sink) { + tracks.forEach { track -> + output.writeByte(track.trackId) + output.writeInt24(track.body.size) + track.body.encode(output) + } + } + + override fun readRawSource(): RawSource { + val rawSources = mutableListOf() + tracks.forEach { track -> + rawSources.add(Buffer().apply { + writeByte(track.trackId) + writeInt24(track.body.size) + }) + rawSources.add(track.body.readRawSource()) + } + return MultiRawSource(rawSources) + } + + override fun toString(): String { + return "ManyTrackOneCodecAudioTagBody(tracks=$tracks)" + } + + companion object { + fun decode( + source: Source, sourceSize: Int + ): ManyTrackOneCodecAudioTagBody { + val tracks = mutableSetOf() + var remainingSize = sourceSize + while (remainingSize > 0) { + val trackId = source.readByte() + val sizeOfAudioTrack = source.readInt24() + val body = RawAudioTagBody.decode(source, sizeOfAudioTrack) + tracks.add( + OneTrackAudioTagBody( + trackId, + body + ) + ) + remainingSize -= sizeOfAudioTrack + } + return ManyTrackOneCodecAudioTagBody(tracks) + } + } +} + +/** + * Many track audio tag body with many codecs. + * + * @param tracks The set of tracks. + */ +class ManyTrackManyCodecAudioTagBody( + val tracks: Set +) : MultitrackAudioTagBody { + override val size = tracks.sumOf { it.size } + + override fun encode(output: Sink) { + tracks.forEach { track -> + track.encode(output) + } + } + + override fun readRawSource(): RawSource { + return MultiRawSource(tracks.map { it.readRawSource() }) + } + + override fun toString(): String { + return "ManyTrackManyCodecAudioTagBody(tracks=$tracks)" + } + + companion object { + fun decode( + source: Source, sourceSize: Int + ): ManyTrackManyCodecAudioTagBody { + val tracks = mutableSetOf() + var remainingSize = sourceSize + while (remainingSize > 0) { + val track = OneTrackMultiCodecAudioTagBody.decode(source, remainingSize) + tracks.add(track) + remainingSize -= track.size + } + return ManyTrackManyCodecAudioTagBody(tracks) + } + } + + data class OneTrackMultiCodecAudioTagBody( + val fourCC: AudioFourCC, + val trackId: Byte = 0, + val body: SingleAudioTagBody + ) { + val size = 8 + body.size + + private fun encodeHeader(output: Sink) { + output.writeInt(fourCC.value.code) + output.writeByte(trackId) + output.writeInt24(body.size) + } + + fun encode(output: Sink) { + encodeHeader(output) + body.encode(output) + } + + fun readRawSource(): RawSource { + return MultiRawSource( + Buffer().apply { + encodeHeader(this) + }, + body.readRawSource() + ) + } + + companion object { + fun decode( + source: Source, sourceSize: Int + ): OneTrackMultiCodecAudioTagBody { + val fourCC = AudioFourCC.codeOf(source.readInt()) + val trackId = source.readByte() + val sizeOfAudioTrack = source.readInt24() + val body = RawAudioTagBody.decode(source, sizeOfAudioTrack) + return OneTrackMultiCodecAudioTagBody(fourCC, trackId, body) + } + } + } +} diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/OnMetadata.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/OnMetadata.kt similarity index 92% rename from flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/OnMetadata.kt rename to flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/OnMetadata.kt index 2cd5eb3..f926ea4 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/OnMetadata.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/OnMetadata.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.tags.script import io.github.thibaultbee.krtmp.amf.elements.containers.AmfEcmaArray import io.github.thibaultbee.krtmp.amf.elements.containers.AmfObject @@ -22,7 +22,7 @@ import io.github.thibaultbee.krtmp.amf.elements.containers.amfObjectOf import io.github.thibaultbee.krtmp.flv.config.FLVAudioConfig import io.github.thibaultbee.krtmp.flv.config.FLVVideoConfig import io.github.thibaultbee.krtmp.flv.config.SoundType -import io.github.thibaultbee.krtmp.flv.tags.OnMetadata.Metadata +import io.github.thibaultbee.krtmp.flv.tags.script.OnMetadata.Metadata import io.github.thibaultbee.krtmp.flv.util.AmfUtil.amf import kotlinx.serialization.Serializable @@ -33,7 +33,7 @@ import kotlinx.serialization.Serializable * @return The onMetaData data */ fun OnMetadata(value: AmfEcmaArray) = - OnMetadata(Metadata.fromArray(value)) + OnMetadata(Metadata.decode(value)) /** * Creates a [OnMetadata] from multiple audio and video configurations @@ -91,8 +91,15 @@ class OnMetadata( ) } + override fun toString(): String { + return "Metadata(duration=$duration, audiocodecid=$audiocodecid, audiodatarate=$audiodatarate, " + + "audiosamplerate=$audiosamplerate, audiosamplesize=$audiosamplesize, stereo=$stereo, " + + "videocodecid=$videocodecid, videodatarate=$videodatarate, width=$width, height=$height, " + + "framerate=$framerate, audioTrackIdInfoMap=$audioTrackIdInfoMap, videoTrackIdInfoMap=$videoTrackIdInfoMap)" + } + companion object { - fun fromArray(array: AmfEcmaArray): Metadata { + fun decode(array: AmfEcmaArray): Metadata { return amf.decodeFromAmfElement(serializer(), amfObjectOf(array)) } diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/ScriptDataObject.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/ScriptDataObject.kt similarity index 86% rename from flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/ScriptDataObject.kt rename to flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/ScriptDataObject.kt index 313cf77..d5f88e7 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/ScriptDataObject.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/ScriptDataObject.kt @@ -13,13 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.tags.script import io.github.thibaultbee.krtmp.amf.AmfVersion +import io.github.thibaultbee.krtmp.amf.elements.containers.AmfEcmaArray import io.github.thibaultbee.krtmp.amf.elements.containers.amf0ContainerFrom import io.github.thibaultbee.krtmp.amf.elements.containers.amfContainerOf -import io.github.thibaultbee.krtmp.amf.elements.containers.AmfEcmaArray import io.github.thibaultbee.krtmp.amf.elements.primitives.AmfString +import io.github.thibaultbee.krtmp.flv.tags.FLVData +import kotlinx.io.Buffer +import kotlinx.io.RawSource import kotlinx.io.Sink import kotlinx.io.Source @@ -41,6 +44,12 @@ open class ScriptDataObject( override fun encode(output: Sink, amfVersion: AmfVersion, isEncrypted: Boolean) = container.write(amfVersion, output) + override fun readRawSource(amfVersion: AmfVersion, isEncrypted: Boolean): RawSource { + return Buffer().apply { + container.write(amfVersion, this) + } + } + companion object { fun decode( source: Source, diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoData.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoData.kt similarity index 68% rename from flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoData.kt rename to flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoData.kt index 0f0ab7b..e84288b 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoData.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoData.kt @@ -13,17 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.tags.video import io.github.thibaultbee.krtmp.amf.AmfVersion import io.github.thibaultbee.krtmp.amf.internal.utils.readInt24 import io.github.thibaultbee.krtmp.amf.internal.utils.writeInt24 import io.github.thibaultbee.krtmp.flv.config.CodecID import io.github.thibaultbee.krtmp.flv.config.VideoFourCC +import io.github.thibaultbee.krtmp.flv.sources.MultiRawSource +import io.github.thibaultbee.krtmp.flv.tags.FLVData +import io.github.thibaultbee.krtmp.flv.tags.ModEx +import io.github.thibaultbee.krtmp.flv.tags.ModExCodec +import io.github.thibaultbee.krtmp.flv.tags.ModExEncoder +import io.github.thibaultbee.krtmp.flv.tags.MultitrackType +import io.github.thibaultbee.krtmp.flv.util.SinkEncoder import io.github.thibaultbee.krtmp.flv.util.WithValue import io.github.thibaultbee.krtmp.flv.util.extensions.shl import io.github.thibaultbee.krtmp.flv.util.extensions.shr import io.github.thibaultbee.krtmp.flv.util.extensions.writeByte +import kotlinx.io.Buffer import kotlinx.io.RawSource import kotlinx.io.Sink import kotlinx.io.Source @@ -45,7 +53,10 @@ class LegacyVideoData internal constructor( val packetType: AVCPacketType? = null, val compositionTime: Int = 0, ) : VideoData(false, frameType, codecID.value.toInt(), body) { - override val size = super.size + if (codecID == CodecID.AVC) 4 else 0 + + override fun getSize(amfVersion: AmfVersion): Int { + return super.getSize(amfVersion) + if (codecID == CodecID.AVC) 4 else 0 + } override fun encodeHeaderImpl(output: Sink) { if (codecID == CodecID.AVC) { @@ -128,56 +139,51 @@ fun ExtendedVideoData( fourCC: VideoFourCC, body: SingleVideoTagBody ) = ExtendedVideoData( - packetDescriptor = ExtendedVideoData.SingleVideoPacketDescriptor( - frameType, - packetType, - fourCC, - body - ) + packetDescriptor = ExtendedVideoData.SingleVideoDataDescriptor( + frameType, packetType, fourCC, body + ), modExs = setOf(VideoModEx.TimestampOffsetNano(123)) ) /** * Representation of extended video data in enhanced FLV format (v1 and v2). * * @param packetDescriptor the packet descriptor - * @param videoModExs the set of video mod ex + * @param modExs the set of video mod ex */ class ExtendedVideoData internal constructor( - val packetDescriptor: VideoPacketDescriptor, - val videoModExs: Set> = emptySet() + val packetDescriptor: VideoDataDescriptor, + val modExs: Set> = emptySet() ) : VideoData( - true, packetDescriptor.frameType, if (videoModExs.isEmpty()) { + true, packetDescriptor.frameType, if (modExs.isEmpty()) { packetDescriptor.packetType.value } else { VideoPacketType.MOD_EX.value }.toInt(), packetDescriptor.body ) { - override val size = super.size + packetDescriptor.size val packetType = packetDescriptor.packetType + override fun getSize(amfVersion: AmfVersion): Int { + return super.getSize(amfVersion) + packetDescriptor.size + VideoModExCodec.encoder.getSize( + modExs + ) + } + override fun encodeHeaderImpl(output: Sink) { - videoModExs.forEachIndexed { index, modEx -> - val nextPacketType = if (index == videoModExs.size - 1) { - packetDescriptor.packetType - } else { - VideoPacketType.MOD_EX - } - modEx.encode(output, nextPacketType) - } if ((packetType != VideoPacketType.META_DATA) && (frameType == VideoFrameType.COMMAND)) { - require(packetDescriptor is CommandVideoPacketDescriptor) { + require(packetDescriptor is CommandVideoDataDescriptor) { "Invalid frame type for command: $frameType. Only CommandHeaderExtension is supported." } } else if (packetType == VideoPacketType.MULTITRACK) { - require(packetDescriptor is MultitrackVideoPacketDescriptor) { + require(packetDescriptor is MultitrackVideoDataDescriptor) { "Invalid frame type for multitrack: $frameType. Only MultitrackHeaderExtension is supported." } } + VideoModExCodec.encoder.encode(output, modExs, packetType.value) packetDescriptor.encode(output) } override fun toString(): String { - return "ExtendedVideoData(frameType=$frameType, packetType=${packetType}, packetDescriptor=$packetDescriptor, videoModExs=$videoModExs)" + return "ExtendedVideoData(frameType=$frameType, packetType=${packetType}, packetDescriptor=$packetDescriptor, modExs=$modExs)" } companion object { @@ -199,58 +205,67 @@ class ExtendedVideoData internal constructor( } internal fun decode( - frameType: VideoFrameType, - packetType: VideoPacketType, - source: Source, - sourceSize: Int + frameType: VideoFrameType, packetType: VideoPacketType, source: Source, sourceSize: Int ): ExtendedVideoData { - var remainingSize = sourceSize - val videoModExs = mutableSetOf>() - while (packetType == VideoPacketType.MOD_EX) { - val videoModEx = ModEx.decode(source) - videoModExs.add(videoModEx) - remainingSize -= videoModEx.size + var nextPacketType = packetType + val modExs = if (packetType == VideoPacketType.MOD_EX) { + val modExDatas = VideoModExCodec.encoder.decode(source) + nextPacketType = VideoPacketType.entryOf(modExDatas.nextPacketType) + modExDatas.modExs + } else { + emptySet() } - val payload = - if ((packetType != VideoPacketType.META_DATA) && (frameType == VideoFrameType.COMMAND)) { - CommandVideoPacketDescriptor.decode(source) - } else if (packetType == VideoPacketType.MULTITRACK) { - MultitrackVideoPacketDescriptor.decode( - frameType, - source, - remainingSize + + val remainingSize = sourceSize - VideoModExCodec.encoder.getSize(modExs) + val packetDescriptor = + if ((nextPacketType != VideoPacketType.META_DATA) && (frameType == VideoFrameType.COMMAND)) { + CommandVideoDataDescriptor.decode(source) + } else if (nextPacketType == VideoPacketType.MULTITRACK) { + MultitrackVideoDataDescriptor.decode( + frameType, source, remainingSize ) } else { - SingleVideoPacketDescriptor.decode(frameType, packetType, source, remainingSize) + SingleVideoDataDescriptor.decode( + frameType, nextPacketType, source, remainingSize + ) } - return ExtendedVideoData(payload, videoModExs) + return ExtendedVideoData(packetDescriptor, modExs) } } - interface VideoPacketDescriptor : SinkEncoder { + interface OneVideoCodec { + val fourCC: VideoFourCC + } + + /** + * Description of an extended video data. + */ + interface VideoDataDescriptor : SinkEncoder { val frameType: VideoFrameType val packetType: VideoPacketType val body: VideoTagBody } - class SingleVideoPacketDescriptor internal constructor( + class SingleVideoDataDescriptor internal constructor( override val frameType: VideoFrameType, override val packetType: VideoPacketType, - val fourCC: VideoFourCC, + override val fourCC: VideoFourCC, override val body: SingleVideoTagBody - ) : VideoPacketDescriptor { + ) : VideoDataDescriptor, OneVideoCodec { override val size = 4 init { require(packetType != VideoPacketType.MULTITRACK) { "Invalid packet type for single video: $packetType. Use MultitrackVideoPacketDescriptor instead." } - require(frameType != VideoFrameType.COMMAND) { - "Invalid frame type for single video: $frameType. Use CommandVideoPacketDescriptor instead." - } require(packetType != VideoPacketType.MOD_EX) { "Invalid packet type for single video: $packetType. MOD_EX is not a valid packet type." } + if (frameType == VideoFrameType.COMMAND) { + require(packetType == VideoPacketType.META_DATA) { + "Invalid frame type for single video: $frameType. Use CommandVideoPacketDescriptor instead." + } + } } override fun encode(output: Sink) { @@ -267,36 +282,19 @@ class ExtendedVideoData internal constructor( packetType: VideoPacketType, source: Source, sourceSize: Int - ): SingleVideoPacketDescriptor { + ): SingleVideoDataDescriptor { val fourCC = VideoFourCC.codeOf(source.readInt()) val remainingSize = sourceSize - 4 - val body = decodeBody(packetType, fourCC, source, remainingSize) - return SingleVideoPacketDescriptor(frameType, packetType, fourCC, body) - } - - internal fun decodeBody( - packetType: VideoPacketType, - fourCC: VideoFourCC, - source: Source, - sourceSize: Int - ): SingleVideoTagBody { - return if (sourceSize == 0) { - EmptyVideoTagBody() - } else if ((packetType == VideoPacketType.CODED_FRAMES) && ((fourCC == VideoFourCC.HEVC) || - (fourCC == VideoFourCC.AVC)) - ) { - CompositionTimeExtendedVideoTagBody.decode(source, sourceSize) - } else { - RawVideoTagBody.decode(source, sourceSize) - } + val body = SingleVideoTagBody.decode(packetType, fourCC, source, remainingSize) + return SingleVideoDataDescriptor(frameType, packetType, fourCC, body) } } } - class CommandVideoPacketDescriptor internal constructor( + class CommandVideoDataDescriptor internal constructor( override val packetType: VideoPacketType, val command: VideoCommand, - ) : VideoPacketDescriptor { + ) : VideoDataDescriptor { override val frameType = VideoFrameType.COMMAND override val body = EmptyVideoTagBody() as VideoTagBody @@ -311,9 +309,9 @@ class ExtendedVideoData internal constructor( } companion object { - fun decode(source: Source): CommandVideoPacketDescriptor { + fun decode(source: Source): CommandVideoDataDescriptor { val command = VideoCommand.entryOf(source.readByte()) - return CommandVideoPacketDescriptor(VideoPacketType.MOD_EX, command) + return CommandVideoDataDescriptor(VideoPacketType.MOD_EX, command) } } } @@ -321,12 +319,11 @@ class ExtendedVideoData internal constructor( /** * The multitrack extended video packet descriptor. */ - sealed class MultitrackVideoPacketDescriptor( + sealed class MultitrackVideoDataDescriptor( override val frameType: VideoFrameType, val multitrackType: MultitrackType, val framePacketType: VideoPacketType - ) : - VideoPacketDescriptor { + ) : VideoDataDescriptor { override val packetType = VideoPacketType.MULTITRACK override val size = 1 @@ -343,53 +340,38 @@ class ExtendedVideoData internal constructor( encodeImpl(output) } - private interface OneCodec : SinkEncoder { - val fourCC: VideoFourCC - } - companion object { fun decode( - frameType: VideoFrameType, - source: Source, - sourceSize: Int - ): MultitrackVideoPacketDescriptor { + frameType: VideoFrameType, source: Source, sourceSize: Int + ): MultitrackVideoDataDescriptor { val byte = source.readByte() val multitrackType = MultitrackType.entryOf(((byte and 0xF0.toByte()) shr 4).toByte()) val framePacketType = VideoPacketType.entryOf(byte and 0x0F.toByte()) val remainingSize = sourceSize - 1 return when (multitrackType) { - MultitrackType.ONE_TRACK -> OneTrackVideoPacketDescriptor.decode( - frameType, - framePacketType, - source, - remainingSize + MultitrackType.ONE_TRACK -> OneTrackVideoDataDescriptor.decode( + frameType, framePacketType, source, remainingSize ) - MultitrackType.MANY_TRACK -> ManyTrackVideoPacketDescriptor.decode( - frameType, - framePacketType, - source, - remainingSize + MultitrackType.MANY_TRACK -> ManyTrackVideoDataDescriptor.decode( + frameType, framePacketType, source, remainingSize ) - MultitrackType.MANY_TRACK_MANY_CODEC -> ManyTrackManyCodecVideoPacketDescriptor.decode( - frameType, - framePacketType, - source, - remainingSize + MultitrackType.MANY_TRACK_MANY_CODEC -> ManyTrackManyCodecVideoDataDescriptor.decode( + frameType, framePacketType, source, remainingSize ) } } } - class OneTrackVideoPacketDescriptor internal constructor( + class OneTrackVideoDataDescriptor internal constructor( override val frameType: VideoFrameType, - override val fourCC: VideoFourCC, framePacketType: VideoPacketType, + override val fourCC: VideoFourCC, override val body: OneTrackVideoTagBody - ) : MultitrackVideoPacketDescriptor(frameType, MultitrackType.ONE_TRACK, framePacketType), - OneCodec { + ) : MultitrackVideoDataDescriptor(frameType, MultitrackType.ONE_TRACK, framePacketType), + OneVideoCodec { override val size = super.size + 4 override fun encodeImpl(output: Sink) { @@ -397,7 +379,7 @@ class ExtendedVideoData internal constructor( } override fun toString(): String { - return "OneTrackVideoPacketDescriptor(frameType=$frameType, fourCC=$fourCC, body=$body)" + return "OneTrackVideoPacketDescriptor(frameType=$frameType, packetType=$framePacketType fourCC=$fourCC, body=$body)" } companion object { @@ -406,23 +388,23 @@ class ExtendedVideoData internal constructor( packetType: VideoPacketType, source: Source, sourceSize: Int - ): OneTrackVideoPacketDescriptor { + ): OneTrackVideoDataDescriptor { val fourCC = VideoFourCC.codeOf(source.readInt()) val remainingSize = sourceSize - 4 val body = OneTrackVideoTagBody.decode(packetType, fourCC, source, remainingSize) - return OneTrackVideoPacketDescriptor(frameType, fourCC, packetType, body) + return OneTrackVideoDataDescriptor(frameType, packetType, fourCC, body) } } } - class ManyTrackVideoPacketDescriptor internal constructor( + class ManyTrackVideoDataDescriptor internal constructor( override val frameType: VideoFrameType, - override val fourCC: VideoFourCC, framePacketType: VideoPacketType, + override val fourCC: VideoFourCC, override val body: ManyTrackOneCodecVideoTagBody - ) : MultitrackVideoPacketDescriptor(frameType, MultitrackType.MANY_TRACK, framePacketType), - OneCodec { + ) : MultitrackVideoDataDescriptor(frameType, MultitrackType.MANY_TRACK, framePacketType), + OneVideoCodec { override val size = super.size + 4 override fun encodeImpl(output: Sink) { @@ -430,7 +412,7 @@ class ExtendedVideoData internal constructor( } override fun toString(): String { - return "ManyTrackVideoPacketDescriptor(frameType=$frameType, fourCC=$fourCC, body=$body)" + return "ManyTrackVideoPacketDescriptor(frameType=$frameType, packetType=$framePacketType, fourCC=$fourCC, body=$body)" } companion object { @@ -439,36 +421,30 @@ class ExtendedVideoData internal constructor( packetType: VideoPacketType, source: Source, sourceSize: Int - ): ManyTrackVideoPacketDescriptor { + ): ManyTrackVideoDataDescriptor { val fourCC = VideoFourCC.codeOf(source.readInt()) val remainingSize = sourceSize - 4 - val body = - ManyTrackOneCodecVideoTagBody.decode( - packetType, - fourCC, - source, - remainingSize - ) - return ManyTrackVideoPacketDescriptor(frameType, fourCC, packetType, body) + val body = ManyTrackOneCodecVideoTagBody.decode( + packetType, fourCC, source, remainingSize + ) + return ManyTrackVideoDataDescriptor(frameType, packetType, fourCC, body) } } } - class ManyTrackManyCodecVideoPacketDescriptor internal constructor( + class ManyTrackManyCodecVideoDataDescriptor internal constructor( override val frameType: VideoFrameType, framePacketType: VideoPacketType, override val body: ManyTrackManyCodecVideoTagBody - ) : MultitrackVideoPacketDescriptor( - frameType, - MultitrackType.MANY_TRACK_MANY_CODEC, - framePacketType + ) : MultitrackVideoDataDescriptor( + frameType, MultitrackType.MANY_TRACK_MANY_CODEC, framePacketType ) { override val size = super.size override fun encodeImpl(output: Sink) = Unit override fun toString(): String { - return "ManyTrackManyCodecVideoPacketDescriptor(frameType=$frameType, body=$body)" + return "ManyTrackManyCodecVideoPacketDescriptor(frameType=$frameType, packetType=$framePacketType, body=$body)" } companion object { @@ -477,12 +453,10 @@ class ExtendedVideoData internal constructor( packetType: VideoPacketType, source: Source, sourceSize: Int - ): ManyTrackManyCodecVideoPacketDescriptor { + ): ManyTrackManyCodecVideoDataDescriptor { val body = ManyTrackManyCodecVideoTagBody.decode(packetType, source, sourceSize) - return ManyTrackManyCodecVideoPacketDescriptor( - frameType, - packetType, - body + return ManyTrackManyCodecVideoDataDescriptor( + frameType, packetType, body ) } } @@ -496,25 +470,34 @@ sealed class VideoData( val next4bitsValue: Int, val body: VideoTagBody ) : FLVData { - open val size = body.size + 1 - override fun getSize(amfVersion: AmfVersion) = size + override fun getSize(amfVersion: AmfVersion) = 1 + body.getSize(amfVersion) abstract fun encodeHeaderImpl( output: Sink ) - protected fun encodeBody(output: Sink) { - body.encode(output) - } - - override fun encode(output: Sink, amfVersion: AmfVersion, isEncrypted: Boolean) { + private fun encodeHeader(output: Sink) { output.writeByte( ((isExtended shl 7) or // IsExHeader (frameType.value shl 4) or // Frame Type - next4bitsValue).toByte() // PacketType + next4bitsValue).toByte() ) encodeHeaderImpl(output) - encodeBody(output) + } + + private fun encodeBody(output: Sink, amfVersion: AmfVersion) { + body.encode(output, amfVersion) + } + + override fun encode(output: Sink, amfVersion: AmfVersion, isEncrypted: Boolean) { + encodeHeader(output) + encodeBody(output, amfVersion) + } + + override fun readRawSource(amfVersion: AmfVersion, isEncrypted: Boolean): RawSource { + val header = Buffer().apply { encodeHeader(this) } + val body = body.readRawSource(amfVersion) + return MultiRawSource(header, body) } companion object { @@ -525,19 +508,12 @@ sealed class VideoData( return if (isExHeader) { val packetType = VideoPacketType.entryOf(firstByte and 0x0F) ExtendedVideoData.decode( - frameType, - packetType, - source, - sourceSize - 1 + frameType, packetType, source, sourceSize - 1 ) } else { val codecID = CodecID.entryOf(firstByte and 0x0F) LegacyVideoData.decode( - frameType, - codecID, - source, - sourceSize - 1, - isEncrypted + frameType, codecID, source, sourceSize - 1, isEncrypted ) } } @@ -595,19 +571,17 @@ enum class VideoCommand(val value: Byte) { * @see AVCPacketType */ enum class VideoPacketType( - val value: Byte, val avcPacketType: AVCPacketType? = null -) { + override val value: Byte, val avcPacketType: AVCPacketType? = null +) : WithValue { SEQUENCE_START(0, AVCPacketType.SEQUENCE_HEADER), // Sequence Start - CODED_FRAMES(1, AVCPacketType.NALU), - SEQUENCE_END( + CODED_FRAMES(1, AVCPacketType.NALU), SEQUENCE_END( 2, AVCPacketType.END_OF_SEQUENCE ), /** * Composition time is implicitly set to 0. */ - CODED_FRAMES_X(3, null), - META_DATA( + CODED_FRAMES_X(3, null), META_DATA( 4, null ), @@ -651,16 +625,36 @@ enum class VideoPacketModExType(override val value: Byte) : WithValue { } } -abstract class VideoModExFactory(type: VideoPacketModExType) : - ModExFactory(type) +sealed class VideoModEx(type: VideoPacketModExType, value: T) : + ModEx(type, value) { + override fun toString(): String { + return "VideoModEx(type=$type, value=$value)" + } + + class TimestampOffsetNano(value: Int) : VideoModEx( + VideoPacketModExType.TIMESTAMP_OFFSET_NANO, value + ) +} -object VideoModExFactories { - val TIMESTAMP_OFFSET_NANO = - object : VideoModExFactory(VideoPacketModExType.TIMESTAMP_OFFSET_NANO) { - override fun create(value: Int): ModEx { - return ModEx(type, 3, { sink -> - sink.writeInt24(value) - }) - } +internal sealed class VideoModExCodec : ModExCodec { + object timestampOffsetNano : VideoModExCodec() { + override val type = VideoPacketModExType.TIMESTAMP_OFFSET_NANO + override val size = 3 + override fun encode(output: Sink, value: Int) { + output.writeInt24(value) } + + override fun decode(source: Source): VideoModEx.TimestampOffsetNano { + return VideoModEx.TimestampOffsetNano(source.readInt24()) + } + } + + companion object { + private val codecs = setOf(timestampOffsetNano) + + internal fun codecOf(type: VideoPacketModExType) = codecs.firstOrNull { it.type == type } + ?: throw IllegalArgumentException("Invalid VideoModExCodec type: $type") + + internal val encoder = ModExEncoder(codecs, VideoPacketType.MOD_EX.value) + } } diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVVideoDatas.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoDatas.kt similarity index 86% rename from flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVVideoDatas.kt rename to flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoDatas.kt index 56bd5ee..6c51766 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/FLVVideoDatas.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoDatas.kt @@ -13,18 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.tags.video +import io.github.thibaultbee.krtmp.amf.elements.AmfElement import io.github.thibaultbee.krtmp.flv.config.CodecID import io.github.thibaultbee.krtmp.flv.config.VideoFourCC import io.github.thibaultbee.krtmp.flv.sources.NaluRawSource +import io.github.thibaultbee.krtmp.flv.tags.video.ManyTrackManyCodecVideoTagBody.OneTrackMultiCodecVideoTagBody import io.github.thibaultbee.krtmp.flv.util.av.avc.AVCDecoderConfigurationRecord import io.github.thibaultbee.krtmp.flv.util.av.hevc.HEVCDecoderConfigurationRecord import io.github.thibaultbee.krtmp.flv.util.readBuffer import kotlinx.io.RawSource /** - * Factories to create [VideoData] and [AudioData]. + * Factories to create [VideoData]. */ // Legacy video data @@ -35,23 +37,23 @@ import kotlinx.io.RawSource * @param codecID the codec ID * @param command the video command */ -fun CommandLegacyVideoData( +fun CommandVideoData( codecID: CodecID, command: VideoCommand ) = LegacyVideoData( frameType = VideoFrameType.COMMAND, codecID = codecID, body = CommandLegacyVideoTagBody(command) ) /** - * Creates a legacy [LegacyVideoData] from a [RawSource] and its size. + * Creates a legacy [VideoData] from a [RawSource] and its size. * - * For AVC/H.264, use [avcLegacyVideoData], [avcHeaderLegacyVideoData] or [avcEndOfSequenceLegacyVideoData] instead. + * For AVC/H.264, use [avcVideoData], [avcHeaderVideoData] or [avcEndOfSequenceVideoData] instead. * * @param frameType the frame type (key frame or intra frame) * @param body the coded [RawSource] * @param bodySize the size of the coded [RawSource] - * @return the [LegacyVideoData] with the frame + * @return the [VideoData] with the frame */ -fun LegacyVideoData( +fun VideoData( frameType: VideoFrameType, codecID: CodecID, body: RawSource, @@ -65,16 +67,16 @@ fun LegacyVideoData( ) /** - * Creates a legacy AVC/H.264 [LegacyVideoData] from a [RawSource] and its size. + * Creates a legacy AVC/H.264 [VideoData] from a [RawSource] and its size. * * @param frameType the frame type (key frame or intra frame) * @param packetType the packet type * @param body the coded AVC [RawSource] without AVCC or AnnexB header * @param bodySize the size of the coded AVC [RawSource] * @param compositionTime the composition time (24 bits). Default is 0. - * @return the [LegacyVideoData] with the AVC frame + * @return the [VideoData] with the AVC frame */ -fun avcLegacyVideoData( +fun avcVideoData( frameType: VideoFrameType, packetType: AVCPacketType, body: RawSource, @@ -91,7 +93,7 @@ fun avcLegacyVideoData( ) /** - * Creates a legacy AVC/H.264 [LegacyVideoData] from a [NaluRawSource]. + * Creates a legacy AVC/H.264 [VideoData] from a [NaluRawSource]. * * It will extract the NAL unit by removing header (start code 0x00000001 or AVCC). * @@ -99,14 +101,14 @@ fun avcLegacyVideoData( * @param packetType the packet type * @param body the NAL unit [RawSource]. * @param compositionTime the composition time (24 bits). Default is 0. - * @return the [LegacyVideoData] with the AVC frame + * @return the [VideoData] with the AVC frame */ -fun avcLegacyVideoData( +fun avcVideoData( frameType: VideoFrameType, packetType: AVCPacketType, body: NaluRawSource, compositionTime: Int = 0 -) = avcLegacyVideoData( +) = avcVideoData( frameType = frameType, packetType = packetType, compositionTime = compositionTime, @@ -115,24 +117,24 @@ fun avcLegacyVideoData( ) /** - * Creates a legacy AVC/H.264 [LegacyVideoData] for SPS and PPS. + * Creates a legacy AVC/H.264 [VideoData] for SPS and PPS. * * This method will create a [AVCDecoderConfigurationRecord] from the SPS and PPS NAL units. - * If you want to directly pass [AVCDecoderConfigurationRecord], use [avcLegacyVideoData] instead. + * If you want to directly pass [AVCDecoderConfigurationRecord], use [avcVideoData] instead. * * @param sps the SPS NAL units * @param pps the PPS NAL units - * @return the [LegacyVideoData] with the SPS and PPS + * @return the [VideoData] with the SPS and PPS */ -fun avcHeaderLegacyVideoData( +fun avcHeaderVideoData( sps: List, pps: List, -) { +): VideoData { val decoderConfigurationRecord = AVCDecoderConfigurationRecord( sps = sps, pps = pps ).readBuffer() - avcLegacyVideoData( + return avcVideoData( frameType = VideoFrameType.KEY, packetType = AVCPacketType.SEQUENCE_HEADER, compositionTime = 0, @@ -143,9 +145,9 @@ fun avcHeaderLegacyVideoData( /** - * Creates a legacy AVC/H.264 [LegacyVideoData] for the end of sequence. + * Creates a legacy AVC/H.264 [VideoData] for the end of sequence. */ -fun avcEndOfSequenceLegacyVideoData() = LegacyVideoData( +fun avcEndOfSequenceVideoData() = LegacyVideoData( frameType = VideoFrameType.KEY, codecID = CodecID.AVC, packetType = AVCPacketType.END_OF_SEQUENCE, @@ -164,7 +166,7 @@ fun avcEndOfSequenceLegacyVideoData() = LegacyVideoData( fun CommandExtendedVideoData( packetType: VideoPacketType, command: VideoCommand ) = ExtendedVideoData( - packetDescriptor = ExtendedVideoData.CommandVideoPacketDescriptor( + packetDescriptor = ExtendedVideoData.CommandVideoDataDescriptor( command = command, packetType = packetType ) ) @@ -260,12 +262,12 @@ fun avcExtendedVideoData( fun avcHeaderExtendedVideoData( sps: List, pps: List, -) { +): ExtendedVideoData { val decoderConfigurationRecord = AVCDecoderConfigurationRecord( sps = sps, pps = pps ).readBuffer() - avcExtendedVideoData( + return avcExtendedVideoData( frameType = VideoFrameType.KEY, packetType = VideoPacketType.SEQUENCE_START, body = decoderConfigurationRecord, @@ -371,12 +373,12 @@ fun hevcHeaderExtendedVideoData( vps: List, sps: List, pps: List, -) { +): ExtendedVideoData { val decoderConfigurationRecord = HEVCDecoderConfigurationRecord( vps = vps, sps = sps, pps = pps ).readBuffer() - hevcVideoExtendedData( + return hevcVideoExtendedData( frameType = VideoFrameType.KEY, packetType = VideoPacketType.SEQUENCE_START, body = decoderConfigurationRecord, @@ -419,6 +421,21 @@ fun endOfSequenceExtendedVideoData( VideoFrameType.KEY, VideoPacketType.SEQUENCE_END, fourCC, EmptyVideoTagBody() ) +/** + * Creates an [ExtendedVideoData] for the metadata. + * + * @param fourCC the FourCCs + * @param name the name of the metadata + * @param value the value of the metadata + */ +fun metadataExtendedVideoData( + fourCC: VideoFourCC, + name: String, + value: AmfElement +) = ExtendedVideoData( + VideoFrameType.KEY, VideoPacketType.META_DATA, fourCC, MetadataVideoTagBody(name, value) +) + /** * Creates a [MultitrackVideoTagBody] for one track video data. * @@ -429,7 +446,7 @@ fun endOfSequenceExtendedVideoData( * @param body the coded [RawSource] * @param bodySize the size of the coded [RawSource] */ -fun oneTrackExtendedVideoData( +fun oneTrackMultitrackExtendedVideoData( frameType: VideoFrameType, fourCC: VideoFourCC, framePacketType: VideoPacketType, @@ -437,7 +454,7 @@ fun oneTrackExtendedVideoData( body: RawSource, bodySize: Int ) = ExtendedVideoData( - packetDescriptor = ExtendedVideoData.MultitrackVideoPacketDescriptor.OneTrackVideoPacketDescriptor( + packetDescriptor = ExtendedVideoData.MultitrackVideoDataDescriptor.OneTrackVideoDataDescriptor( frameType = frameType, fourCC = fourCC, framePacketType = framePacketType, @@ -460,16 +477,16 @@ fun oneCodecMultitrackExtendedVideoData( fourCC: VideoFourCC, framePacketType: VideoPacketType, tracks: Set -) { +): ExtendedVideoData { val packetDescriptor = if (tracks.size == 1) { - ExtendedVideoData.MultitrackVideoPacketDescriptor.OneTrackVideoPacketDescriptor( + ExtendedVideoData.MultitrackVideoDataDescriptor.OneTrackVideoDataDescriptor( frameType = frameType, fourCC = fourCC, framePacketType = framePacketType, body = tracks.first() ) } else if (tracks.size > 1) { - ExtendedVideoData.MultitrackVideoPacketDescriptor.ManyTrackVideoPacketDescriptor( + ExtendedVideoData.MultitrackVideoDataDescriptor.ManyTrackVideoDataDescriptor( frameType = frameType, fourCC = fourCC, framePacketType = framePacketType, @@ -479,7 +496,7 @@ fun oneCodecMultitrackExtendedVideoData( throw IllegalArgumentException("No track in the set") } - ExtendedVideoData( + return ExtendedVideoData( packetDescriptor = packetDescriptor ) } @@ -489,14 +506,16 @@ fun oneCodecMultitrackExtendedVideoData( * * @param frameType the frame type * @param framePacketType the frame packet type - * @param body the [ManyTrackManyCodecVideoTagBody] + * @param tracks the set of [OneTrackMultiCodecVideoTagBody] */ fun manyCodecMultitrackExtendedVideoData( frameType: VideoFrameType, framePacketType: VideoPacketType, - body: ManyTrackManyCodecVideoTagBody + tracks: Set ) = ExtendedVideoData( - packetDescriptor = ExtendedVideoData.MultitrackVideoPacketDescriptor.ManyTrackManyCodecVideoPacketDescriptor( - frameType = frameType, framePacketType = framePacketType, body = body + packetDescriptor = ExtendedVideoData.MultitrackVideoDataDescriptor.ManyTrackManyCodecVideoDataDescriptor( + frameType = frameType, + framePacketType = framePacketType, + body = ManyTrackManyCodecVideoTagBody(tracks) ) ) \ No newline at end of file diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoTagBody.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoTagBody.kt new file mode 100644 index 0000000..c42bd2d --- /dev/null +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/VideoTagBody.kt @@ -0,0 +1,406 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.flv.tags.video + +import io.github.thibaultbee.krtmp.amf.AmfVersion +import io.github.thibaultbee.krtmp.amf.elements.AmfElement +import io.github.thibaultbee.krtmp.amf.elements.containers.amf0ContainerFrom +import io.github.thibaultbee.krtmp.amf.elements.containers.amfContainerOf +import io.github.thibaultbee.krtmp.amf.elements.primitives.AmfString +import io.github.thibaultbee.krtmp.amf.internal.utils.readInt24 +import io.github.thibaultbee.krtmp.amf.internal.utils.writeInt24 +import io.github.thibaultbee.krtmp.flv.config.VideoFourCC +import io.github.thibaultbee.krtmp.flv.sources.MultiRawSource +import io.github.thibaultbee.krtmp.flv.tags.video.SingleVideoTagBody.Companion.decode +import io.github.thibaultbee.krtmp.flv.util.extensions.readSource +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.Sink +import kotlinx.io.Source + +/** + * Interface for video tag body. + */ +interface VideoTagBody { + fun getSize(amfVersion: AmfVersion): Int + fun encode(output: Sink, amfVersion: AmfVersion) + fun readRawSource(amfVersion: AmfVersion): RawSource +} + +interface SingleVideoTagBody : VideoTagBody { + companion object { + internal fun decode( + packetType: VideoPacketType, + fourCC: VideoFourCC, + source: Source, + sourceSize: Int + ): SingleVideoTagBody { + return if (sourceSize == 0) { + EmptyVideoTagBody() + } else if ((packetType == VideoPacketType.CODED_FRAMES) && ((fourCC == VideoFourCC.HEVC) || + (fourCC == VideoFourCC.AVC)) + ) { + CompositionTimeExtendedVideoTagBody.decode(source, sourceSize) + } else if (packetType == VideoPacketType.META_DATA) { + MetadataVideoTagBody.decode(source, sourceSize) + } else { + RawVideoTagBody.decode(source, sourceSize) + } + } + } +} + +/** + * Metadata video tag body. + */ +class MetadataVideoTagBody( + val name: String, + val value: AmfElement +) : SingleVideoTagBody { + private val container = amfContainerOf(listOf(name, value)) + override fun getSize(amfVersion: AmfVersion): Int { + return if (amfVersion == AmfVersion.AMF0) { + container.size0 // AMF0 size + } else { + container.size3 // AMF3 size + } + } + + override fun encode(output: Sink, amfVersion: AmfVersion) { + container.write(amfVersion, output) + } + + override fun readRawSource(amfVersion: AmfVersion): RawSource { + return Buffer().apply { + encode(this, amfVersion) + } + } + + override fun toString(): String { + return "MetadataVideoTagBody(name=$name, value=$value)" + } + + companion object { + fun decode(source: Source, sourceSize: Int): MetadataVideoTagBody { + val container = amf0ContainerFrom(2, source) + val name = (container.first() as AmfString) + val value = container.last() + return MetadataVideoTagBody(name.value, value) + } + } +} + +/** + * Default video tag body. + */ +class RawVideoTagBody( + val data: RawSource, + val dataSize: Int +) : SingleVideoTagBody { + override fun getSize(amfVersion: AmfVersion) = dataSize + + override fun encode(output: Sink, amfVersion: AmfVersion) { + output.write(data, dataSize.toLong()) + } + + override fun readRawSource(amfVersion: AmfVersion): RawSource { + return data + } + + override fun toString(): String { + return "RawVideoTagBody(dataSize=$dataSize)" + } + + companion object { + fun decode(source: Source, sourceSize: Int): RawVideoTagBody { + return RawVideoTagBody(source.readSource(sourceSize.toLong()), sourceSize) + } + } +} + +internal class CommandLegacyVideoTagBody( + val command: VideoCommand +) : VideoTagBody { + override fun getSize(amfVersion: AmfVersion) = 1 + + override fun encode(output: Sink, amfVersion: AmfVersion) { + output.writeByte(command.value) + } + + override fun readRawSource(amfVersion: AmfVersion): RawSource { + return Buffer().apply { encode(this, amfVersion) } + } + + companion object { + fun decode(source: Source, sourceSize: Int): CommandLegacyVideoTagBody { + require(sourceSize >= 1) { "Command video tag body must have at least 1 byte" } + val command = source.readByte() + return CommandLegacyVideoTagBody(VideoCommand.entryOf(command)) + } + } +} + +/** + * Empty video tag body. + */ +internal class EmptyVideoTagBody : SingleVideoTagBody { + override fun getSize(amfVersion: AmfVersion) = 0 + override fun encode(output: Sink, amfVersion: AmfVersion) = + Unit // End of sequence does not have a body + + override fun readRawSource(amfVersion: AmfVersion) = Buffer() + + override fun toString(): String { + return "Empty" + } +} + + +/** + * AVC HEVC coded frame video tag body. + * + * Only to be used with extended AVC and HEVC codec when packet type is + * [VideoPacketType.CODED_FRAMES]. + * + * @param compositionTime 24 bits composition time + * @param data The raw source data of the video frame. + * @param dataSize The size of the data in bytes. + */ +class CompositionTimeExtendedVideoTagBody( + val compositionTime: Int, // 24 bits + val data: RawSource, + val dataSize: Int +) : SingleVideoTagBody { + private val size = 3 + dataSize + override fun getSize(amfVersion: AmfVersion) = size + + private fun encodeHeader(output: Sink) { + output.writeInt24(compositionTime) + } + + override fun encode(output: Sink, amfVersion: AmfVersion) { + encodeHeader(output) + output.write(data, dataSize.toLong()) + } + + override fun readRawSource(amfVersion: AmfVersion): RawSource { + return MultiRawSource( + Buffer().apply { encodeHeader(this) }, + data + ) + } + + override fun toString(): String { + return "ExtendedWithCompositionTimeVideoTagBody(compositionTime=$compositionTime, dataSize=$dataSize)" + } + + companion object { + fun decode( + source: Source, sourceSize: Int + ): CompositionTimeExtendedVideoTagBody { + val compositionTime = source.readInt24() + val remainingSize = sourceSize - 3 + return CompositionTimeExtendedVideoTagBody( + compositionTime, + source.readSource(remainingSize.toLong()), + remainingSize + ) + } + } +} + + +interface MultitrackVideoTagBody : VideoTagBody +interface OneCodecMultitrackVideoTagBody : MultitrackVideoTagBody + +/** + * One track video tag body. + * + * @param trackId The track id of the video. + * @param body The video tag body. + */ +class OneTrackVideoTagBody( + val trackId: Byte, + val body: SingleVideoTagBody +) : OneCodecMultitrackVideoTagBody { + override fun getSize(amfVersion: AmfVersion) = 1 + body.getSize(amfVersion) + + private fun encodeHeader(output: Sink) { + output.writeByte(trackId) + } + + override fun encode(output: Sink, amfVersion: AmfVersion) { + encodeHeader(output) + body.encode(output, amfVersion) + } + + override fun readRawSource(amfVersion: AmfVersion): RawSource { + return MultiRawSource( + Buffer().apply { encodeHeader(this) }, + body.readRawSource(amfVersion) + ) + } + + override fun toString(): String { + return "OneTrackVideoTagBody(trackId=$trackId, body=$body)" + } + + companion object { + fun decode( + packetType: VideoPacketType, fourCC: VideoFourCC, source: Source, sourceSize: Int + ): OneTrackVideoTagBody { + require(sourceSize >= 1) { "One track video tag body must have at least 1 byte" } + val trackId = source.readByte() + val body = SingleVideoTagBody.decode(packetType, fourCC, source, sourceSize - 1) + return OneTrackVideoTagBody(trackId, body) + } + } +} + +/** + * Many track video tag body with one codec. + * + * @param tracks The set of tracks. + */ +class ManyTrackOneCodecVideoTagBody internal constructor( + val tracks: Set +) : OneCodecMultitrackVideoTagBody { + override fun getSize(amfVersion: AmfVersion) = + tracks.sumOf { it.getSize(amfVersion) + 3 } // +3 for sizeOfVideoTrack + + override fun encode(output: Sink, amfVersion: AmfVersion) { + tracks.forEach { track -> + output.writeByte(track.trackId) + output.writeInt24(track.body.getSize(amfVersion)) + track.body.encode(output, amfVersion) + } + } + + override fun readRawSource(amfVersion: AmfVersion): RawSource { + val rawSources = mutableListOf() + tracks.forEach { track -> + rawSources.add(Buffer().apply { + writeByte(track.trackId) + writeInt24(track.body.getSize(amfVersion)) + }) + rawSources.add(track.body.readRawSource(amfVersion)) + } + return MultiRawSource(rawSources) + } + + override fun toString(): String { + return "ManyTrackOneCodecVideoTagBody(tracks=$tracks)" + } + + companion object { + fun decode( + packetType: VideoPacketType, fourCC: VideoFourCC, source: Source, sourceSize: Int + ): ManyTrackOneCodecVideoTagBody { + val tracks = mutableSetOf() + var remainingSize = sourceSize + while (remainingSize > 0) { + val trackId = source.readByte() + val sizeOfVideoTrack = source.readInt24() + val body = SingleVideoTagBody.decode(packetType, fourCC, source, sizeOfVideoTrack) + tracks.add(OneTrackVideoTagBody(trackId, body)) + remainingSize -= sizeOfVideoTrack + } + return ManyTrackOneCodecVideoTagBody(tracks) + } + } +} + +/** + * Many track video tag body with many codecs. + * + * @param tracks The set of tracks. + */ +class ManyTrackManyCodecVideoTagBody( + val tracks: Set +) : MultitrackVideoTagBody { + override fun getSize(amfVersion: AmfVersion): Int { + return tracks.sumOf { it.getSize(amfVersion) } + } + + override fun encode(output: Sink, amfVersion: AmfVersion) { + tracks.forEach { track -> + track.encode(output, amfVersion) + } + } + + override fun readRawSource(amfVersion: AmfVersion): RawSource { + return MultiRawSource(tracks.map { it.readRawSource(amfVersion) }) + } + + override fun toString(): String { + return "ManyTrackManyCodecVideoTagBody(tracks=$tracks)" + } + + companion object { + fun decode( + packetType: VideoPacketType, source: Source, sourceSize: Int + ): ManyTrackManyCodecVideoTagBody { + val tracks = mutableSetOf() + var remainingSize = sourceSize + while (remainingSize > 0) { + val track = OneTrackMultiCodecVideoTagBody.decode(packetType, source, remainingSize) + tracks.add(track) + remainingSize -= track.getSize(AmfVersion.AMF0) // AMF version does not matter here + } + return ManyTrackManyCodecVideoTagBody(tracks) + } + } + + data class OneTrackMultiCodecVideoTagBody( + val fourCC: VideoFourCC, + val trackId: Byte = 0, + val body: SingleVideoTagBody + ) { + fun getSize(amfVersion: AmfVersion) = 8 + body.getSize(amfVersion) + + private fun encodeHeader(output: Sink, amfVersion: AmfVersion) { + output.writeInt(fourCC.value.code) + output.writeByte(trackId) + output.writeInt24(body.getSize(amfVersion)) + } + + fun encode(output: Sink, amfVersion: AmfVersion) { + encodeHeader(output, amfVersion) + body.encode(output, amfVersion) + } + + fun readRawSource(amfVersion: AmfVersion): RawSource { + return MultiRawSource( + Buffer().apply { + encodeHeader(this, amfVersion) + }, + body.readRawSource(amfVersion) + ) + } + + companion object { + fun decode( + packetType: VideoPacketType, source: Source, sourceSize: Int + ): OneTrackMultiCodecVideoTagBody { + val fourCC = VideoFourCC.codeOf(source.readInt()) + val trackId = source.readByte() + val sizeOfVideoTrack = source.readInt24() + val body = decode(packetType, fourCC, source, sizeOfVideoTrack) + return OneTrackMultiCodecVideoTagBody(fourCC, trackId, body) + } + } + } +} diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/FLVHeader.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/FLVHeader.kt index eeaf8b8..1ae6e97 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/FLVHeader.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/FLVHeader.kt @@ -29,6 +29,10 @@ class FLVHeader(val hasAudio: Boolean, val hasVideo: Boolean) { output.writeInt(DATA_OFFSET) } + override fun toString(): String { + return "FLVHeader(hasAudio=$hasAudio, hasVideo=$hasVideo)" + } + companion object { private const val DATA_OFFSET = 9 diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/SinkEncoder.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/SinkEncoder.kt similarity index 94% rename from flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/SinkEncoder.kt rename to flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/SinkEncoder.kt index 7b2129c..96eac6a 100644 --- a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/tags/SinkEncoder.kt +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/SinkEncoder.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.util import kotlinx.io.Buffer import kotlinx.io.Sink @@ -23,7 +23,7 @@ import kotlinx.io.readByteArray /** * Interface a [Sink] encoder */ -sealed interface SinkEncoder { +interface SinkEncoder { /** * The size of the data in bytes. */ diff --git a/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/SourceExporter.kt b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/SourceExporter.kt new file mode 100644 index 0000000..174e350 --- /dev/null +++ b/flv/src/commonMain/kotlin/io/github/thibaultbee/krtmp/flv/util/SourceExporter.kt @@ -0,0 +1,9 @@ +package io.github.thibaultbee.krtmp.flv.util + +/** + * A way to export the source to a multiple [RawSource]. + * The purpose is to read huge data (for a video frame for example) the latest possible. + */ +internal interface SourceExporter { + // fun toRawSources(): List +} \ No newline at end of file diff --git a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayRawSourceTest.kt b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayBackedRawSourceTest.kt similarity index 85% rename from flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayRawSourceTest.kt rename to flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayBackedRawSourceTest.kt index 9992718..a4979ea 100644 --- a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayRawSourceTest.kt +++ b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteArrayBackedRawSourceTest.kt @@ -9,11 +9,11 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.fail -class ByteArrayRawSourceTest { +class ByteArrayBackedRawSourceTest { @Test fun `read from empty source`() { val byteArray = byteArrayOf() - val byteArrayRawSource = ByteArrayRawSource(byteArray) + val byteArrayRawSource = ByteArrayBackedRawSource(byteArray) val buffer = Buffer() val size = byteArrayRawSource.readAtMostTo(buffer, 4) @@ -25,7 +25,7 @@ class ByteArrayRawSourceTest { @Test fun `read from source`() { val byteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04) - val byteArrayRawSource = ByteArrayRawSource(byteArray) + val byteArrayRawSource = ByteArrayBackedRawSource(byteArray) var buffer = Buffer() var size = byteArrayRawSource.readAtMostTo(buffer, 2) @@ -43,7 +43,7 @@ class ByteArrayRawSourceTest { @Test fun `read from exhausted source`() { val byteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04) - val byteArrayRawSource = ByteArrayRawSource(byteArray) + val byteArrayRawSource = ByteArrayBackedRawSource(byteArray) val buffer = Buffer() byteArrayRawSource.readAtMostTo(buffer, 4) @@ -54,7 +54,7 @@ class ByteArrayRawSourceTest { @Test fun `read above source size`() { val byteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04) - val byteArrayRawSource = ByteArrayRawSource(byteArray) + val byteArrayRawSource = ByteArrayBackedRawSource(byteArray) val buffer = Buffer() val size = byteArrayRawSource.readAtMostTo(buffer, 5) @@ -66,7 +66,7 @@ class ByteArrayRawSourceTest { @Test fun `byte count out of range test`() { val byteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04) - val byteArrayRawSource = ByteArrayRawSource(byteArray) + val byteArrayRawSource = ByteArrayBackedRawSource(byteArray) val buffer = Buffer() try { byteArrayRawSource.readAtMostTo(buffer, -1) diff --git a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSourceTest.kt b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSourceTest.kt index 24879c9..7647786 100644 --- a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSourceTest.kt +++ b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/NaluRawSourceTest.kt @@ -15,7 +15,7 @@ class NaluRawSourceTest { val sizedRawSource = NaluRawSource(array) assertEquals(6, sizedRawSource.byteCount) - val actual = Buffer().apply { sizedRawSource.source.readAtMostTo(this, 6) }.readByteArray() + val actual = Buffer().apply { sizedRawSource.readAtMostTo(this, 6) }.readByteArray() assertContentEquals(expected, actual) } @@ -27,7 +27,7 @@ class NaluRawSourceTest { val sizedRawSource = NaluRawSource(array) assertEquals(6, sizedRawSource.byteCount) - val actual = Buffer().apply { sizedRawSource.source.readAtMostTo(this, 6) }.readByteArray() + val actual = Buffer().apply { sizedRawSource.readAtMostTo(this, 6) }.readByteArray() assertContentEquals(expected, actual) } @@ -39,7 +39,7 @@ class NaluRawSourceTest { val sizedRawSource = NaluRawSource(array) assertEquals(6, sizedRawSource.byteCount) - val actual = Buffer().apply { sizedRawSource.source.readAtMostTo(this, 6) }.readByteArray() + val actual = Buffer().apply { sizedRawSource.readAtMostTo(this, 6) }.readByteArray() assertContentEquals(expected, actual) } @@ -54,7 +54,7 @@ class NaluRawSourceTest { val sizedRawSource = NaluRawSource(buffer) assertEquals(6, sizedRawSource.byteCount) - val actual = Buffer().apply { sizedRawSource.source.readAtMostTo(this, 6) }.readByteArray() + val actual = Buffer().apply { sizedRawSource.readAtMostTo(this, 6) }.readByteArray() assertContentEquals(expected, actual) } @@ -69,7 +69,7 @@ class NaluRawSourceTest { val sizedRawSource = NaluRawSource(buffer) assertEquals(6, sizedRawSource.byteCount) - val actual = Buffer().apply { sizedRawSource.source.readAtMostTo(this, 6) }.readByteArray() + val actual = Buffer().apply { sizedRawSource.readAtMostTo(this, 6) }.readByteArray() assertContentEquals(expected, actual) } @@ -83,7 +83,7 @@ class NaluRawSourceTest { val sizedRawSource = NaluRawSource(buffer) assertEquals(6, sizedRawSource.byteCount) - val actual = Buffer().apply { sizedRawSource.source.readAtMostTo(this, 6) }.readByteArray() + val actual = Buffer().apply { sizedRawSource.readAtMostTo(this, 6) }.readByteArray() assertContentEquals(expected, actual) } } \ No newline at end of file diff --git a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioDataTest.kt b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/LegacyAudioDataTest.kt similarity index 81% rename from flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioDataTest.kt rename to flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/LegacyAudioDataTest.kt index 530795b..735ec9b 100644 --- a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/AudioDataTest.kt +++ b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/audio/LegacyAudioDataTest.kt @@ -1,4 +1,4 @@ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.tags.audio import io.github.thibaultbee.krtmp.amf.AmfVersion import io.github.thibaultbee.krtmp.flv.Resource @@ -6,13 +6,16 @@ import io.github.thibaultbee.krtmp.flv.config.SoundFormat import io.github.thibaultbee.krtmp.flv.config.SoundRate import io.github.thibaultbee.krtmp.flv.config.SoundSize import io.github.thibaultbee.krtmp.flv.config.SoundType +import io.github.thibaultbee.krtmp.flv.tags.FLVTag +import io.github.thibaultbee.krtmp.flv.tags.readByteArray +import io.github.thibaultbee.krtmp.flv.util.readByteArray import kotlinx.io.Buffer import kotlinx.io.readByteArray import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals -class AudioDataTest { +class LegacyAudioDataTest { @Test fun `test write aac raw tag`() { val expected = Resource("tags/audio/aac/raw/tag").toByteArray() @@ -22,9 +25,9 @@ class AudioDataTest { write(raw) } - val audioTagBody = DefaultAudioTagBody(rawBuffer, rawBuffer.size.toInt()) + val audioTagBody = RawAudioTagBody(rawBuffer, rawBuffer.size.toInt()) val audioData = - AudioData( + LegacyAudioData( soundFormat = SoundFormat.AAC, soundRate = SoundRate.F_44100HZ, soundSize = SoundSize.S_16BITS, @@ -45,9 +48,9 @@ class AudioDataTest { write(raw) } - val audioTagBody = DefaultAudioTagBody(rawBuffer, rawBuffer.size.toInt()) + val audioTagBody = RawAudioTagBody(rawBuffer, rawBuffer.size.toInt()) val audioData = - AudioData( + LegacyAudioData( soundFormat = SoundFormat.AAC, soundRate = SoundRate.F_44100HZ, soundSize = SoundSize.S_16BITS, @@ -69,9 +72,9 @@ class AudioDataTest { } val audioTagBody = - DefaultAudioTagBody(sequenceHeaderBuffer, sequenceHeaderBuffer.size.toInt()) + RawAudioTagBody(sequenceHeaderBuffer, sequenceHeaderBuffer.size.toInt()) val audioData = - AudioData( + LegacyAudioData( soundFormat = SoundFormat.AAC, soundRate = SoundRate.F_44100HZ, soundSize = SoundSize.S_16BITS, @@ -93,7 +96,7 @@ class AudioDataTest { } val audioTag = FLVTag.decode(muxBuffer, AmfVersion.AMF0) - val audioData = audioTag.data as AudioData + val audioData = audioTag.data as LegacyAudioData assertEquals(SoundFormat.AAC, audioData.soundFormat) assertEquals(SoundRate.F_44100HZ, audioData.soundRate) @@ -101,8 +104,9 @@ class AudioDataTest { assertEquals(SoundType.STEREO, audioData.soundType) assertEquals(AACPacketType.RAW, audioData.aacPacketType) + val body = audioData.body as RawAudioTagBody val actual = Buffer().apply { - write(audioData.body.data, audioData.body.dataSize.toLong()) + write(body.data, body.dataSize.toLong()) } assertContentEquals(expected, actual.readByteArray()) @@ -118,7 +122,7 @@ class AudioDataTest { } val audioTag = FLVTag.decode(muxBuffer) - val audioData = audioTag.data as AudioData + val audioData = audioTag.data as LegacyAudioData assertEquals(SoundFormat.AAC, audioData.soundFormat) assertEquals(SoundRate.F_44100HZ, audioData.soundRate) @@ -126,8 +130,9 @@ class AudioDataTest { assertEquals(SoundType.STEREO, audioData.soundType) assertEquals(AACPacketType.SEQUENCE_HEADER, audioData.aacPacketType) + val body = audioData.body as RawAudioTagBody val actual = Buffer().apply { - write(audioData.body.data, audioData.body.dataSize.toLong()) + write(body.data, body.dataSize.toLong()) } assertContentEquals(expected, actual.readByteArray()) diff --git a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/OnMetadataTest.kt b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/OnMetadataTest.kt similarity index 94% rename from flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/OnMetadataTest.kt rename to flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/OnMetadataTest.kt index bf5147a..e79fb65 100644 --- a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/OnMetadataTest.kt +++ b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/script/OnMetadataTest.kt @@ -1,4 +1,4 @@ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.tags.script import io.github.thibaultbee.krtmp.amf.AmfVersion import io.github.thibaultbee.krtmp.flv.Resource @@ -9,6 +9,9 @@ import io.github.thibaultbee.krtmp.flv.config.SoundRate import io.github.thibaultbee.krtmp.flv.config.SoundSize import io.github.thibaultbee.krtmp.flv.config.SoundType import io.github.thibaultbee.krtmp.flv.config.VideoMediaType +import io.github.thibaultbee.krtmp.flv.tags.FLVTag +import io.github.thibaultbee.krtmp.flv.tags.readByteArray +import io.github.thibaultbee.krtmp.flv.util.readByteArray import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals diff --git a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoDataTest.kt b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/LegacyVideoDataTest.kt similarity index 90% rename from flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoDataTest.kt rename to flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/LegacyVideoDataTest.kt index 02b622f..42bffd4 100644 --- a/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/VideoDataTest.kt +++ b/flv/src/commonTest/kotlin/io/github/thibaultbee/krtmp/flv/tags/video/LegacyVideoDataTest.kt @@ -1,14 +1,17 @@ -package io.github.thibaultbee.krtmp.flv.tags +package io.github.thibaultbee.krtmp.flv.tags.video import io.github.thibaultbee.krtmp.flv.Resource import io.github.thibaultbee.krtmp.flv.config.CodecID +import io.github.thibaultbee.krtmp.flv.tags.FLVTag +import io.github.thibaultbee.krtmp.flv.tags.readByteArray +import io.github.thibaultbee.krtmp.flv.util.readByteArray import kotlinx.io.Buffer import kotlinx.io.readByteArray import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals -class VideoDataTest { +class LegacyVideoDataTest { @Test fun `test write avc key tag`() { val expected = Resource("tags/video/avc/key/tag").toByteArray() diff --git a/flv/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/flv/extensions/PacketWriterExtensions.kt b/flv/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/flv/extensions/PacketWriterExtensions.kt deleted file mode 100644 index 6559731..0000000 --- a/flv/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/flv/extensions/PacketWriterExtensions.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.github.thibaultbee.krtmp.flv.extensions - -import io.github.thibaultbee.krtmp.flv.util.PacketWriter -import io.github.thibaultbee.krtmp.flv.util.readBuffer -import kotlinx.io.readAtMostTo -import java.nio.ByteBuffer - -/** - * Writes the packet to a [ByteBuffer]. - * - * @return the [ByteBuffer] containing the packet - */ -fun PacketWriter.readByteBuffer(): ByteBuffer { - val buffer = readBuffer() - val byteBuffer = ByteBuffer.allocate(buffer.size.toInt()) - buffer.readAtMostTo(byteBuffer) - return byteBuffer -} diff --git a/flv/src/jvmTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteBufferBackedRawSourceTest.kt b/flv/src/jvmTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteBufferBackedRawSourceTest.kt new file mode 100644 index 0000000..6f1b9cd --- /dev/null +++ b/flv/src/jvmTest/kotlin/io/github/thibaultbee/krtmp/flv/sources/ByteBufferBackedRawSourceTest.kt @@ -0,0 +1,83 @@ +package io.github.thibaultbee.krtmp.flv.sources + +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import java.nio.ByteBuffer +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fail + +class BytBufferBackedRawSourceTest { + @Test + fun `read from empty source`() { + val byteBuffer = ByteBuffer.allocate(0) + val byteBufferRawSource = ByteBufferBackedRawSource(byteBuffer) + val buffer = Buffer() + + val size = byteBufferRawSource.readAtMostTo(buffer, 4) + assertContentEquals(byteBuffer.array(), buffer.readByteArray()) + assertEquals(-1, size) + } + + @Test + fun `read from source`() { + val byteBuffer = ByteBuffer.allocate(4) + byteBuffer.putInt(0x01020304) + byteBuffer.flip() // Prepare the buffer for reading + val byteBufferRawSource = ByteBufferBackedRawSource(byteBuffer) + + var buffer = Buffer() + var size = byteBufferRawSource.readAtMostTo(buffer, 2) + assertContentEquals(byteArrayOf(0x01, 0x02), buffer.readByteArray()) + assertEquals(2, size) + + buffer = Buffer() + size = byteBufferRawSource.readAtMostTo(buffer, 2) + assertContentEquals(byteArrayOf(0x03, 0x04), buffer.readByteArray()) + assertEquals(2, size) + } + + @Test + fun `read from exhausted source`() { + val byteBuffer = ByteBuffer.allocate(4) + byteBuffer.putInt(0x01020304) + byteBuffer.flip() // Prepare the buffer for reading + val byteBufferRawSource = ByteBufferBackedRawSource(byteBuffer) + val buffer = Buffer() + + byteBufferRawSource.readAtMostTo(buffer, 4) + assertEquals(-1, byteBufferRawSource.readAtMostTo(buffer, 4)) + } + + @Test + fun `read above source size`() { + val byteBuffer = ByteBuffer.allocate(4) + byteBuffer.putInt(0x01020304) + byteBuffer.flip() // Prepare the buffer for reading + val byteBufferRawSource = ByteBufferBackedRawSource(byteBuffer) + val buffer = Buffer() + + val size = byteBufferRawSource.readAtMostTo(buffer, 5) + assertContentEquals(byteBuffer.array(), buffer.readByteArray(4)) + assertEquals(4, size) + } + + @Test + fun `byte count out of range test`() { + val byteBuffer = ByteBuffer.allocate(4) + byteBuffer.putInt(0x01020304) + byteBuffer.flip() // Prepare the buffer for reading + val byteBufferRawSource = ByteBufferBackedRawSource(byteBuffer) + val buffer = Buffer() + + try { + byteBufferRawSource.readAtMostTo(buffer, -1) + fail("Should throw IllegalArgumentException") + } catch (e: Exception) { + assertTrue(e is IllegalArgumentException) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4260c5e..23f4f48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,21 @@ [versions] -agp = "8.9.1" +agp = "8.11.0" +clikt = "5.0.3" flexMessagingCore = "4.8.0" -jcodec = "0.2.5" -kotlin = "2.1.20" +kotlin = "2.1.21" dokka = "2.0.0" kotlinxCoroutines = "1.10.2" kotlinxDatetime = "0.6.2" kotlinxKover = "0.9.1" kotlinxIo = "0.7.0" kotlinxSerialization = "1.8.0" -ktor = "3.1.2" +ktor = "3.2.0" detekt = "1.23.8" publish = "2.0.0" [libraries] +clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } flex-messaging-core = { module = "org.apache.flex.blazeds:flex-messaging-core", version.ref = "flexMessagingCore" } -jcodec = { module = "org.jcodec:jcodec", version.ref = "jcodec" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } diff --git a/rtmp/build.gradle.kts b/rtmp/build.gradle.kts index 238027e..90c12b4 100644 --- a/rtmp/build.gradle.kts +++ b/rtmp/build.gradle.kts @@ -24,7 +24,7 @@ kotlin { compilations.all { compileTaskProvider.configure { compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) + jvmTarget.set(JvmTarget.JVM_18) } } } @@ -44,10 +44,10 @@ kotlin { sourceSets { commonMain.dependencies { api(libs.ktor.network) - api(libs.ktor.network.tls) - api(libs.ktor.http) - api(libs.ktor.client.core) - api(libs.ktor.client.cio) + implementation(libs.ktor.network.tls) + implementation(libs.ktor.http) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) implementation(libs.kotlinx.io.core) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.coroutines.core) @@ -59,6 +59,12 @@ kotlin { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) } + androidMain { + kotlin.srcDir("src/commonJvmAndroid/kotlin") + } + jvmMain { + kotlin.srcDir("src/commonJvmAndroid/kotlin") + } jvmTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) @@ -74,7 +80,7 @@ kotlin { android { namespace = "io.github.thibaultbee.krtmp.rtmp" - compileSdk = 34 + compileSdk = 36 defaultConfig { minSdk = 21 } diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/RtmpConfiguration.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/RtmpConfiguration.kt index 49e0d7b..7199c50 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/RtmpConfiguration.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/RtmpConfiguration.kt @@ -15,24 +15,11 @@ */ package io.github.thibaultbee.krtmp.rtmp -import io.github.thibaultbee.krtmp.amf.AmfVersion -import io.github.thibaultbee.krtmp.rtmp.util.RtmpClock - -/** - * This class contains configuration for RTMP connection. - * - * @param writeChunkSize RTMP chunk size in bytes - * @param writeWindowAcknowledgementSize RTMP acknowledgement window size in bytes - * @param amfVersion AMF version - * @param clock Clock used to timestamp RTMP messages. You should use the same clock for your video and audio timestamps. - */ -abstract class RtmpConfiguration( - val writeChunkSize: Int = DEFAULT_CHUNK_SIZE, - val writeWindowAcknowledgementSize: Int = Int.MAX_VALUE, - val amfVersion: AmfVersion = AmfVersion.AMF0, - val clock: RtmpClock = RtmpClock.Default(), -) { - companion object { - const val DEFAULT_CHUNK_SIZE = 128 // bytes - } +object RtmpConfiguration { + /** + * The default chunk size used for RTMP connections. + * This value is used when the client does not specify a chunk size during the handshake. + * The default value is 128 bytes, which is the minimum chunk size allowed by the RTMP protocol. + */ + const val DEFAULT_CHUNK_SIZE = 128 // bytes } \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RtmpClient.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RtmpClient.kt new file mode 100644 index 0000000..393ed9d --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RtmpClient.kt @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.client + +import io.github.thibaultbee.krtmp.common.logger.KrtmpLogger +import io.github.thibaultbee.krtmp.flv.sources.ByteArrayBackedRawSource +import io.github.thibaultbee.krtmp.flv.tags.FLVData +import io.github.thibaultbee.krtmp.flv.tags.FLVTag +import io.github.thibaultbee.krtmp.flv.tags.RawFLVTag +import io.github.thibaultbee.krtmp.flv.tags.script.OnMetadata +import io.github.thibaultbee.krtmp.rtmp.connection.RtmpConnection +import io.github.thibaultbee.krtmp.rtmp.connection.RtmpConnectionCallback +import io.github.thibaultbee.krtmp.rtmp.connection.RtmpSettings +import io.github.thibaultbee.krtmp.rtmp.connection.write +import io.github.thibaultbee.krtmp.rtmp.extensions.clientHandshake +import io.github.thibaultbee.krtmp.rtmp.messages.Command +import io.github.thibaultbee.krtmp.rtmp.messages.DataAmf +import io.github.thibaultbee.krtmp.rtmp.messages.Message +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObjectBuilder +import io.github.thibaultbee.krtmp.rtmp.messages.command.StreamPublishType +import io.github.thibaultbee.krtmp.rtmp.util.RtmpURLBuilder +import io.github.thibaultbee.krtmp.rtmp.util.sockets.ISocket +import io.github.thibaultbee.krtmp.rtmp.util.sockets.SocketFactory +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.network.sockets.ASocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.io.Buffer +import kotlinx.io.RawSource +import kotlinx.io.Source + +/** + * Creates a new [RtmpClient] with the given URL string and settings. + * + * @param urlString the RTMP URL to connect to + * @param callback the callback to handle RTMP client events + * @param settings the settings for the RTMP client + * @return a new [RtmpClient] instance + */ +suspend fun RtmpClient( + urlString: String, + callback: DefaultRtmpClientCallback = DefaultRtmpClientCallback(), + settings: RtmpSettings = RtmpSettings +) = + RtmpClient(RtmpURLBuilder(urlString), callback, settings) + +/** + * Creates a new [RtmpClient] with the given URL and settings. + * + * @param url the RTMP URL to connect to + * @param callback the callback to handle RTMP client events + * @param settings the settings for the RTMP client + * @return a new [RtmpClient] instance + */ +suspend fun RtmpClient( + url: Url, + callback: DefaultRtmpClientCallback = DefaultRtmpClientCallback(), + settings: RtmpSettings = RtmpSettings +) = + RtmpClient(RtmpURLBuilder(url), callback, settings) + +/** + * Creates a new [RtmpClient] with the given [URLBuilder] and settings. + * + * Use [RtmpURLBuilder] to create the [URLBuilder]. + * + * @param urlBuilder the [URLBuilder] to connect to + * @param callback the callback to handle RTMP client events + * @param settings the settings for the RTMP client + * @return a new [RtmpClient] instance + */ +suspend fun RtmpClient( + urlBuilder: URLBuilder, + callback: RtmpClientCallback = DefaultRtmpClientCallback(), + settings: RtmpSettings = RtmpSettings, +): RtmpClient { + val connection = SocketFactory().connect(urlBuilder) + try { + connection.clientHandshake(settings.clock) + } catch (t: Throwable) { + connection.close() + throw t + } + return RtmpClient(connection, callback, settings) +} + +internal fun RtmpClient( + connection: ISocket, + callback: RtmpClientCallback, + settings: RtmpSettings +): RtmpClient { + return RtmpClient( + RtmpConnection( + connection, + settings, + RtmpClientConnectionCallback.Factory(callback), + ) + ) +} + +/** + * The RTMP client. + */ +class RtmpClient internal constructor( + private val connection: RtmpConnection +) : + CoroutineScope by connection, ASocket by connection { + /** + * Whether the connection is closed. + */ + val isClosed: Boolean + get() = connection.isClosed + + /** + * Connects to the server. + * + * @param block a block to configure the [ConnectObjectBuilder] + * @return the [Command.Result] send by the server + */ + suspend fun connect(block: ConnectObjectBuilder.() -> Unit = {}) = + connection.connect(block) + + /** + * Creates a stream. + * + * @return the [Command.Result] send by the server + * + * @see [deleteStream] + */ + suspend fun createStream() = connection.createStream() + + /** + * Publishes the stream. + * + * @param type the publish type + * @return the [Command.OnStatus] send by the server + */ + suspend fun publish( + type: StreamPublishType = StreamPublishType.LIVE + ) = connection.publish(type) + + /** + * Deletes the stream. + * + * @return the [Command.Result] send by the server + */ + suspend fun deleteStream() = connection.deleteStream() + + /** + * Closes the connection and cleans up resources. + */ + override fun close() = connection.close() + + /** + * Writes the SetDataFrame from [OnMetadata.Metadata]. + * It must be called after [publish] and before sending audio or video frames. + * + * Expected AMF format is the one set in [RtmpSettings.amfVersion]. + * + * @param metadata the on metadata to send + */ + suspend fun writeSetDataFrame(metadata: OnMetadata.Metadata) = + connection.writeSetDataFrame(metadata) + + /** + * Writes the SetDataFrame from a [ByteArray]. + * It must be called after [publish] and before sending audio or video frames. + * + * Expected AMF format is the one set in [RtmpSettings.amfVersion]. + * + * @param onMetadata the on metadata to send + */ + suspend fun writeSetDataFrame(onMetadata: ByteArray) = connection.writeSetDataFrame( + ByteArrayBackedRawSource(onMetadata), onMetadata.size + ) + + /** + * Writes the SetDataFrame from a [Buffer]. + * It must be called after [publish] and before sending audio or video frames. + * + * Expected AMF format is the one set in [RtmpSettings.amfVersion]. + * + * @param onMetadata the on metadata to send + */ + suspend fun writeSetDataFrame(onMetadata: RawSource, size: Int) = + connection.writeSetDataFrame(onMetadata, size) + + /** + * Writes an audio frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param array the audio frame to write + */ + suspend fun writeAudio(timestamp: Int, array: ByteArray) = + connection.writeAudio(timestamp, array) + + /** + * Writes a video frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param array the video frame to write + */ + suspend fun writeVideo(timestamp: Int, array: ByteArray) = + connection.writeVideo(timestamp, array) + + /** + * Writes an audio frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param source the audio frame to write + */ + suspend fun writeAudio(timestamp: Int, source: RawSource, sourceSize: Int) = + connection.writeAudio(timestamp, source, sourceSize) + + /** + * Writes a video frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param source the video frame to write + */ + suspend fun writeVideo(timestamp: Int, source: RawSource, sourceSize: Int) = + connection.writeVideo(timestamp, source, sourceSize) + + /** + * Writes a raw audio, video or script frame from a [Source]. + * + * The frame must be in the FLV format. + * + * @param source the frame to write + */ + suspend fun write(source: Source) = connection.write(source) + + /** + * Writes a [FLVData]. + * + * @param timestampMs the timestamp of the frame in milliseconds + * @param data the frame to write + */ + suspend fun write(timestampMs: Int, data: FLVData) = connection.write(timestampMs, data) + + /** + * Writes a [FLVTag]. + * + * @param tag the FLV tag to write + */ + suspend fun write(tag: FLVTag) = connection.write(tag) + + /** + * Writes a [RawFLVTag]. + * + * @param rawTag the FLV tag to write + */ + suspend fun write(rawTag: RawFLVTag) = connection.write(rawTag) + + /** + * Writes a custom [Command]. + * + * @param command the command to write + */ + suspend fun write(command: Command) = connection.writeAmfMessage(command) + + override fun dispose() { + try { + close() + } catch (ignore: Throwable) { + } + } +} + +internal class RtmpClientConnectionCallback( + private val socket: RtmpConnection, + private val callback: RtmpClientCallback +) : RtmpConnectionCallback { + override suspend fun onMessage(message: Message) { + callback.onMessage(message) + } + + override suspend fun onCommand(command: Command) { + callback.onCommand(command) + } + + override suspend fun onData(data: DataAmf) { + callback.onData(data) + } + + class Factory(private val callback: RtmpClientCallback) : RtmpConnectionCallback.Factory { + override fun create(streamer: RtmpConnection): RtmpConnectionCallback { + return RtmpClientConnectionCallback(streamer, callback) + } + } +} + +class DefaultRtmpClientCallback : RtmpClientCallback { + override suspend fun onMessage(message: Message) { + KrtmpLogger.i(TAG, "Received message: $message") + } + + override suspend fun onCommand(command: Command) { + KrtmpLogger.i(TAG, "Received command: $command") + } + + override suspend fun onData(data: DataAmf) { + KrtmpLogger.i(TAG, "Received data: $data") + } + + companion object { + /** + * Default instance of [DefaultRtmpClientCallback]. + */ + private const val TAG = "DefaultRtmpClientCallback" + } +} + +/** + * Callback interface for RTMP client events. + */ +interface RtmpClientCallback { + /** + * Called when a message is received. + * + * @param message the received message + */ + suspend fun onMessage(message: Message) + + /** + * Called when a command is received. + * + * @param command the received command + */ + suspend fun onCommand(command: Command) + + /** + * Called when data is received. + * + * @param data the received data + */ + suspend fun onData(data: DataAmf) +} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RtmpClientSettings.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RtmpClientSettings.kt deleted file mode 100644 index 9adf408..0000000 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RtmpClientSettings.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (C) 2024 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.rtmp.client - -import io.github.thibaultbee.krtmp.amf.AmfVersion -import io.github.thibaultbee.krtmp.flv.models.config.AudioMediaType -import io.github.thibaultbee.krtmp.flv.models.config.VideoMediaType -import io.github.thibaultbee.krtmp.rtmp.RtmpConfiguration -import io.github.thibaultbee.krtmp.rtmp.messages.Command.Connect.ConnectObject.Companion.DEFAULT_AUDIO_CODECS -import io.github.thibaultbee.krtmp.rtmp.messages.Command.Connect.ConnectObject.Companion.DEFAULT_FLASH_VER -import io.github.thibaultbee.krtmp.rtmp.messages.Command.Connect.ConnectObject.Companion.DEFAULT_VIDEO_CODECS -import io.github.thibaultbee.krtmp.rtmp.util.RtmpClock - -/** - * This class contains configuration for RTMP client. - * - * @param writeChunkSize RTMP chunk size in bytes - * @param writeWindowAcknowledgementSize RTMP acknowledgement window size in bytes - * @param amfVersion AMF version - * @param clock Clock used to timestamp RTMP messages. You should use the same clock for your video and audio timestamps. - */ -open class RtmpClientSettings( - writeChunkSize: Int = DEFAULT_CHUNK_SIZE, - writeWindowAcknowledgementSize: Int = Int.MAX_VALUE, - amfVersion: AmfVersion = AmfVersion.AMF0, - clock: RtmpClock = RtmpClock.Default() -) : RtmpConfiguration( - writeChunkSize, - writeWindowAcknowledgementSize, - amfVersion, - clock -) { - /** - * The default instance of [RtmpClientSettings] - */ - companion object Default : RtmpClientSettings() -} - -/** - * Connect information used by the connect command to RTMP server. - * - * @param flashVer Flash version - * @param audioCodecs List of supported audio codecs - * @param videoCodecs List of supported video codecs (including extended RTMP codecs) - */ -open class RtmpClientConnectInformation( - val flashVer: String = DEFAULT_FLASH_VER, - val audioCodecs: List? = DEFAULT_AUDIO_CODECS, - val videoCodecs: List? = DEFAULT_VIDEO_CODECS, -) { - /** - * The default instance of [RtmpClientConnectInformation] - */ - companion object Default : RtmpClientConnectInformation() -} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/publish/RtmpClient.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/publish/RtmpClient.kt deleted file mode 100644 index 20cf61a..0000000 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/publish/RtmpClient.kt +++ /dev/null @@ -1,821 +0,0 @@ -/* - * Copyright (C) 2024 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.rtmp.client.publish - -import io.github.thibaultbee.krtmp.amf.AmfVersion -import io.github.thibaultbee.krtmp.amf.elements.containers.AmfObject -import io.github.thibaultbee.krtmp.amf.elements.primitives.AmfNumber -import io.github.thibaultbee.krtmp.amf.elements.primitives.AmfString -import io.github.thibaultbee.krtmp.common.logger.Logger -import io.github.thibaultbee.krtmp.flv.FLVMuxer -import io.github.thibaultbee.krtmp.flv.models.sources.ByteArrayRawSource -import io.github.thibaultbee.krtmp.flv.models.sources.RawSourceWithSize -import io.github.thibaultbee.krtmp.flv.models.tags.FLVTag -import io.github.thibaultbee.krtmp.flv.models.tags.OnMetadata -import io.github.thibaultbee.krtmp.rtmp.RtmpConfiguration -import io.github.thibaultbee.krtmp.rtmp.chunk.Chunk -import io.github.thibaultbee.krtmp.rtmp.client.RemoteServerException -import io.github.thibaultbee.krtmp.rtmp.client.RtmpClientConnectInformation -import io.github.thibaultbee.krtmp.rtmp.client.RtmpClientSettings -import io.github.thibaultbee.krtmp.rtmp.client.publish.RtmpClient.Factory -import io.github.thibaultbee.krtmp.rtmp.extensions.clientHandshake -import io.github.thibaultbee.krtmp.rtmp.extensions.isTunneledRtmp -import io.github.thibaultbee.krtmp.rtmp.extensions.streamKey -import io.github.thibaultbee.krtmp.rtmp.extensions.validateRtmp -import io.github.thibaultbee.krtmp.rtmp.extensions.write -import io.github.thibaultbee.krtmp.rtmp.messages.Acknowledgement -import io.github.thibaultbee.krtmp.rtmp.messages.AmfMessage -import io.github.thibaultbee.krtmp.rtmp.messages.Audio -import io.github.thibaultbee.krtmp.rtmp.messages.Command -import io.github.thibaultbee.krtmp.rtmp.messages.Command.Connect.ConnectObject.Companion.DEFAULT_AUDIO_CODECS -import io.github.thibaultbee.krtmp.rtmp.messages.Command.Connect.ConnectObject.Companion.DEFAULT_FLASH_VER -import io.github.thibaultbee.krtmp.rtmp.messages.Command.Connect.ConnectObject.Companion.DEFAULT_VIDEO_CODECS -import io.github.thibaultbee.krtmp.rtmp.messages.CommandMessage -import io.github.thibaultbee.krtmp.rtmp.messages.DataAmf -import io.github.thibaultbee.krtmp.rtmp.messages.DataAmfMessage -import io.github.thibaultbee.krtmp.rtmp.messages.Message -import io.github.thibaultbee.krtmp.rtmp.messages.SetChunkSize -import io.github.thibaultbee.krtmp.rtmp.messages.SetPeerBandwidth -import io.github.thibaultbee.krtmp.rtmp.messages.UserControl -import io.github.thibaultbee.krtmp.rtmp.messages.Video -import io.github.thibaultbee.krtmp.rtmp.messages.WindowAcknowledgementSize -import io.github.thibaultbee.krtmp.rtmp.util.MessagesManager -import io.github.thibaultbee.krtmp.rtmp.util.NetStreamCommand -import io.github.thibaultbee.krtmp.rtmp.util.RtmpClock -import io.github.thibaultbee.krtmp.rtmp.util.RtmpURLBuilder -import io.github.thibaultbee.krtmp.rtmp.util.TransactionCommandCompletion -import io.github.thibaultbee.krtmp.rtmp.util.connections.HttpConnection -import io.github.thibaultbee.krtmp.rtmp.util.connections.IConnection -import io.github.thibaultbee.krtmp.rtmp.util.connections.TcpConnection -import io.ktor.http.URLBuilder -import io.ktor.network.sockets.SocketOptions -import io.ktor.utils.io.CancellationException -import kotlinx.coroutines.CompletableJob -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.Job -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.channels.ClosedReceiveChannelException -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlinx.io.Buffer -import kotlinx.io.IOException -import kotlinx.io.RawSource -import kotlinx.io.startsWith - - -class RtmpClientFactory(settings: RtmpClient.Settings) - -/** - * A RTMP client to publish stream. - * - * To create a client, use [Factory.create]. - * - * The usage is: - * - Connect to the server with [Factory.create] - * - Send RTMP create stream command with [createStream] - * - Send RTMP publish command with [publish] - * - * - Send metadata and audio and video frames with [writeSetDataFrame] and [writeAudio] or [writeVideo]. - * - * - Close the connection with [close] - */ -class RtmpClient internal constructor( - private val urlBuilder: URLBuilder, - private val connection: IConnection, - private val settings: Settings -) : CoroutineScope by connection { - private val messagesManager = MessagesManager() - - private var _transactionId = 1L - private val transactionId: Long - get() = _transactionId++ - - private var writeChunkSize: Int = settings.writeChunkSize - private var readChunkSize = RtmpConfiguration.DEFAULT_CHUNK_SIZE - private var readWindowAcknowledgementSize = Int.MAX_VALUE - private var lastReadWindowAcknowledgementSize = Int.MAX_VALUE - - private val commandChannels = TransactionCommandCompletion() - - private var messageStreamId = 0 - - private var _flvMuxer: FLVMuxer? = null - - override val coroutineContext: CompletableJob = Job() - - /** - * Gets the cause of the connection closure. - */ - val closedCause: Throwable? - get() { - return try { - connection.closedCause - } catch (e: Exception) { - null - } - } - - /** - * Returns the FLV muxer that can be used to write FLV tags directly. - * - * The muxer is created on the first call and reused for subsequent calls. - * - * When using this muxer, you don't have to call [writeAudio] neither [writeVideo] (and - * assimilated). - * - * @return the FLV muxer - */ - val flvMuxer: FLVMuxer - get() { - _flvMuxer?.let { return it } - - val listener = object : FLVMuxer.Listener { - override fun onOutputPacket(outputPacket: Packet) { - // Throw exception if connection is closed so the user can handle it - if (connection.isClosed) { - throw IOException("Connection closed", closedCause) - } - try { - connection.launch { - try { - writePacket(outputPacket) - } catch (e: TimeoutCancellationException) { - Logger.w(TAG, "Timeout while writing packet: ${e.message}") - } - } - } catch (e: Exception) { - throw IOException("Failed to write packet", e) - } - } - } - val muxer = FLVMuxer().apply { - addListener(listener) - } - _flvMuxer = muxer - return muxer - } - - /** - * Returns true if the connection is closed. - */ - val isClosed: Boolean - get() = connection.isClosed - - /** - * Returns the write chunk size. - * - * The default value is [RtmpConfiguration.DEFAULT_CHUNK_SIZE]. - * - * @return the write chunk size - * @see [setWriteChunkSize] - */ - fun getWriteChunkSize() = writeChunkSize - - /** - * Sets the write chunk size. - * - * The default value is [RtmpConfiguration.DEFAULT_CHUNK_SIZE]. - * - * @param chunkSize the write chunk size - * @see [getWriteChunkSize] - */ - suspend fun setWriteChunkSize(chunkSize: Int) { - if (writeChunkSize != chunkSize) { - val setChunkSize = SetChunkSize(settings.clock.nowInMs, chunkSize) - setChunkSize.write() - } - } - - /** - * Connects to the server and handshakes. - */ - suspend fun establishConnection() { - connection.connect() - connection.invokeOnCompletion { throwable -> clear(throwable) } - connection.clientHandshake(settings.clock) - } - - /** - * Connects to the server. - * - * @return the [Command.Result] send by the server - */ - suspend fun connect(connectInformation: ConnectInformation = ConnectInformation): Command.Result { - if (connection.isClosed) { - establishConnection() - } - - // Prepare connect object - val objectEncoding = if (settings.amfVersion == AmfVersion.AMF0) { - Command.Connect.ObjectEncoding.AMF0 - } else { - Command.Connect.ObjectEncoding.AMF3 - } - val connectObject = Command.Connect.ConnectObject( - app = urlBuilder.pathSegments[1], - flashVer = connectInformation.flashVer, - swfUrl = null, - tcUrl = urlBuilder.buildString().removeSuffix(urlBuilder.streamKey), - audioCodecs = connectInformation.audioCodecs, - videoCodecs = connectInformation.videoCodecs, - pageUrl = null, - objectEncoding = objectEncoding - ) - - // Launch coroutine to handle RTMP messages - connection.launch { - handleRtmpMessages() - } - - settings.clock.reset() - - val connectTransactionId = transactionId - val connectCommand = Command.Connect( - connectTransactionId, settings.clock.nowInMs, connectObject - ) - connectCommand.write() - - try { - return commandChannels.waitForResponse(connectTransactionId) as Command.Result - } catch (e: RemoteServerException) { - throw RemoteServerException("Connect command failed: ${e.message}", e.command) - } catch (e: Exception) { - throw IOException("Connect command failed", e) - } - } - - /** - * Creates a stream. - * - * @return the [Command.Result] send by the server - * - * @see [deleteStream] - */ - suspend fun createStream(): Command.Result { - val releaseStreamCommand = Command.ReleaseStream( - transactionId, settings.clock.nowInMs, urlBuilder.streamKey - ) - - val fcPublishCommand = Command.FCPublish( - transactionId, settings.clock.nowInMs, urlBuilder.streamKey - ) - - val createStreamTransactionId = transactionId - val createStreamCommand = - Command.CreateStream(createStreamTransactionId, settings.clock.nowInMs) - - listOf(releaseStreamCommand, fcPublishCommand, createStreamCommand).writeAmfMessages() - - val result = try { - commandChannels.waitForResponse(createStreamTransactionId) - } catch (e: RemoteServerException) { - throw RemoteServerException("Create stream command failed: ${e.message}", e.command) - } catch (e: Exception) { - throw IOException("Create stream command failed", e) - } - messageStreamId = (result.arguments[0] as AmfNumber).value.toInt() - return result as Command.Result - } - - /** - * Publishes the stream. - * - * @param type the publish type - * @return the [Command.OnStatus] send by the server - */ - suspend fun publish(type: Command.Publish.Type = Command.Publish.Type.LIVE): Command.OnStatus { - val publishTransactionId = transactionId - val publishCommand = Command.Publish( - messageStreamId, - publishTransactionId, - settings.clock.nowInMs, - urlBuilder.streamKey, - type - ) - publishCommand.write() - - return try { - commandChannels.waitForResponse(NetStreamCommand.PUBLISH) as Command.OnStatus - } catch (e: RemoteServerException) { - throw RemoteServerException("Publish command failed: ${e.message}", e.command) - } catch (e: Exception) { - throw IOException("Publish command failed", e) - } - } - - /** - * Write SetDataFrame from [OnMetadata.Metadata]. - * It must be called after [publish] and before [writeFrame], [writeAudio] or [writeVideo]. - * - * Expected AMF format is the one set in [RtmpClientSettings.amfVersion]. - * - * @param metadata the on metadata to send - */ - suspend fun writeSetDataFrame(metadata: OnMetadata.Metadata) { - val dataFrameDataAmf = DataAmf.SetDataFrame( - settings.clock.nowInMs, - messageStreamId, - metadata - ) - dataFrameDataAmf.write() - } - - /** - * Write SetDataFrame from a [ByteArray]. - * It must be called after [publish] and before [writeFrame], [writeAudio] or [writeVideo]. - * - * Expected AMF format is the one set in [RtmpClientSettings.amfVersion]. - * - * @param onMetadata the on metadata to send - */ - suspend fun writeSetDataFrame(onMetadata: ByteArray) { - val dataFrameDataAmf = DataAmfMessage.SetDataFrame( - settings.amfVersion, - messageStreamId, - settings.clock.nowInMs, - ByteArrayRawSource(onMetadata) - ) - return dataFrameDataAmf.write() - } - - /** - * Write SetDataFrame from a [Buffer]. - * It must be called after [publish] and before [writeFrame], [writeAudio] or [writeVideo]. - * - * Expected AMF format is the one set in [RtmpClientSettings.amfVersion]. - * - * @param onMetadata the on metadata to send - */ - suspend fun writeSetDataFrame(onMetadata: RawSource) { - val dataFrameDataAmf = DataAmfMessage.SetDataFrame( - settings.amfVersion, - messageStreamId, - settings.clock.nowInMs, - onMetadata - ) - return dataFrameDataAmf.write() - } - - suspend fun writeFrame(array: ByteArray) { - writeFrame(Buffer().apply { write(array) }) - } - - /** - * Writes a raw frame. - * - * The frame must be in the FLV format. - * - * Internally, it will parse the frame to extract the header and the body. - * It is not the most efficient way to write frames but it is convenient. - * If you have a frame in the FLV format, prefer using [writeAudio] or [writeVideo]. - * - * @param buffer the frame to write - */ - suspend fun writeFrame(buffer: Buffer) { - /** - * Dropping FLV header that is not needed. It starts with 'F', 'L' and 'V'. - * Just check the first byte to simplify. - */ - if (buffer.startsWith('F'.code.toByte())) { - Logger.i(TAG, "Dropping FLV header") - return - } - try { - val header = FlvTagPacket.Header.read(buffer) - val tag = RawSourceWithSize(buffer, header.bodySize.toLong()) - when (header.type) { - FLVTag.Type.AUDIO -> writeAudio(header.timestampMs, tag) - FLVTag.Type.VIDEO -> writeVideo(header.timestampMs, tag) - FLVTag.Type.SCRIPT -> writeSetDataFrame(tag) - else -> throw IllegalArgumentException("Frame type ${header.type} not supported") - } - } catch (e: Exception) { - throw IOException("Failed to write frame", e) - } - } - - /** - * Writes an [Packet]. - * - * @param packet the FLV packet to write - */ - suspend fun writePacket(packet: Packet) { - // Only FlvTagOutputPacket matters here - if (packet is FlvTagPacket) { - writeFlvTagOutputPacket(packet) - } - } - - /** - * Writes a FLV tag wrapped in a [FlvTagPacket]. - * - * @param tagPacket the FLV tag output packet to write - */ - private suspend fun writeFlvTagOutputPacket(tagPacket: FlvTagPacket) { - return when { - tagPacket.tag.type == FLVTag.Type.AUDIO -> writeAudio( - tagPacket.timestampMs, - tagPacket.bodyOutputPacket.readRawSource() - ) - - tagPacket.tag.type == FLVTag.Type.VIDEO -> writeVideo( - tagPacket.timestampMs, - tagPacket.bodyOutputPacket.readRawSource() - ) - - tagPacket.tag is OnMetadata -> { - val flvTag = tagPacket.tag as OnMetadata - flvTag.amfVersion = settings.amfVersion - writeSetDataFrame(flvTag.metadata) - } - - else -> throw IllegalArgumentException("Packet type ${tagPacket.tag::class.simpleName} not supported") - } - } - - /** - * Writes an audio frame. - * - * The frame must be wrapped in a FLV body. - * - * @param timestamp the timestamp of the frame - * @param array the audio frame to write - */ - suspend fun writeAudio(timestamp: Int, array: ByteArray) = - writeAudio(timestamp, ByteArrayRawSource(array)) - - /** - * Writes a video frame. - * - * The frame must be wrapped in a FLV body. - * - * @param timestamp the timestamp of the frame - * @param array the video frame to write - */ - suspend fun writeVideo(timestamp: Int, array: ByteArray) = - writeVideo(timestamp, ByteArrayRawSource(array)) - - /** - * Writes an audio frame. - * - * The frame must be wrapped in a FLV body. - * - * @param timestamp the timestamp of the frame - * @param source the audio frame to write - */ - suspend fun writeAudio(timestamp: Int, source: RawSource) { - val audio = Audio(timestamp, messageStreamId, source) - return audio.withTimeoutWriteIfNeeded() - } - - /** - * Writes a video frame. - * - * The frame must be wrapped in a FLV body. - * - * @param timestamp the timestamp of the frame - * @param source the video frame to write - */ - suspend fun writeVideo(timestamp: Int, source: RawSource) { - val video = Video(timestamp, messageStreamId, source) - return video.withTimeoutWriteIfNeeded() - } - - /** - * Deletes the stream. - * - * @see [createStream] - */ - suspend fun deleteStream() { - val deleteStreamCommand = Command.DeleteStream( - transactionId, settings.clock.nowInMs, urlBuilder.streamKey - ) - deleteStreamCommand.write() - } - - /** - * Closes the connection and cleans up resources. - */ - suspend fun close() { - val closeCommand = Command.CloseStream(transactionId, settings.clock.nowInMs) - closeCommand.write() - - connection.close() - clear() - coroutineContext.cancelChildren() - } - - private fun clear(throwable: Throwable? = null) { - messagesManager.clear() - if (!coroutineContext.isCompleted) { - if (throwable != null) { - coroutineContext.completeExceptionally(throwable) - } else { - coroutineContext.complete() - } - } - } - - private suspend fun handleRtmpMessages() { - try { - while (true) { - handleRtmpMessage() - } - } catch (e: CancellationException) { - commandChannels.completeAllExceptionally(e) - Logger.i(TAG, "Connection cancelled") - } catch (e: ClosedReceiveChannelException) { - commandChannels.completeAllExceptionally(e) - Logger.i(TAG, "Received channel closed") - } catch (e: ClosedSendChannelException) { - commandChannels.completeAllExceptionally(e) - Logger.i(TAG, "Send channel closed") - } catch (t: Throwable) { - commandChannels.completeAllExceptionally(t) - Logger.e(TAG, "Error while handling RTMP messages", t) - } finally { - close() - } - } - - private suspend fun handleRtmpMessage() { - val message = readMessage().apply { - // Send Acknowledgement message if needed - val totalBytesRead = connection.totalBytesRead.toInt() - val readBytes = totalBytesRead - lastReadWindowAcknowledgementSize - if (readBytes >= readWindowAcknowledgementSize) { - Acknowledgement(settings.clock.nowInMs, totalBytesRead).write() - lastReadWindowAcknowledgementSize = totalBytesRead - } - } - - when (message) { - is Acknowledgement -> { - /** - * The server sends Acknowledgement messages to the client every - * `writeWindowAcknowledgementSize` bytes send. - * We don't do anything with this message. - */ - } - - is Audio -> { - throw NotImplementedError("Audio not supported") - } - - is CommandMessage -> { - when (val command = Command.read(message)) { - is Command.Result -> commandChannels.complete(command.transactionId, command) - is Command.Error -> commandChannels.completeExceptionally( - command.transactionId, command - ) - - is Command.OnStatus -> { - val amfObject = command.arguments[0] as AmfObject - amfObject["code"]?.let { - val code = (it as AmfString).value - if (code.startsWith(NetStreamCommand.PUBLISH)) { - if (code == NetStreamCommand.PUBLISH_START) { - commandChannels.complete(NetStreamCommand.PUBLISH, command) - } else { - commandChannels.completeExceptionally( - NetStreamCommand.PUBLISH, command - ) - } - } - } - } - - else -> Unit // Nothing to do - } - } - - is SetChunkSize -> { - readChunkSize = message.chunkSize - } - - is SetPeerBandwidth -> { - val windowAcknowledgementSize = WindowAcknowledgementSize( - settings.clock.nowInMs, settings.writeWindowAcknowledgementSize - ) - windowAcknowledgementSize.write() - } - - is UserControl -> { - when (message.eventType) { - UserControl.EventType.PING_REQUEST -> { - val pingResponse = UserControl( - settings.clock.nowInMs, - UserControl.EventType.PING_RESPONSE, - message.data - ) - pingResponse.write() - } - - else -> Unit // Nothing to do - } - } - - is Video -> { - throw NotImplementedError("Video not supported") - } - - is WindowAcknowledgementSize -> { - readWindowAcknowledgementSize = message.windowSize - } - - else -> { - throw IllegalArgumentException("Message $message not supported (type: ${message.messageType})") - } - } - } - - private suspend fun readMessage(): Message { - return connection.read { readChannel -> - messagesManager.getPreviousReadMessage { previousMessages -> - Message.read(readChannel, readChunkSize) { chunkStreamId -> - previousMessages[chunkStreamId] - } - } - } - } - - private suspend fun List.writeAmfMessages() { - val messages = map { it.createMessage(settings.amfVersion) } - messages.writeMessages() - } - - private suspend fun AmfMessage.write() { - val message = createMessage(settings.amfVersion) - message.write() - } - - private suspend fun Message.withTimeoutWriteIfNeeded() { - if (settings.enableTooLateFrameDrop) { - val timeoutInMs: Long = - settings.tooLateFrameDropTimeoutInMs - (settings.clock.nowInMs - timestamp) - withTimeout(timeoutInMs) { - this@withTimeoutWriteIfNeeded.write() - } - } else { - write() - } - } - - private suspend fun List.writeMessages() { - val chunks = mutableListOf() - forEach { message -> - messagesManager.getPreviousWrittenMessage(message) { previousMessage -> - chunks += message.createChunks(writeChunkSize, previousMessage) - } - } - val length = chunks.sumOf { it.size } - connection.write(length) { writeChannel -> - chunks.write(writeChannel) - } - } - - private suspend fun Message.write() { - messagesManager.getPreviousWrittenMessage(this) { previousMessage -> - val chunks = this.createChunks(writeChunkSize, previousMessage) - val length = chunks.sumOf { it.size } - connection.write(length) { writeChannel -> - chunks.write(writeChannel) - } - } - } - - companion object { - private const val TAG = "RtmpPublishClient" - } - - open class ConnectInformation( - flashVer: String = DEFAULT_FLASH_VER, - audioCodecs: List? = DEFAULT_AUDIO_CODECS, - videoCodecs: List? = DEFAULT_VIDEO_CODECS, - ) : RtmpClientConnectInformation(flashVer, audioCodecs, videoCodecs) { - /** - * The default instance of [ConnectInformation] - */ - companion object Default : ConnectInformation() - } - - /** - * RTMP settings for [RtmpClient]. - * - * @param enableTooLateFrameDrop enable dropping too late frames. Default is false. It will drop frames if they are are too late if set to true. If enable, make sure frame timestamps are on on the same clock as [clock]. - * @param tooLateFrameDropTimeoutInMs the timeout after which a frame will be dropped (from frame timestamps). Default is 3000ms. - */ - open class Settings( - writeChunkSize: Int = DEFAULT_CHUNK_SIZE, - writeWindowAcknowledgementSize: Int = Int.MAX_VALUE, - amfVersion: AmfVersion = AmfVersion.AMF0, - clock: RtmpClock = RtmpClock.Default(), - val enableTooLateFrameDrop: Boolean = false, - val tooLateFrameDropTimeoutInMs: Long = DEFAULT_TOO_LATE_FRAME_DROP_TIMEOUT_IN_MS - ) : RtmpClientSettings( - writeChunkSize, - writeWindowAcknowledgementSize, - amfVersion, - clock - ) { - /** - * The default instance of [Settings] - */ - companion object Default : Settings() { - const val DEFAULT_TOO_LATE_FRAME_DROP_TIMEOUT_IN_MS = 2000L // ms - } - } - - /** - * A factory that creates [RtmpClient]. - * @param settings the RTMP settings. By default it creates a configuration for a RTMP client. - */ - class Factory( - private val settings: Settings = Settings - ) { - /** - * Connects to the server. It establishes a TCP socket connection. You still have to execute - * [RtmpClient.connect] afterwards. - * - * @param url the RTMP url - * @return a [RtmpClient] - */ - fun create(url: String): RtmpClient { - return create(RtmpURLBuilder(url)) - } - - /** - * Connects to the server. It establishes a TCP socket connection. You still have to execute - * [RtmpClient.connect] afterwards. - * - * @param urlBuilder the RTMP url builder - * @return a [RtmpClient] - */ - fun create( - urlBuilder: URLBuilder - ): RtmpClient { - urlBuilder.validateRtmp() - - return if (urlBuilder.protocol.isTunneledRtmp) { - createTunneling(urlBuilder) - } else { - createTcpConnection(urlBuilder) - } - } - - /** - * Creates a RTMP client based on TCP (for `RTMP` and `RTMPS`). - * - * @param urlBuilder the RTMP url builder - * @param dispatcher the coroutine dispatcher to use. Default is [Dispatchers.IO] - * @param socketOptions the socket options to use. Default is empty. - * @return a [RtmpClient] - */ - fun createTcpConnection( - urlBuilder: URLBuilder, - dispatcher: CoroutineDispatcher = Dispatchers.IO, - socketOptions: SocketOptions.PeerSocketOptions.() -> Unit = {}, - ): RtmpClient { - urlBuilder.validateRtmp() - require(!urlBuilder.protocol.isTunneledRtmp) { "URL must not be tunneled" } - - val connection: IConnection = - TcpConnection(urlBuilder, dispatcher, socketOptions) - return RtmpClient(urlBuilder, connection, settings) - } - - /** - * Creates a RTMP client based on HTTP (for `RTMPT` and `RTMPTS`). - * - * @param urlBuilder the RTMP url builder - * @return a [RtmpClient] - */ - fun createTunneling( - urlBuilder: URLBuilder - ): RtmpClient { - urlBuilder.validateRtmp() - require(urlBuilder.protocol.isTunneledRtmp) { "URL must be tunneled" } - - val connection: IConnection = HttpConnection(urlBuilder) - return RtmpClient(urlBuilder, connection, settings) - } - } -} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RemoteServerException.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RemoteCommandException.kt similarity index 78% rename from rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RemoteServerException.kt rename to rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RemoteCommandException.kt index 44de4ce..3e05c34 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/client/RemoteServerException.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RemoteCommandException.kt @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.rtmp.client +package io.github.thibaultbee.krtmp.rtmp.connection import io.github.thibaultbee.krtmp.rtmp.messages.Command /** - * This exception is thrown when server returns an error. + * This exception is thrown when the remote device returns an error. * * @param message the detail message. * @param command the command send by the server. */ -class RemoteServerException(message: String, val command: Command) : Exception(message) \ No newline at end of file +class RemoteCommandException(message: String, val command: Command) : Exception(message) \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RtmpConnection.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RtmpConnection.kt new file mode 100644 index 0000000..ef6ed58 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RtmpConnection.kt @@ -0,0 +1,889 @@ +/* + * Copyright (C) 2024 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.connection + +import io.github.thibaultbee.krtmp.amf.AmfVersion +import io.github.thibaultbee.krtmp.amf.elements.containers.AmfObject +import io.github.thibaultbee.krtmp.amf.elements.primitives.AmfNumber +import io.github.thibaultbee.krtmp.amf.elements.primitives.AmfString +import io.github.thibaultbee.krtmp.common.logger.KrtmpLogger +import io.github.thibaultbee.krtmp.flv.sources.ByteArrayBackedRawSource +import io.github.thibaultbee.krtmp.flv.tags.FLVData +import io.github.thibaultbee.krtmp.flv.tags.FLVTag +import io.github.thibaultbee.krtmp.flv.tags.RawFLVTag +import io.github.thibaultbee.krtmp.flv.tags.audio.AudioData +import io.github.thibaultbee.krtmp.flv.tags.script.OnMetadata +import io.github.thibaultbee.krtmp.flv.tags.video.VideoData +import io.github.thibaultbee.krtmp.flv.util.FLVHeader +import io.github.thibaultbee.krtmp.rtmp.extensions.rtmpAppOrNull +import io.github.thibaultbee.krtmp.rtmp.extensions.rtmpStreamKey +import io.github.thibaultbee.krtmp.rtmp.extensions.rtmpTcUrl +import io.github.thibaultbee.krtmp.rtmp.extensions.write +import io.github.thibaultbee.krtmp.rtmp.messages.Acknowledgement +import io.github.thibaultbee.krtmp.rtmp.messages.AmfMessage +import io.github.thibaultbee.krtmp.rtmp.messages.Audio +import io.github.thibaultbee.krtmp.rtmp.messages.Command +import io.github.thibaultbee.krtmp.rtmp.messages.CommandCloseStream +import io.github.thibaultbee.krtmp.rtmp.messages.CommandConnect +import io.github.thibaultbee.krtmp.rtmp.messages.CommandCreateStream +import io.github.thibaultbee.krtmp.rtmp.messages.CommandDeleteStream +import io.github.thibaultbee.krtmp.rtmp.messages.CommandFCPublish +import io.github.thibaultbee.krtmp.rtmp.messages.CommandFCUnpublish +import io.github.thibaultbee.krtmp.rtmp.messages.CommandMessage +import io.github.thibaultbee.krtmp.rtmp.messages.CommandNetConnectionResult +import io.github.thibaultbee.krtmp.rtmp.messages.CommandPlay +import io.github.thibaultbee.krtmp.rtmp.messages.CommandPublish +import io.github.thibaultbee.krtmp.rtmp.messages.CommandReleaseStream +import io.github.thibaultbee.krtmp.rtmp.messages.DataAmf +import io.github.thibaultbee.krtmp.rtmp.messages.DataAmfMessage +import io.github.thibaultbee.krtmp.rtmp.messages.Message +import io.github.thibaultbee.krtmp.rtmp.messages.SetChunkSize +import io.github.thibaultbee.krtmp.rtmp.messages.SetDataFrame +import io.github.thibaultbee.krtmp.rtmp.messages.SetPeerBandwidth +import io.github.thibaultbee.krtmp.rtmp.messages.UserControl +import io.github.thibaultbee.krtmp.rtmp.messages.Video +import io.github.thibaultbee.krtmp.rtmp.messages.WindowAcknowledgementSize +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.Chunk +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObjectBuilder +import io.github.thibaultbee.krtmp.rtmp.messages.command.NetConnectionResultObject +import io.github.thibaultbee.krtmp.rtmp.messages.command.ObjectEncoding +import io.github.thibaultbee.krtmp.rtmp.messages.command.StreamPublishType +import io.github.thibaultbee.krtmp.rtmp.messages.createChunks +import io.github.thibaultbee.krtmp.rtmp.util.MessagesManager +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusCodePublish +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusLevelError +import io.github.thibaultbee.krtmp.rtmp.util.TransactionCommandCompletion +import io.github.thibaultbee.krtmp.rtmp.util.sockets.ISocket +import io.ktor.network.sockets.ASocket +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.Source +import kotlinx.io.readString + +/** + * A RTMP client to publish stream. + * + * The usage is: + * - Send RTMP connect command with [connect] + * - Send RTMP create stream command with [createStream] + * - Send RTMP publish command with [publish] + * + * - Send metadata and audio and video frames with [writeSetDataFrame] and [writeAudio] or [writeVideo]. + * + * - Close the connection with [close] + */ +internal class RtmpConnection internal constructor( + private val connection: ISocket, + val settings: RtmpSettings, + callbackFactory: RtmpConnectionCallback.Factory, + private val callbackDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : CoroutineScope by connection, ASocket by connection { + private val callback by lazy { callbackFactory.create(this) } + private val messagesManager = MessagesManager() + + private var _transactionId = 1L + private val transactionId: Long + get() = _transactionId++ + + /** + * The write chunk size. + * + * The default value is [RtmpSettings.DEFAULT_CHUNK_SIZE]. + * + * @return the write chunk size + * @see [setWriteChunkSize] + */ + var writeChunkSize: Int = settings.writeChunkSize + private set + var readChunkSize = RtmpSettings.DEFAULT_CHUNK_SIZE + private set + var readWindowAcknowledgementSize = Int.MAX_VALUE + private set + private var lastReadWindowAcknowledgementSize = Int.MAX_VALUE + + private val commandChannels = TransactionCommandCompletion() + + private var messageStreamId: Int? = null + + override val coroutineContext = connection.coroutineContext + + init { + // Launch coroutine to handle RTMP messages + connection.launch { + handleRtmpMessages() + } + } + + /** + * Whether the connection is closed. + */ + val isClosed: Boolean + get() = connection.isClosed + + /** + * Sets the write chunk size. + * + * The default value is [RtmpConfiguration.DEFAULT_CHUNK_SIZE]. + * + * @param chunkSize the write chunk size + */ + suspend fun setWriteChunkSize(chunkSize: Int) { + if (writeChunkSize != chunkSize) { + val setChunkSize = SetChunkSize(settings.clock.nowInMs, chunkSize) + writeMessage(setChunkSize) + } + } + + /** + * Writes a window acknowledgement size message with the given size. + * + * @param size the size of the window acknowledgement + */ + suspend fun writeWindowAcknowledgementSize(size: Int) { + val setWindowAcknowledgementSize = WindowAcknowledgementSize( + settings.clock.nowInMs, size + ) + writeMessage(setWindowAcknowledgementSize) + } + + /** + * Writes a SetPeerBandwidth message with the given size and type. + * + * @param size the size of the peer bandwidth + * @param type the type of the peer bandwidth limit + */ + suspend fun writeSetPeerBandwidth(size: Int, type: SetPeerBandwidth.LimitType) { + val setPeerBandwidth = SetPeerBandwidth( + settings.clock.nowInMs, size, type + ) + writeMessage(setPeerBandwidth) + } + + /** + * Writes a user control message with the given event type. + * + * @param eventType the type of the user control event + */ + suspend fun writeUserControl( + eventType: UserControl.EventType + ) { + val userControl = UserControl( + settings.clock.nowInMs, + eventType + ) + writeMessage(userControl) + } + + + /** + * Writes a user control message with the given event type and data. + * + * @param eventType the type of the user control event + * @param data the data to send with the user control event + */ + suspend fun writeUserControl( + eventType: UserControl.EventType, + data: Buffer + ) { + val userControl = UserControl( + settings.clock.nowInMs, + eventType, + data + ) + writeMessage(userControl) + } + + /** + * Replies to the connect request with the necessary parameters. + * + * @param windowAcknowledgementSize the size of the window acknowledgement + * @param peerBandwidth the peer bandwidth to set + * @param peerBandwidthType the type of the peer bandwidth limit + */ + suspend fun replyConnect( + windowAcknowledgementSize: Int, + peerBandwidth: Int, + peerBandwidthType: SetPeerBandwidth.LimitType + ) { + val setWindowAcknowledgementSize = WindowAcknowledgementSize( + settings.clock.nowInMs, windowAcknowledgementSize + ) + + val setPeerBandwidth = SetPeerBandwidth( + settings.clock.nowInMs, peerBandwidth, peerBandwidthType + ) + + val userControlStreamBegin = UserControl( + settings.clock.nowInMs, + UserControl.EventType.STREAM_BEGIN + ) + + val result = CommandNetConnectionResult( + settings.clock.nowInMs, + NetConnectionResultObject.default, + if (settings.amfVersion == AmfVersion.AMF0) { + ObjectEncoding.AMF0 + } else { + ObjectEncoding.AMF3 + } + ) + + writeMessages( + listOf( + setWindowAcknowledgementSize, + setPeerBandwidth, + userControlStreamBegin, + result.createMessage(amfVersion = settings.amfVersion) + ) + ) + } + + /** + * Connects to the server. + * + * @param block a block to configure the [ConnectObjectBuilder] + * @return the [Command.Result] send by the server + */ + suspend fun connect(block: ConnectObjectBuilder.() -> Unit = {}): Command.Result { + // Prepare connect object + val objectEncoding = if (settings.amfVersion == AmfVersion.AMF0) { + ObjectEncoding.AMF0 + } else { + ObjectEncoding.AMF3 + } + val connectObjectBuilder = ConnectObjectBuilder( + app = connection.urlBuilder.rtmpAppOrNull ?: "", + tcUrl = connection.urlBuilder.rtmpTcUrl, + objectEncoding = objectEncoding + ) + connectObjectBuilder.block() + + settings.clock.reset() + + val connectTransactionId = transactionId + val connectCommand = CommandConnect( + connectTransactionId, settings.clock.nowInMs, connectObjectBuilder.build() + ) + + try { + return writeAmfMessageWithResponse( + connectCommand, + connectTransactionId + ) as Command.Result + } catch (t: Throwable) { + throw IOException("Connect command failed", t) + } + } + + /** + * Creates a stream. + * + * @return the [Command.Result] send by the server + * + * @see [deleteStream] + */ + suspend fun createStream(): Command.Result { + val releaseStreamCommand = CommandReleaseStream( + transactionId, settings.clock.nowInMs, connection.urlBuilder.rtmpStreamKey + ) + + val fcPublishCommand = CommandFCPublish( + transactionId, settings.clock.nowInMs, connection.urlBuilder.rtmpStreamKey + ) + + val createStreamTransactionId = transactionId + val createStreamCommand = + CommandCreateStream(createStreamTransactionId, settings.clock.nowInMs) + + val result = try { + writeAmfMessagesWithResponse( + listOf( + releaseStreamCommand, + fcPublishCommand, + createStreamCommand + ), createStreamTransactionId + ) + } catch (t: Throwable) { + throw IOException("Create stream command failed", t) + } + messageStreamId = (result.arguments[0] as AmfNumber).value.toInt() + return result as Command.Result + } + + /** + * Publishes the stream. + * + * @param type the publish type + * @return the [Command.OnStatus] send by the server + */ + suspend fun publish(type: StreamPublishType = StreamPublishType.LIVE): Command.OnStatus { + val messageStreamId = requireNotNull(messageStreamId) { + "You must call createStream() before publish()" + } + + val publishTransactionId = transactionId + val publishCommand = CommandPublish( + messageStreamId, + publishTransactionId, + settings.clock.nowInMs, + connection.urlBuilder.rtmpStreamKey, + type + ) + + return try { + writeAmfMessageWithResponse( + publishCommand, + NetStreamOnStatusCodePublish + ) as Command.OnStatus + } catch (t: Throwable) { + throw IOException("Publish command failed", t) + } + } + + /** + * Plays the stream. + * + * @param streamName the name of the stream to play + */ + suspend fun play(streamName: String) { + val playCommand = CommandPlay( + settings.clock.nowInMs, + streamName + ) + + return try { + writeAmfMessage(playCommand) + } catch (t: Throwable) { + throw IOException("Play command failed", t) + } + } + + /** + * Deletes the stream. + * + * @see [createStream] + */ + suspend fun deleteStream() { + val messages = mutableListOf( + CommandFCUnpublish( + transactionId, settings.clock.nowInMs, connection.urlBuilder.rtmpStreamKey + ) + ) + messageStreamId?.let { + messages += CommandDeleteStream( + transactionId, settings.clock.nowInMs, it + ) + } + + writeAmfMessages(messages) + } + + /** + * Closes the connection and cleans up resources. + */ + override fun close() { + try { + val closeCommand = CommandCloseStream(transactionId, settings.clock.nowInMs) + runBlocking { + writeAmfMessage(closeCommand) + } + } catch (t: Throwable) { + KrtmpLogger.i(TAG, "Error sending close command: ${t.message}") + } + + try { + connection.close() + } finally { + commandChannels.completeAllExceptionally(CancellationException("")) + messagesManager.clear() + } + } + + /** + * Writes the SetDataFrame from [OnMetadata.Metadata]. + * It must be called after [publish] and before sending audio or video frames. + * + * Expected AMF format is the one set in [RtmpSettings.amfVersion]. + * + * @param metadata the on metadata to send + */ + suspend fun writeSetDataFrame(metadata: OnMetadata.Metadata) { + val messageStreamId = requireNotNull(messageStreamId) { + "You must call createStream() before publish()" + } + + val dataFrameDataAmf = SetDataFrame( + settings.clock.nowInMs, + messageStreamId, + metadata + ) + writeAmfMessage(dataFrameDataAmf) + } + + /** + * Writes the SetDataFrame from a [ByteArray]. + * It must be called after [publish] and before sending audio or video frames. + * + * Expected AMF format is the one set in [RtmpSettings.amfVersion]. + * + * @param onMetadata the on metadata to send + */ + suspend fun writeSetDataFrame(onMetadata: ByteArray) { + val messageStreamId = requireNotNull(messageStreamId) { + "You must call createStream() before publish()" + } + + val dataFrameDataAmf = SetDataFrame( + settings.amfVersion, + messageStreamId, + settings.clock.nowInMs, + ByteArrayBackedRawSource(onMetadata), + onMetadata.size + ) + return writeMessage(dataFrameDataAmf) + } + + /** + * Writes the SetDataFrame from a [Buffer]. + * It must be called after [publish] and before sending audio or video frames. + * + * Expected AMF format is the one set in [RtmpSettings.amfVersion]. + * + * @param onMetadata the on metadata to send + */ + suspend fun writeSetDataFrame(onMetadata: RawSource, size: Int) { + val messageStreamId = requireNotNull(messageStreamId) { + "You must call createStream() before publish()" + } + + val dataFrameDataAmf = SetDataFrame( + settings.amfVersion, + messageStreamId, + settings.clock.nowInMs, + onMetadata, + size + ) + return writeMessage(dataFrameDataAmf) + } + + /** + * Writes an audio frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param array the audio frame to write + */ + suspend fun writeAudio(timestamp: Int, array: ByteArray) = + writeAudio(timestamp, ByteArrayBackedRawSource(array), array.size) + + /** + * Writes a video frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param array the video frame to write + */ + suspend fun writeVideo(timestamp: Int, array: ByteArray) = + writeVideo(timestamp, ByteArrayBackedRawSource(array), array.size) + + /** + * Writes an audio frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param source the audio frame to write + */ + suspend fun writeAudio(timestamp: Int, source: RawSource, sourceSize: Int) { + val messageStreamId = requireNotNull(messageStreamId) { + "You must call createStream() before publish()" + } + + val audio = Audio(timestamp, messageStreamId, source, sourceSize) + return withTimeoutWriteIfNeeded(audio) + } + + /** + * Writes a video frame. + * + * The frame must be wrapped in a FLV body. + * + * @param timestamp the timestamp of the frame + * @param source the video frame to write + */ + suspend fun writeVideo(timestamp: Int, source: RawSource, sourceSize: Int) { + val messageStreamId = requireNotNull(messageStreamId) { + "You must call createStream() before publish()" + } + + val video = Video(timestamp, messageStreamId, source, sourceSize) + return withTimeoutWriteIfNeeded(video) + } + + private suspend fun writeAmfMessagesWithResponse( + amfMessages: List, + id: Any = transactionId + ): Command { + writeAmfMessages(amfMessages) + return commandChannels.waitForResponse(id) + } + + private suspend fun writeAmfMessages(amfMessages: List) { + val messages = amfMessages.map { it.createMessage(settings.amfVersion) } + writeMessages(messages) + } + + private suspend fun writeAmfMessageWithResponse( + amfMessage: AmfMessage, + id: Any = transactionId + ): Command { + val message = amfMessage.createMessage(settings.amfVersion) + return writeMessageWithResponse(message, id) + } + + suspend fun writeAmfMessage(amfMessage: AmfMessage) { + val message = amfMessage.createMessage(settings.amfVersion) + writeMessage(message) + } + + private suspend fun withTimeoutWriteIfNeeded(message: Message) { + if (settings.enableTooLateFrameDrop) { + val timeoutInMs: Long = + settings.tooLateFrameDropTimeoutInMs - (settings.clock.nowInMs - message.timestamp) + withTimeout(timeoutInMs) { + writeMessage(message) + } + } else { + writeMessage(message) + } + } + + private suspend fun writeMessages(messages: List) { + val chunks = mutableListOf() + messages.forEach { message -> + messagesManager.getPreviousWrittenMessage(message) { previousMessage -> + chunks += message.createChunks(writeChunkSize, previousMessage) + } + } + val length = chunks.sumOf { it.size.toLong() } + connection.write(length) { writeChannel -> + chunks.write(writeChannel) + } + } + + /** + * Writes a message to the connection and waits for a response. + * + * @param message the message to write + * @param id the transaction id to wait for the response + * @return the response command + */ + suspend fun writeMessageWithResponse(message: Message, id: Any = transactionId): Command { + writeMessage(message) + return commandChannels.waitForResponse(id) + } + + /** + * Writes the message to the connection. + * + * @param message the message to write + */ + suspend fun writeMessage(message: Message) { + messagesManager.getPreviousWrittenMessage(message) { previousMessage -> + val chunks = message.createChunks(writeChunkSize, previousMessage) + val length = chunks.sumOf { it.size.toLong() } + connection.write(length) { writeChannel -> + chunks.write(writeChannel) + } + } + } + + private suspend fun handleRtmpMessages() { + try { + while (isActive) { + handleMessages() + } + } catch (t: Throwable) { + commandChannels.completeAllExceptionally(t) + KrtmpLogger.i(TAG, "Failed to handle RTMP message: ${t.message}", t) + } finally { + try { + connection.close() + } finally { + messagesManager.clear() + } + } + } + + private suspend fun handleMessages() { + val message = readMessage().apply { + // Send Acknowledgement message if needed + val totalBytesRead = connection.totalBytesRead.toInt() + val readBytes = totalBytesRead - lastReadWindowAcknowledgementSize + if (readBytes >= readWindowAcknowledgementSize) { + writeMessage(Acknowledgement(settings.clock.nowInMs, totalBytesRead)) + lastReadWindowAcknowledgementSize = totalBytesRead + } + } + + when (message) { + is Acknowledgement -> { + /** + * The server sends Acknowledgement messages to the client every + * `writeWindowAcknowledgementSize` bytes send. + * We don't do anything with this message. + */ + } + + is CommandMessage -> { + handleCommandMessage(message) + } + + is SetChunkSize -> { + readChunkSize = message.chunkSize + } + + is SetPeerBandwidth -> { + val windowAcknowledgementSize = WindowAcknowledgementSize( + settings.clock.nowInMs, settings.writeWindowAcknowledgementSize + ) + writeMessage(windowAcknowledgementSize) + } + + is UserControl -> { + when (message.eventType) { + UserControl.EventType.PING_REQUEST -> { + val pingResponse = UserControl( + settings.clock.nowInMs, + UserControl.EventType.PING_RESPONSE, + message.data + ) + writeMessage(pingResponse) + } + + else -> Unit // Nothing to do + } + } + + is WindowAcknowledgementSize -> { + readWindowAcknowledgementSize = message.windowSize + } + + is DataAmfMessage -> { + handleDataMessage(message) + } + + else -> { + withContext(callbackDispatcher) { + callback.onMessage(message) + } + } + } + } + + private suspend fun handleCommandMessage(message: CommandMessage) { + when (val command = Command.read(message)) { + is Command.Result -> commandChannels.complete(command.transactionId, command) + is Command.Error -> commandChannels.completeExceptionally( + command.transactionId, command + ) + + is Command.OnStatus -> { + val amfObject = command.arguments[0] as AmfObject + + val code = (amfObject["code"]!! as AmfString).value + val commandType = code.substringBeforeLast('.') + + val level = (amfObject["level"]!! as AmfString).value + + if (level == NetStreamOnStatusLevelError) { + commandChannels.completeExceptionally( + commandType, command + ) + } else { + commandChannels.complete(commandType, command) + } + } + + else -> + withContext(callbackDispatcher) { callback.onCommand(command) } + } + } + + private suspend fun handleDataMessage(message: DataAmfMessage) { + withContext(callbackDispatcher) { + callback.onData(DataAmf.read(message)) + } + } + + private suspend fun readMessage(): Message { + return connection.read { readChannel -> + messagesManager.getPreviousReadMessage { previousMessages -> + Message.read(readChannel, readChunkSize) { chunkStreamId -> + previousMessages[chunkStreamId] + } + } + } + } + + companion object { + internal const val TAG = "MessageStreamer" + } + + override fun dispose() { + try { + close() + } catch (ignore: Throwable) { + } + } +} + + +/** + * Callback interface for the [RtmpConnection]. + */ +internal interface RtmpConnectionCallback { + suspend fun onCommand(command: Command) = Unit + suspend fun onData(data: DataAmf) = Unit + suspend fun onMessage(message: Message) = Unit + + interface Factory { + fun create(streamer: RtmpConnection): RtmpConnectionCallback + } +} + +/** + * Writes a raw audio, video or script frame from a [ByteArray]. + * + * The frame must be in the FLV format. + * + * Internally, it will parse the frame to extract the header and the body. + * It is not the most efficient way to write frames but it is convenient. + * + * @param array the frame to write + */ +internal suspend fun RtmpConnection.write(array: ByteArray) { + write(Buffer().apply { write(array) }) +} + +/** + * Writes a raw audio, video or script frame from a [Source]. + * + * The frame must be in the FLV format. + * + * @param source the frame to write + */ +internal suspend fun RtmpConnection.write(source: Source) { + /** + * Dropping FLV header that is not needed. It starts with 'F', 'L' and 'V'. + * Just check the first byte to simplify. + */ + val peek = source.peek() + val isHeader = try { + peek.readString(3) == "FLV" + } catch (t: Throwable) { + false + } + if (isHeader) { + // Skip header + FLVHeader.decode(source) + } + + source.readInt() // skip previous tag size + + val tag = RawFLVTag.decode(source) + when (tag.type) { + FLVTag.Type.AUDIO -> writeAudio(tag.timestampMs, tag.body, tag.bodySize) + FLVTag.Type.VIDEO -> writeVideo(tag.timestampMs, tag.body, tag.bodySize) + FLVTag.Type.SCRIPT -> writeSetDataFrame(tag.body, tag.bodySize) + } +} + +/** + * Writes a [FLVData]. + * + * @param timestampMs the timestamp of the frame in milliseconds + * @param data the frame to write + */ +internal suspend fun RtmpConnection.write(timestampMs: Int, data: FLVData) { + val rawSource = data.readRawSource(settings.amfVersion, false) + val size = data.getSize(settings.amfVersion) + + when (data) { + is AudioData -> writeAudio( + timestampMs, + rawSource, + size + ) + + is VideoData -> writeVideo( + timestampMs, + rawSource, + size + ) + + is OnMetadata -> { + writeSetDataFrame( + rawSource, + size + ) + } + + else -> throw IllegalArgumentException("Packet type ${data::class.simpleName} not supported") + } +} + +/** + * Writes a [FLVTag]. + * + * @param tag the FLV tag to write + */ +internal suspend fun RtmpConnection.write(tag: FLVTag) = + write(tag.timestampMs, tag.data) + +/** + * Writes a [RawFLVTag]. + * + * @param tag the FLV tag to write + */ +internal suspend fun RtmpConnection.write(tag: RawFLVTag) { + when (tag.type) { + FLVTag.Type.AUDIO -> { + writeAudio(tag.timestampMs, tag.body, tag.bodySize) + } + + FLVTag.Type.VIDEO -> { + writeVideo(tag.timestampMs, tag.body, tag.bodySize) + } + + FLVTag.Type.SCRIPT -> { + writeSetDataFrame(tag.body, tag.bodySize) + } + } +} + diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RtmpSettings.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RtmpSettings.kt new file mode 100644 index 0000000..f6b4518 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/connection/RtmpSettings.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.connection + +import io.github.thibaultbee.krtmp.amf.AmfVersion +import io.github.thibaultbee.krtmp.rtmp.RtmpConfiguration +import io.github.thibaultbee.krtmp.rtmp.util.RtmpClock + +/** + * This class contains configuration for RTMP client. + * + * @param writeChunkSize RTMP chunk size in bytes + * @param writeWindowAcknowledgementSize RTMP acknowledgement window size in bytes + * @param amfVersion AMF version + * @param clock Clock used to timestamp RTMP messages. You should use the same clock for your video and audio timestamps. + * @param enableTooLateFrameDrop enable dropping too late frames. Default is false. It will drop frames if they are are too late if set to true. If enable, make sure frame timestamps are on on the same clock as [clock]. + * @param tooLateFrameDropTimeoutInMs the timeout after which a frame will be dropped (from frame timestamps). Default is 3000ms. + */ +open class RtmpSettings( + val writeChunkSize: Int = DEFAULT_CHUNK_SIZE, + val writeWindowAcknowledgementSize: Int = Int.MAX_VALUE, + val amfVersion: AmfVersion = AmfVersion.AMF0, + val clock: RtmpClock = RtmpClock.Default(), + val enableTooLateFrameDrop: Boolean = false, + val tooLateFrameDropTimeoutInMs: Long = DEFAULT_TOO_LATE_FRAME_DROP_TIMEOUT_IN_MS +) { + /** + * The default instance of [RtmpSettings] + */ + companion object Default : RtmpSettings() { + const val DEFAULT_CHUNK_SIZE = RtmpConfiguration.DEFAULT_CHUNK_SIZE // bytes + const val DEFAULT_TOO_LATE_FRAME_DROP_TIMEOUT_IN_MS = 2000L // ms + } +} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/IConnectionExtensions.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/IConnectionExtensions.kt index efcfdb7..3da728f 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/IConnectionExtensions.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/IConnectionExtensions.kt @@ -15,9 +15,12 @@ */ package io.github.thibaultbee.krtmp.rtmp.extensions -import io.github.thibaultbee.krtmp.rtmp.Handshake +import io.github.thibaultbee.krtmp.rtmp.util.Handshake import io.github.thibaultbee.krtmp.rtmp.util.RtmpClock -import io.github.thibaultbee.krtmp.rtmp.util.connections.IConnection +import io.github.thibaultbee.krtmp.rtmp.util.sockets.ISocket -internal suspend fun IConnection.clientHandshake(clock: RtmpClock = RtmpClock.Default()) = - Handshake(this, clock = clock).startClient() \ No newline at end of file +internal suspend fun ISocket.clientHandshake(clock: RtmpClock = RtmpClock.Default()) = + Handshake(this, clock = clock).startClient() + +internal suspend fun ISocket.serverHandshake(clock: RtmpClock = RtmpClock.Default()) = + Handshake(this, clock = clock).starServer() \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/ListExtensions.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/ListExtensions.kt index 7ae9058..8618dff 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/ListExtensions.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/ListExtensions.kt @@ -1,6 +1,6 @@ package io.github.thibaultbee.krtmp.rtmp.extensions -import io.github.thibaultbee.krtmp.rtmp.chunk.Chunk +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.Chunk import io.ktor.utils.io.ByteWriteChannel internal fun List.orNull(): List? = ifEmpty { null } diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensions.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensions.kt index ebe8eb7..3e17a3e 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensions.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensions.kt @@ -17,14 +17,25 @@ package io.github.thibaultbee.krtmp.rtmp.extensions import io.ktor.http.URLBuilder +/** + * Validates the URLBuilder for RTMP protocol. + * + * @throws IllegalArgumentException if the URLBuilder is not valid for RTMP. + */ fun URLBuilder.validateRtmp() { require(protocol.isRtmp) { "Invalid protocol $protocol" } require(host.isNotBlank()) { "Invalid host $host" } require(port in 0..65535) { "Port must be in range 0..65535" } - require(pathSegments.size > 2) { "Invalid number of elements in path at least 2 but found ${pathSegments.size}" } - require(pathSegments.last().isNotBlank()) { "Invalid stream key ${pathSegments.last()}" } + require(pathSegments.size >= 2) { "Invalid number of elements in path at least 2 but found ${pathSegments.size}" } + val streamKey = pathSegments.last() + require(streamKey.isNotBlank()) { "Invalid stream key: $streamKey" } } +/** + * Whether the URLBuilder is valid for RTMP protocol. + * + * @return true if the URLBuilder is valid for RTMP, false otherwise. + */ val URLBuilder.isValidRtmp: Boolean get() { return try { @@ -35,12 +46,39 @@ val URLBuilder.isValidRtmp: Boolean } } +/** + * The stream key from the URLBuilder. + * + * @throws IllegalArgumentException if the URLBuilder is not valid for RTMP. + */ +val URLBuilder.rtmpStreamKey: String + get() { + validateRtmp() + return pathSegments.last() + } -val URLBuilder.streamKey: String + +/** + * The application name from the URLBuilder or null if not present. + * + * @throws IllegalArgumentException if the URLBuilder is not valid for RTMP. + */ +val URLBuilder.rtmpAppOrNull: String? get() { - val streamKey = pathSegments.last() - require(streamKey.isNotBlank()) { "Invalid stream key $streamKey" } - return streamKey + validateRtmp() + return if (pathSegments.size > 1) { + pathSegments[1] + } else { + null + } } +/** + * The RTMP URL without the stream key. + */ +val URLBuilder.rtmpTcUrl: String + get() { + validateRtmp() + return buildString().removeSuffix(rtmpStreamKey) + } diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Abort.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Abort.kt index 9fd45a0..639ff06 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Abort.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Abort.kt @@ -15,18 +15,28 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId import kotlinx.io.Buffer -internal fun Abort(timestamp: Int, payload: Buffer) = Abort(timestamp, payload.readInt()) +internal fun Abort(timestamp: Int, chunkStreamId: Int, payload: Buffer) = + Abort(timestamp, payload.readInt(), chunkStreamId) -internal class Abort(timestamp: Int, chunkStreamId: Int) : +internal class Abort( + timestamp: Int, + val discardedChunkStreamId: Int, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value +) : Message( - chunkStreamId = ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId = chunkStreamId, messageStreamId = MessageStreamId.PROTOCOL_CONTROL.value, timestamp = timestamp, messageType = MessageType.SET_CHUNK_SIZE, payload = Buffer().apply { - writeInt(chunkStreamId) + writeInt(discardedChunkStreamId) } - ) + ) { + + override fun toString(): String { + return "Abort(timestamp=$timestamp, discardedChunkStreamId=$discardedChunkStreamId, chunkStreamId=$chunkStreamId)" + } +} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Acknowledgement.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Acknowledgement.kt index 4175335..b486d7e 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Acknowledgement.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Acknowledgement.kt @@ -15,19 +15,27 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId import kotlinx.io.Buffer -internal fun Acknowledgement(timestamp: Int, payload: Buffer) = - Acknowledgement(timestamp, payload.readInt()) +internal fun Acknowledgement(timestamp: Int, chunkStreamId: Int, payload: Buffer) = + Acknowledgement(timestamp, payload.readInt(), chunkStreamId) -internal class Acknowledgement(timestamp: Int, sequenceNumber: Int) : +internal class Acknowledgement( + timestamp: Int, + val sequenceNumber: Int, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value +) : Message( - chunkStreamId = ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId = chunkStreamId, messageStreamId = MessageStreamId.PROTOCOL_CONTROL.value, timestamp = timestamp, messageType = MessageType.ACK, payload = Buffer().apply { writeInt(sequenceNumber) } - ) + ) { + override fun toString(): String { + return "Acknowledgement(timestamp=$timestamp, sequenceNumber=$sequenceNumber, chunkStreamId=$chunkStreamId)" + } +} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AmfMessage.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AmfMessage.kt index b434723..f8a2c02 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AmfMessage.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AmfMessage.kt @@ -19,7 +19,7 @@ import io.github.thibaultbee.krtmp.amf.AmfVersion import io.github.thibaultbee.krtmp.rtmp.RtmpConfiguration import io.ktor.utils.io.ByteWriteChannel -internal interface AmfMessage { +interface AmfMessage { fun createMessage(amfVersion: AmfVersion): Message suspend fun write( diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Audio.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Audio.kt index dad0776..c3a7b73 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Audio.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Audio.kt @@ -15,14 +15,84 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.flv.sources.ByteArrayBackedRawSource +import io.github.thibaultbee.krtmp.flv.tags.audio.AudioData +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId +import kotlinx.io.Buffer import kotlinx.io.RawSource +import kotlinx.io.buffered -internal class Audio(timestamp: Int, messageStreamId: Int, payload: RawSource) : +/** + * Creates an audio message with a [ByteArray] payload. + * + * @param timestamp The timestamp of the message. + * @param messageStreamId The stream ID of the message. + * @param payload The byte array containing the audio data. + * @param chunkStreamId The chunk stream ID for this message, defaulting to the audio channel. + */ +fun Audio( + timestamp: Int, + messageStreamId: Int, + payload: ByteArray, + chunkStreamId: Int = ChunkStreamId.AUDIO_CHANNEL.value +) = Audio( + timestamp = timestamp, + messageStreamId = messageStreamId, + payload = ByteArrayBackedRawSource(payload), + payloadSize = payload.size, + chunkStreamId = chunkStreamId +) + +/** + * Creates an audio message with a [Buffer] payload. + * + * @param timestamp The timestamp of the message. + * @param messageStreamId The stream ID of the message. + * @param payload The buffer containing the audio data. + * @param payloadSize The size of the payload in bytes. + * @param chunkStreamId The chunk stream ID for this message, defaulting to the audio channel. + */ +fun Audio( + timestamp: Int, + messageStreamId: Int, + payload: Buffer, + chunkStreamId: Int = ChunkStreamId.AUDIO_CHANNEL.value +) = Audio( + timestamp = timestamp, + messageStreamId = messageStreamId, + payload = payload, + payloadSize = payload.size.toInt(), + chunkStreamId = chunkStreamId +) + +/** + * An audio message in RTMP. + * + * @property timestamp The timestamp of the message. + * @property messageStreamId The stream ID of the message. + * @property payload The payload provider containing the audio data. + * @property chunkStreamId The chunk stream ID for this message, defaulting to the audio channel. + */ +class Audio internal constructor( + timestamp: Int, + messageStreamId: Int, + payload: RawSource, + payloadSize: Int, + chunkStreamId: Int = ChunkStreamId.AUDIO_CHANNEL.value +) : Message( - chunkStreamId = ChunkStreamId.AUDIO_CHANNEL.value, + chunkStreamId = chunkStreamId, messageStreamId = messageStreamId, timestamp = timestamp, messageType = MessageType.AUDIO, - payload = payload - ) + payload = payload, + payloadSize = payloadSize, + ) { + override fun toString(): String { + return "Audio(timestamp=$timestamp, messageStreamId=$messageStreamId, payload=$payload)" + } +} + + +fun Audio.decode() = + AudioData.decode(payload.buffered(), payloadSize, false) diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Command.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Command.kt index 35682e7..ce1f862 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Command.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Command.kt @@ -21,18 +21,31 @@ import io.github.thibaultbee.krtmp.amf.elements.Amf3ElementReader import io.github.thibaultbee.krtmp.amf.elements.AmfElement import io.github.thibaultbee.krtmp.amf.elements.AmfElementFactory import io.github.thibaultbee.krtmp.amf.elements.AmfPrimitive -import io.github.thibaultbee.krtmp.flv.models.config.AudioMediaType -import io.github.thibaultbee.krtmp.flv.models.config.VideoFourCC -import io.github.thibaultbee.krtmp.flv.models.config.VideoMediaType -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId -import io.github.thibaultbee.krtmp.rtmp.extensions.orNull +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_CLOSE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_CONNECT_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_CREATE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_DELETE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_FCPUBLISH_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_FCUNPUBLISH_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_ONFCPUBLISH_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_PLAY_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_PUBLISH_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_RELEASE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject +import io.github.thibaultbee.krtmp.rtmp.messages.command.NetConnectionResultInformation +import io.github.thibaultbee.krtmp.rtmp.messages.command.NetConnectionResultObject +import io.github.thibaultbee.krtmp.rtmp.messages.command.ObjectEncoding +import io.github.thibaultbee.krtmp.rtmp.messages.command.StreamPublishType import io.github.thibaultbee.krtmp.rtmp.util.AmfUtil.amf +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusCode +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusLevel import io.ktor.utils.io.ByteWriteChannel import kotlinx.io.Buffer import kotlinx.io.buffered import kotlinx.serialization.Serializable -class CommandMessage( +internal class CommandMessage( chunkStreamId: Int, messageStreamId: Int, timestamp: Int, @@ -87,27 +100,30 @@ open class Command( } override fun toString(): String { - return "Command($name, $transactionId, $commandObject, ${arguments.contentToString()})" + return "Command(timestamp=$timestamp, messageStreamId=$messageStreamId, name=$name, transactionId=$transactionId, commandObject=$commandObject, arguments=${arguments.joinToString()})" } companion object { - private const val COMMAND_RESULT_NAME = "_result" - private const val COMMAND_ERROR_NAME = "_error" + internal const val COMMAND_RESULT_NAME = "_result" + internal const val COMMAND_ERROR_NAME = "_error" - private const val COMMAND_ON_STATUS_NAME = "onStatus" + internal const val COMMAND_ON_STATUS_NAME = "onStatus" - private const val COMMAND_CONNECT_NAME = "connect" + internal const val COMMAND_CONNECT_NAME = "connect" - private const val COMMAND_CREATE_STREAM_NAME = "createStream" - private const val COMMAND_RELEASE_STREAM_NAME = "releaseStream" - private const val COMMAND_CLOSE_STREAM_NAME = "closeStream" - private const val COMMAND_DELETE_STREAM_NAME = "deleteStream" + internal const val COMMAND_CREATE_STREAM_NAME = "createStream" + internal const val COMMAND_RELEASE_STREAM_NAME = "releaseStream" + internal const val COMMAND_CLOSE_STREAM_NAME = "closeStream" + internal const val COMMAND_DELETE_STREAM_NAME = "deleteStream" - private const val COMMAND_FCPUBLISH_NAME = "FCPublish" - private const val COMMAND_FCUNPUBLISH_NAME = "FCUnpublish" - private const val COMMAND_PUBLISH_NAME = "publish" + internal const val COMMAND_PLAY_NAME = "play" - fun read( + internal const val COMMAND_FCPUBLISH_NAME = "FCPublish" + internal const val COMMAND_ONFCPUBLISH_NAME = "onFCPublish" + internal const val COMMAND_FCUNPUBLISH_NAME = "FCUnpublish" + internal const val COMMAND_PUBLISH_NAME = "publish" + + internal fun read( commandMessage: CommandMessage ): Command { val amfElementReader = when (commandMessage.messageType) { @@ -125,6 +141,10 @@ open class Command( if (!payload.exhausted()) (amfElementReader.read(payload) as AmfPrimitive).value.toLong() else 0 val commandObject = if (!payload.exhausted()) amfElementReader.read(payload) else null + val arguments = mutableListOf() + while (!payload.exhausted()) { + arguments.add(amfElementReader.read(payload)) + } return when (name) { COMMAND_RESULT_NAME -> { Result( @@ -132,7 +152,7 @@ open class Command( transactionId, commandMessage.timestamp, commandObject, - if (!payload.exhausted()) amfElementReader.read(payload) else null + arguments.firstOrNull() ) } @@ -142,7 +162,7 @@ open class Command( transactionId, commandMessage.timestamp, commandObject, - amfElementReader.read(payload) + arguments.firstOrNull() ) } @@ -151,8 +171,8 @@ open class Command( commandMessage.messageStreamId, transactionId, commandMessage.timestamp, - commandObject, - amfElementReader.read(payload) + arguments.firstOrNull() + ?: throw IllegalArgumentException("onStatus command must have exactly one argument") ) } @@ -163,7 +183,8 @@ open class Command( commandMessage.timestamp, name, transactionId, - commandObject + commandObject, + *arguments.toTypedArray() ) } } @@ -177,11 +198,12 @@ open class Command( messageStreamId: Int, transactionId: Long, timestamp: Int, - resultObject: AmfElement?, - informationObject: AmfElement? + resultObject: AmfElement? = null, + informationObject: AmfElement? = null, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value ) : Command( - ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId, messageStreamId, timestamp, COMMAND_RESULT_NAME, @@ -194,11 +216,12 @@ open class Command( messageStreamId: Int, transactionId: Long, timestamp: Int, - errorObject: AmfElement?, - informationObject: AmfElement + errorObject: AmfElement? = null, + informationObject: AmfElement? = null, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value ) : Command( - ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId, messageStreamId, timestamp, COMMAND_ERROR_NAME, @@ -211,24 +234,91 @@ open class Command( messageStreamId: Int, transactionId: Long, timestamp: Int, - onStatusObject: AmfElement?, - informationObject: AmfElement + information: NetStreamOnStatusInformation, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value ) : Command( - ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId, messageStreamId, timestamp, COMMAND_ON_STATUS_NAME, transactionId, - onStatusObject, - informationObject - ) + null, + amf.encodeToAmfElement( + NetStreamOnStatusInformation.serializer(), + information + ) + ) { + @Serializable + class NetStreamOnStatusInformation( + override val level: NetStreamOnStatusLevel, + override val code: NetStreamOnStatusCode, + override val description: String, + ) : ResultInformation + + companion object { + fun from( + messageStreamId: Int, + transactionId: Long, + timestamp: Int, + information: AmfElement + ) = OnStatus( + messageStreamId, + transactionId, + timestamp, + amf.decodeFromAmfElement(NetStreamOnStatusInformation.serializer(), information) + ) + } + } +} - class Connect( - transactionId: Long, - timestamp: Int, - connectObject: ConnectObject - ) : Command( +interface ResultInformation { + val level: String + val code: String + val description: String +} + +fun OnStatus( + messageStreamId: Int, + transactionId: Long, + timestamp: Int, + information: AmfElement +) = Command.OnStatus.from(messageStreamId, transactionId, timestamp, information) + +fun CommandNetConnectionResult( + timestamp: Int, + connectReplyObject: NetConnectionResultObject = NetConnectionResultObject.default, + objectEncoding: ObjectEncoding = ObjectEncoding.AMF0 +): Command.Result { + return Command.Result( + ChunkStreamId.PROTOCOL_CONTROL.value, + 1, + timestamp, + amf.encodeToAmfElement( + NetConnectionResultObject.serializer(), + connectReplyObject + ), + amf.encodeToAmfElement( + NetConnectionResultInformation.serializer(), + NetConnectionResultInformation( + level = "status", + code = "NetConnection.Connect.Success", + description = "Connection succeeded.", + objectEncoding = objectEncoding + ) + ), + ) +} + +fun CommandConnect( + transactionId: Long, + timestamp: Int, + connectObject: ConnectObject +): Command { + require(transactionId == 1L) { + "Transaction ID must be 1 for connect command, got $transactionId" + } + return Command( ChunkStreamId.PROTOCOL_CONTROL.value, MessageStreamId.PROTOCOL_CONTROL.value, timestamp, @@ -238,277 +328,125 @@ open class Command( ConnectObject.serializer(), connectObject ) - ) { - - init { - require(transactionId == 1L) { - "Transaction ID must be 1" - } - } - - /** - * @param app The server application name the client is connected to - * @param flashVer The flash Player version - * @param tcUrl The server IP address the client is connected to (format: rtmp://ip:port/app/instance) - * @param swfUrl The URL of the source SWF file - * @param fpad True if proxy is used - * @param audioCodecs The supported (by the client) audio codecs - * @param videoCodecs The supported (by the client) video codecs - * @param fourCcList The supported (by the client) video codecs (extended RTMP) - * @param pageUrl The URL of the web page in which the media was embedded - * @param objectEncoding The AMF encoding version - */ - @Serializable - class ConnectObject - private constructor( - val app: String, - val flashVer: String, - val tcUrl: String, - val swfUrl: String?, - val fpad: Boolean, - val capabilities: Double, - val audioCodecs: Double?, - val videoCodecs: Double?, - val fourCcList: List?, - val videoFunction: Double, - val pageUrl: String?, - val objectEncoding: Double - ) { - constructor( - app: String, - flashVer: String = DEFAULT_FLASH_VER, - tcUrl: String, - swfUrl: String? = null, - fpad: Boolean = false, - capabilities: Int = DEFAULT_CAPABILITIES, - audioCodecs: List? = DEFAULT_AUDIO_CODECS, - videoCodecs: List? = DEFAULT_VIDEO_CODECS, - videoFunction: List = DEFAULT_VIDEO_FUNCTION, - pageUrl: String? = null, - objectEncoding: ObjectEncoding = ObjectEncoding.AMF0 - ) : this( - app, - flashVer, - tcUrl, - swfUrl, - fpad, - capabilities.toDouble(), - audioCodecs?.filter { AudioCodec.isSupportedCodec(it) }?.map { - AudioCodec.fromMediaTypes(listOf(it)) - }?.fold(0) { acc, audioCodec -> - acc or audioCodec.value - }?.toDouble(), - videoCodecs?.filter { VideoCodec.isSupportedCodec(it) }?.map { - VideoCodec.fromMimeType(it) - }?.fold(0) { acc, videoCodec -> - acc or videoCodec.value - }?.toDouble(), - videoCodecs?.filter { ExVideoCodec.isSupportedCodec(it) }?.map { - ExVideoCodec.fromMediaType(it).value.toString() - }?.orNull(), - videoFunction.fold(0) { acc, vFunction -> - acc or vFunction.value - }.toDouble(), - pageUrl, - objectEncoding.value.toDouble() - ) - - companion object { - internal const val DEFAULT_FLASH_VER = "FMLE/3.0 (compatible; FMSc/1.0)" - internal const val DEFAULT_CAPABILITIES = 239 - internal val DEFAULT_VIDEO_FUNCTION = emptyList() - internal val DEFAULT_AUDIO_CODECS = listOf( - AudioMediaType.AAC, AudioMediaType.G711_ALAW, AudioMediaType.G711_MLAW - ) - internal val DEFAULT_VIDEO_CODECS = listOf( - VideoMediaType.SORENSON_H263, VideoMediaType.AVC - ) - } - } - - enum class AudioCodec(val value: Int, val mediaType: AudioMediaType?) { - NONE(0x0001, null), - ADPCM(0x0002, AudioMediaType.ADPCM), - MP3(0x0004, AudioMediaType.MP3), - INTEL(0x0008, null), - UNUSED(0x0010, null), - NELLY8(0x0020, AudioMediaType.NELLYMOSER_8KHZ), - NELLY(0x0040, AudioMediaType.NELLYMOSER), - G711A(0x0080, AudioMediaType.G711_ALAW), - G711U(0x0100, AudioMediaType.G711_MLAW), - NELLY16(0x0200, AudioMediaType.NELLYMOSER_16KHZ), - AAC(0x0400, AudioMediaType.AAC), - SPEEX(0x0800, AudioMediaType.SPEEX); - - companion object { - fun isSupportedCodec(mediaType: AudioMediaType): Boolean { - return entries.any { it.mediaType == mediaType } - } - - fun fromMediaTypes(mediaTypes: List): AudioCodec { - return entries.firstOrNull { it.mediaType in mediaTypes } - ?: throw IllegalArgumentException("Unsupported codec: $mediaTypes") - } - } - } - - enum class VideoCodec(val value: Int, val mediaType: VideoMediaType?) { - UNUSED(0x01, null), - JPEG(0x02, null), - SORENSON(0x04, VideoMediaType.SORENSON_H263), - HOMEBREW(0x08, null), - VP6(0x10, VideoMediaType.VP6), - VP6_ALPHA(0x20, VideoMediaType.VP6_ALPHA), - HOMEBREWV(0x40, null), - H264(0x80, VideoMediaType.AVC); - - companion object { - fun isSupportedCodec(mediaType: VideoMediaType): Boolean { - return entries.any { it.mediaType == mediaType } - } - - fun fromMimeType(mediaType: VideoMediaType): VideoCodec { - return entries.firstOrNull { it.mediaType == mediaType } - ?: throw IllegalArgumentException("Unsupported codec: $mediaType") - } - } - } - - class ExVideoCodec { - companion object { - private val supportedCodecs = listOf( - VideoMediaType.VP9, VideoMediaType.HEVC, VideoMediaType.AV1 - ) - - fun isSupportedCodec(mediaType: VideoMediaType): Boolean { - return supportedCodecs.contains(mediaType) - } - - fun fromMediaType(mediaType: VideoMediaType): VideoFourCC { - if (!isSupportedCodec(mediaType)) { - throw IllegalArgumentException("Unsupported codec: $mediaType") - } - return mediaType.fourCCs ?: throw IllegalArgumentException( - "Unsupported codec: $mediaType" - ) - } - } - } - - enum class VideoFunction(val value: Int) { - CLIENT_SEEK(0x1), - - // Enhanced RTMP v1 - CLIENT_HDR(0x2), - CLIENT_PACKET_TYPE_METADATA(0x4), - CLIENT_LARGE_SCALE_TILE(0x8), - } - - enum class ObjectEncoding(val value: Int) { - AMF0(0), - AMF3(3) - } - } - - class CreateStream(transactionId: Long, timestamp: Int) : - Command( - ChunkStreamId.PROTOCOL_CONTROL.value, - MessageStreamId.PROTOCOL_CONTROL.value, - timestamp, - COMMAND_CREATE_STREAM_NAME, - transactionId, - null - ) - + ) +} - class ReleaseStream( - transactionId: Long, - timestamp: Int, - streamKey: String - ) : - Command( - ChunkStreamId.PROTOCOL_CONTROL.value, - MessageStreamId.PROTOCOL_CONTROL.value, - timestamp, - COMMAND_RELEASE_STREAM_NAME, - transactionId, - null, - streamKey - ) +fun CommandCreateStream(transactionId: Long, timestamp: Int) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, + timestamp, + COMMAND_CREATE_STREAM_NAME, + transactionId, + null + ) - class CloseStream(transactionId: Long, timestamp: Int) : - Command( - ChunkStreamId.PROTOCOL_CONTROL.value, - MessageStreamId.PROTOCOL_CONTROL.value, - timestamp, - COMMAND_CLOSE_STREAM_NAME, - transactionId, - null - ) - class DeleteStream(transactionId: Long, timestamp: Int, streamKey: String) : - Command( - ChunkStreamId.PROTOCOL_CONTROL.value, - MessageStreamId.PROTOCOL_CONTROL.value, - timestamp, - COMMAND_DELETE_STREAM_NAME, - transactionId, - null, - streamKey - ) +fun CommandReleaseStream( + transactionId: Long, + timestamp: Int, + streamKey: String +) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, + timestamp, + COMMAND_RELEASE_STREAM_NAME, + transactionId, + null, + streamKey + ) - class FCPublish( - transactionId: Long, - timestamp: Int, - streamKey: String - ) : - Command( - ChunkStreamId.PROTOCOL_CONTROL.value, - MessageStreamId.PROTOCOL_CONTROL.value, - timestamp, - COMMAND_FCPUBLISH_NAME, - transactionId, +fun CommandCloseStream(transactionId: Long, timestamp: Int) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, + timestamp, + COMMAND_CLOSE_STREAM_NAME, + transactionId, + null + ) - null, - streamKey - ) +fun CommandDeleteStream(transactionId: Long, timestamp: Int, streamId: Int) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, + timestamp, + COMMAND_DELETE_STREAM_NAME, + transactionId, + null, + streamId.toDouble() + ) - class FCUnpublish( - messageStreamId: Int, - transactionId: Long, - timestamp: Int, - streamKey: String - ) : - Command( - ChunkStreamId.PROTOCOL_CONTROL.value, - messageStreamId, - timestamp, - COMMAND_FCUNPUBLISH_NAME, - transactionId, - null, - streamKey - ) +fun CommandPlay(timestamp: Int, streamName: String) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, + timestamp, + COMMAND_PLAY_NAME, + 0, + null, + streamName + ) +fun CommandFCPublish( + transactionId: Long, + timestamp: Int, + streamKey: String +) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, + timestamp, + COMMAND_FCPUBLISH_NAME, + transactionId, + null, + streamKey + ) + +fun CommandOnFCPublish( + transactionId: Long, + timestamp: Int +) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, + timestamp, + COMMAND_ONFCPUBLISH_NAME, + transactionId, + null, + null + ) - class Publish( - messageStreamId: Int, - transactionId: Long, - timestamp: Int, - streamKey: String, - streamType: Type - ) : Command( - ChunkStreamId.COMMAND_CHANNEL.value, - messageStreamId, +fun CommandFCUnpublish( + transactionId: Long, + timestamp: Int, + streamKey: String +) = + Command( + ChunkStreamId.PROTOCOL_CONTROL.value, + MessageStreamId.PROTOCOL_CONTROL.value, timestamp, - COMMAND_PUBLISH_NAME, + COMMAND_FCUNPUBLISH_NAME, transactionId, null, - streamKey, - streamType.value - ) { + streamKey + ) - enum class Type(val value: String) { - LIVE("live"), RECORD("record"), APPEND("append") - } - } -} + +fun CommandPublish( + messageStreamId: Int, + transactionId: Long, + timestamp: Int, + streamKey: String, + streamType: StreamPublishType +) = Command( + ChunkStreamId.COMMAND_CHANNEL.value, + messageStreamId, + timestamp, + COMMAND_PUBLISH_NAME, + transactionId, + null, + streamKey, + streamType.value +) diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/DataAmf.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/DataAmf.kt index 296fbf6..df890c6 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/DataAmf.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/DataAmf.kt @@ -15,45 +15,52 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.amf.Amf import io.github.thibaultbee.krtmp.amf.AmfVersion +import io.github.thibaultbee.krtmp.amf.elements.Amf0ElementReader +import io.github.thibaultbee.krtmp.amf.elements.Amf3ElementReader import io.github.thibaultbee.krtmp.amf.elements.AmfElement import io.github.thibaultbee.krtmp.amf.elements.AmfElementFactory +import io.github.thibaultbee.krtmp.amf.elements.AmfPrimitive import io.github.thibaultbee.krtmp.amf.elements.containers.amfContainerOf -import io.github.thibaultbee.krtmp.amf.elements.containers.amfEcmaArrayOf -import io.github.thibaultbee.krtmp.amf.elements.containers.AmfObject -import io.github.thibaultbee.krtmp.flv.models.tags.OnMetadata -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.flv.tags.script.OnMetadata +import io.github.thibaultbee.krtmp.rtmp.messages.DataAmf.Companion.DATAAMF_SET_DATA_FRAME_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId import io.ktor.utils.io.ByteWriteChannel import kotlinx.io.RawSource +import kotlinx.io.buffered internal open class DataAmfMessage( messageStreamId: Int, timestamp: Int, messageType: MessageType, - payload: RawSource + payload: RawSource, + payloadSize: Int, + chunkStreamId: Int = ChunkStreamId.COMMAND_CHANNEL.value ) : Message( - chunkStreamId = ChunkStreamId.COMMAND_CHANNEL.value, + chunkStreamId = chunkStreamId, messageStreamId = messageStreamId, timestamp = timestamp, messageType = messageType, - payload = payload -) { - class SetDataFrame( - amfVersion: AmfVersion, - messageStreamId: Int, - timestamp: Int, - payload: RawSource - ) : - DataAmfMessage( - messageStreamId, - timestamp, - if (amfVersion == AmfVersion.AMF0) MessageType.DATA_AMF0 else MessageType.DATA_AMF3, - payload - ) -} + payload = payload, + payloadSize = payloadSize +) -internal open class DataAmf( +internal fun SetDataFrame( + amfVersion: AmfVersion, + messageStreamId: Int, + timestamp: Int, + payload: RawSource, + payloadSize: Int +) = + DataAmfMessage( + messageStreamId, + timestamp, + if (amfVersion == AmfVersion.AMF0) MessageType.DATA_AMF0 else MessageType.DATA_AMF3, + payload, + payloadSize + ) + +open class DataAmf( val messageStreamId: Int, val timestamp: Int, val name: String, @@ -75,7 +82,8 @@ internal open class DataAmf( messageStreamId = messageStreamId, timestamp = timestamp, messageType = messageType, - payload = payload.write(amfVersion) + payload = payload.write(amfVersion), + payloadSize = payload.getSize(amfVersion), ) } @@ -92,26 +100,58 @@ internal open class DataAmf( return "DataAmf(name=$name, timestamp=$timestamp, messageStreamId=$messageStreamId, parameters=$parameters)" } - class SetDataFrame( - messageStreamId: Int, - timestamp: Int, - metadata: OnMetadata.Metadata, - ) : - DataAmf( - messageStreamId, - timestamp, - "@setDataFrame", - amfContainerOf( - listOf( - "onMetaData", - // Swapping elements to ECMA array - amfEcmaArrayOf( - (Amf.encodeToAmfElement( - OnMetadata.Metadata.serializer(), - metadata - ) as AmfObject) + companion object { + const val DATAAMF_SET_DATA_FRAME_NAME = "@setDataFrame" + + internal fun read( + dataAmfMessage: DataAmfMessage + ): DataAmf { + val amfElementReader = when (dataAmfMessage.messageType) { + MessageType.DATA_AMF0 -> Amf0ElementReader + MessageType.DATA_AMF3 -> Amf3ElementReader + else -> throw IllegalArgumentException("Unknown message type: ${dataAmfMessage.messageType}") + } + val payload = dataAmfMessage.payload.buffered() + + @Suppress("UNCHECKED_CAST") + val name = (amfElementReader.read(payload) as AmfPrimitive).value + val parameter = + if (!payload.exhausted()) amfElementReader.read(payload) else null + val parameterContent = + if (!payload.exhausted()) amfElementReader.read(payload) else null + val parameters = if (parameterContent != null) { + amfContainerOf( + listOf( + parameter!!, + parameterContent ) ) + } else { + parameter + } + return DataAmf( + dataAmfMessage.messageStreamId, + dataAmfMessage.timestamp, + name, + parameters ) - ) + } + } } + +fun SetDataFrame( + messageStreamId: Int, + timestamp: Int, + metadata: OnMetadata.Metadata, +) = + DataAmf( + messageStreamId, + timestamp, + DATAAMF_SET_DATA_FRAME_NAME, + amfContainerOf( + listOf( + "onMetaData", + metadata.encode() + ) + ) + ) \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Message.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Message.kt index 16b0fe7..b65e61f 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Message.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Message.kt @@ -15,14 +15,17 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.common.logger.Logger +import io.github.thibaultbee.krtmp.common.logger.KrtmpLogger +import io.github.thibaultbee.krtmp.flv.sources.RawSourceWithSize import io.github.thibaultbee.krtmp.rtmp.RtmpConfiguration -import io.github.thibaultbee.krtmp.rtmp.chunk.Chunk -import io.github.thibaultbee.krtmp.rtmp.chunk.MessageHeader -import io.github.thibaultbee.krtmp.rtmp.chunk.MessageHeader0 -import io.github.thibaultbee.krtmp.rtmp.chunk.MessageHeader1 -import io.github.thibaultbee.krtmp.rtmp.chunk.MessageHeader2 -import io.github.thibaultbee.krtmp.rtmp.chunk.MessageHeader3 +import io.github.thibaultbee.krtmp.rtmp.extensions.readFully +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.BasicHeader +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.Chunk +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.MessageHeader +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.MessageHeader0 +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.MessageHeader1 +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.MessageHeader2 +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.MessageHeader3 import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteWriteChannel import kotlinx.io.Buffer @@ -35,70 +38,31 @@ sealed class Message( val timestamp: Int, val messageType: MessageType, val payload: RawSource, + val payloadSize: Int ) { - var payloadSize: Int = 0 + private var readPayloadSize: Int = 0 init { require(timestamp >= 0) { "Timestamp must be positive but $timestamp" } } - private fun buildHeader0(): MessageHeader0 { - return MessageHeader0( - timestamp = timestamp, - messageLength = payloadSize, - messageType = messageType, - messageStreamId = messageStreamId - ) - } - - private fun buildFirstHeader(previousMessage: Message?): MessageHeader { - return if (previousMessage == null) { - buildHeader0() - } else { - if (previousMessage.timestamp > timestamp) { - Logger.w( - TAG, - "Timestamps are not in order. Previous: ${previousMessage.timestamp}, current: $timestamp" - ) - buildHeader0() // Force header 0 when timestamp are not in order - } else - if (previousMessage.messageStreamId == messageStreamId) { - if ((previousMessage.messageType == messageType) && (previousMessage.payloadSize == payloadSize)) { - MessageHeader2(timestampDelta = timestamp - previousMessage.timestamp) - } else { - MessageHeader1( - timestampDelta = timestamp - previousMessage.timestamp, - messageLength = payloadSize, - messageType = messageType - ) - } - } else { - buildHeader0() - } - } - } - - /** - * Creates chunks from message payload. - */ - internal fun createChunks(chunkSize: Int, previousMessage: Message?): List { - val firstBuffer = Buffer() - payloadSize += payload.readAtMostTo(firstBuffer, chunkSize.toLong()).toInt() - val chunks = mutableListOf() - - do { - val buffer = Buffer() - val bytesRead = payload.readAtMostTo(buffer, chunkSize.toLong()).toInt() - if (bytesRead > 0) { - chunks.add(Chunk(chunkStreamId, MessageHeader3(), buffer)) - payloadSize += bytesRead - } - } while (bytesRead > 0) - - val header = buildFirstHeader(previousMessage) - chunks.add(0, Chunk(chunkStreamId, header, firstBuffer)) - - return chunks + constructor( + chunkStreamId: Int, + messageStreamId: Int, + timestamp: Int, + messageType: MessageType, + payload: Buffer + ) : this( + chunkStreamId = chunkStreamId, + messageStreamId = messageStreamId, + timestamp = timestamp, + messageType = messageType, + payload = payload, + payloadSize = payload.size.toInt() + ) + + override fun toString(): String { + return "Message(chunkStreamId=$chunkStreamId, messageStreamId=$messageStreamId, timestamp=$timestamp, messageType=$messageType, payloadSize=$payloadSize)" } internal suspend fun write( @@ -112,8 +76,6 @@ sealed class Message( } companion object { - private val TAG = "Message" - /** * Reads a message from input stream. * For test purpose only. @@ -133,52 +95,60 @@ sealed class Message( getPreviousMessage: suspend (Int) -> Message? ): Message { val payload = Buffer() - val firstChunk = Chunk.read(channel, chunkSize, payload) - val previousMessage = getPreviousMessage(firstChunk.basicHeader.chunkStreamId.toInt()) + // Read first chunk + val basicHeader = BasicHeader.read(channel) - val messageLength = when (firstChunk.messageHeader) { - is MessageHeader0 -> firstChunk.messageHeader.messageLength - is MessageHeader1 -> firstChunk.messageHeader.messageLength + val chunkStreamId = basicHeader.chunkStreamId.toInt() + val previousMessage = getPreviousMessage(chunkStreamId) + + val messageHeader = MessageHeader.read(channel, basicHeader.headerType) + val messageLength = when (messageHeader) { + is MessageHeader0 -> messageHeader.messageLength + is MessageHeader1 -> messageHeader.messageLength is MessageHeader2 -> previousMessage?.payloadSize - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header2: Previous message with $chunkStreamId must not be null") is MessageHeader3 -> previousMessage?.payloadSize - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header3: Previous message with $chunkStreamId must not be null") } + require(messageLength > 0) { "Message length must be greater than 0 but is $messageLength" } + + channel.readFully(payload, min(messageLength, chunkSize)) + val firstChunk = Chunk(basicHeader, messageHeader, payload) val messageType = when (firstChunk.messageHeader) { is MessageHeader0 -> firstChunk.messageHeader.messageType is MessageHeader1 -> firstChunk.messageHeader.messageType is MessageHeader2 -> previousMessage?.messageType - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header2: Previous message with $chunkStreamId must not be null") is MessageHeader3 -> previousMessage?.messageType - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header3: Previous message with $chunkStreamId must not be null") } val messageStreamId = when (firstChunk.messageHeader) { is MessageHeader0 -> firstChunk.messageHeader.messageStreamId is MessageHeader1 -> previousMessage?.messageStreamId - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header1: Previous message with $chunkStreamId must not be null") is MessageHeader2 -> previousMessage?.messageStreamId - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header2: Previous message with $chunkStreamId must not be null") is MessageHeader3 -> previousMessage?.messageStreamId - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header3: Previous message with $chunkStreamId must not be null") } val timestamp = when (firstChunk.messageHeader) { is MessageHeader0 -> firstChunk.messageHeader.timestamp is MessageHeader1 -> previousMessage?.timestamp?.plus(firstChunk.messageHeader.timestampDelta) - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header1: Previous message with $chunkStreamId must not be null") is MessageHeader2 -> previousMessage?.timestamp?.plus(firstChunk.messageHeader.timestampDelta) - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header2: Previous message with $chunkStreamId must not be null") is MessageHeader3 -> previousMessage?.timestamp - ?: throw IllegalArgumentException("Previous message must not be null") + ?: throw IllegalArgumentException("Header3: Previous message with $chunkStreamId must not be null") } while (payload.size < messageLength) { @@ -191,47 +161,151 @@ sealed class Message( return when (messageType) { MessageType.SET_CHUNK_SIZE -> SetChunkSize( + chunkStreamId = chunkStreamId, timestamp = timestamp, payload = payload ) MessageType.ABORT -> Abort( + chunkStreamId = chunkStreamId, timestamp = timestamp, payload = payload ) MessageType.ACK -> Acknowledgement( + chunkStreamId = chunkStreamId, timestamp = timestamp, payload = payload ) MessageType.USER_CONTROL -> UserControl( + chunkStreamId = chunkStreamId, timestamp = timestamp, payload = payload ) MessageType.WINDOW_ACK_SIZE -> WindowAcknowledgementSize( + chunkStreamId = chunkStreamId, timestamp = timestamp, payload = payload ) MessageType.SET_PEER_BANDWIDTH -> SetPeerBandwidth( + chunkStreamId = chunkStreamId, timestamp = timestamp, payload = payload ) MessageType.COMMAND_AMF0, MessageType.COMMAND_AMF3 -> CommandMessage( - chunkStreamId = firstChunk.basicHeader.chunkStreamId.toInt(), + chunkStreamId = chunkStreamId, messageStreamId = messageStreamId, timestamp = timestamp, messageType = messageType, payload = payload ) + MessageType.DATA_AMF0, MessageType.DATA_AMF3 -> DataAmfMessage( + chunkStreamId = chunkStreamId, + messageStreamId = messageStreamId, + timestamp = timestamp, + messageType = messageType, + payload = payload, + payloadSize = payload.size.toInt() + ) + + MessageType.VIDEO -> Video( + chunkStreamId = chunkStreamId, + messageStreamId = messageStreamId, + timestamp = timestamp, + payload = payload, + payloadSize = payload.size.toInt() + ) + + MessageType.AUDIO -> Audio( + chunkStreamId = chunkStreamId, + messageStreamId = messageStreamId, + timestamp = timestamp, + payload = payload, + payloadSize = payload.size.toInt() + ) + else -> { throw IllegalArgumentException("Message type $messageType not supported") } } } } -} \ No newline at end of file +} + + +private fun Message.buildHeader0(): MessageHeader0 { + return MessageHeader0( + timestamp = timestamp, + messageLength = payloadSize, + messageType = messageType, + messageStreamId = messageStreamId + ) +} + +private fun Message.buildFirstHeader(previousMessage: Message?): MessageHeader { + return if (previousMessage == null) { + buildHeader0() + } else { + if (previousMessage.timestamp > timestamp) { + KrtmpLogger.w( + TAG, + "Timestamps are not in order. Previous: ${previousMessage.timestamp}, current: $timestamp" + ) + buildHeader0() // Force header 0 when timestamp are not in order + } else + if (previousMessage.messageStreamId == messageStreamId) { + if ((previousMessage.messageType == messageType) && (previousMessage.payloadSize == payloadSize)) { + MessageHeader2(timestampDelta = timestamp - previousMessage.timestamp) + } else { + MessageHeader1( + timestampDelta = timestamp - previousMessage.timestamp, + messageLength = payloadSize, + messageType = messageType + ) + } + } else { + buildHeader0() + } + } +} + +/** + * Creates chunks from message payload. + */ +internal fun Message.createChunks(chunkSize: Int, previousMessage: Message?): List { + val chunks = mutableListOf() + + val header = buildFirstHeader(previousMessage) + val firstChunkPayloadSize = minOf(chunkSize, payloadSize) + chunks.add( + Chunk( + chunkStreamId, + header, + RawSourceWithSize(payload, firstChunkPayloadSize.toLong()), + firstChunkPayloadSize + ) + ) + + var remainingSize = payloadSize - firstChunkPayloadSize + while (remainingSize > 0) { + val chunkPayloadSize = minOf(chunkSize, remainingSize) + chunks.add( + Chunk( + chunkStreamId, + MessageHeader3(), + RawSourceWithSize(payload, chunkPayloadSize.toLong()), + chunkPayloadSize + ) + ) + remainingSize -= chunkPayloadSize + } + + return chunks +} + +private const val TAG = "Message" \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetChunkSize.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetChunkSize.kt index a03c3fc..623ad06 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetChunkSize.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetChunkSize.kt @@ -15,14 +15,19 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId import kotlinx.io.Buffer -internal fun SetChunkSize(timestamp: Int, payload: Buffer) = SetChunkSize(timestamp, payload.readInt()) +internal fun SetChunkSize(timestamp: Int, chunkStreamId: Int, payload: Buffer) = + SetChunkSize(timestamp, payload.readInt(), chunkStreamId) -internal class SetChunkSize(timestamp: Int, val chunkSize: Int) : +internal class SetChunkSize( + timestamp: Int, + val chunkSize: Int, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value +) : Message( - chunkStreamId = ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId = chunkStreamId, messageStreamId = MessageStreamId.PROTOCOL_CONTROL.value, timestamp = timestamp, messageType = MessageType.SET_CHUNK_SIZE, diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetPeerBandwidth.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetPeerBandwidth.kt index 1a4a2eb..b142d25 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetPeerBandwidth.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/SetPeerBandwidth.kt @@ -15,19 +15,29 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId import kotlinx.io.Buffer -internal fun SetPeerBandwidth(timestamp: Int, payload: Buffer): SetPeerBandwidth = +internal fun SetPeerBandwidth( + timestamp: Int, + chunkStreamId: Int, + payload: Buffer +): SetPeerBandwidth = SetPeerBandwidth( timestamp, payload.readInt(), - SetPeerBandwidth.LimitType.from(payload.readByte()) + SetPeerBandwidth.LimitType.from(payload.readByte()), + chunkStreamId ) -internal class SetPeerBandwidth(timestamp: Int, windowSize: Int, limitType: LimitType) : +internal class SetPeerBandwidth( + timestamp: Int, + windowSize: Int, + limitType: LimitType, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value +) : Message( - chunkStreamId = ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId = chunkStreamId, messageStreamId = MessageStreamId.PROTOCOL_CONTROL.value, timestamp = timestamp, messageType = MessageType.SET_PEER_BANDWIDTH, diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/UserControl.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/UserControl.kt index a02b2bb..efa4bc6 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/UserControl.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/UserControl.kt @@ -15,17 +15,32 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId import kotlinx.io.Buffer +import kotlin.math.max -internal fun UserControl(timestamp: Int, payload: Buffer) = UserControl( +internal fun UserControl(timestamp: Int, chunkStreamId: Int, payload: Buffer) = UserControl( timestamp, UserControl.EventType.from(payload.readShort()), - Buffer().apply { payload.readAtMostTo(this, payload.size - Short.SIZE_BYTES) }) + Buffer().apply { payload.readAtMostTo(this, max(0, payload.size - Short.SIZE_BYTES)) }, + chunkStreamId +) -internal class UserControl(timestamp: Int, val eventType: EventType, val data: Buffer) : +internal fun UserControl( + timestamp: Int, + eventType: UserControl.EventType, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value, +) = + UserControl(timestamp, eventType, Buffer(), chunkStreamId) + +internal class UserControl( + timestamp: Int, + val eventType: EventType, + val data: Buffer, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value +) : Message( - chunkStreamId = ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId = chunkStreamId, messageStreamId = MessageStreamId.PROTOCOL_CONTROL.value, timestamp = timestamp, messageType = MessageType.USER_CONTROL, diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Video.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Video.kt index c76963d..83329aa 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Video.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/Video.kt @@ -15,14 +15,83 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.flv.sources.ByteArrayBackedRawSource +import io.github.thibaultbee.krtmp.flv.tags.video.VideoData +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId +import kotlinx.io.Buffer import kotlinx.io.RawSource +import kotlinx.io.buffered -internal class Video(timestamp: Int, messageStreamId: Int, payload: RawSource) : +/** + * Creates a video message with a [ByteArray] payload. + * + * @param timestamp The timestamp of the message. + * @param messageStreamId The stream ID of the message. + * @param payload The byte array containing the video data. + * @param chunkStreamId The chunk stream ID for this message, defaulting to the video channel. + */ +fun Video( + timestamp: Int, + messageStreamId: Int, + payload: ByteArray, + chunkStreamId: Int = ChunkStreamId.VIDEO_CHANNEL.value +) = Video( + timestamp = timestamp, + messageStreamId = messageStreamId, + payload = ByteArrayBackedRawSource(payload), + payloadSize = payload.size, + chunkStreamId = chunkStreamId +) + +/** + * Creates a video message with a [Buffer] payload. + * + * @param timestamp The timestamp of the message. + * @param messageStreamId The stream ID of the message. + * @param payload The buffer containing the video data. + * @param chunkStreamId The chunk stream ID for this message, defaulting to the video channel. + */ +fun Video( + timestamp: Int, + messageStreamId: Int, + payload: Buffer, + chunkStreamId: Int = ChunkStreamId.VIDEO_CHANNEL.value +) = Video( + timestamp = timestamp, + messageStreamId = messageStreamId, + payload = payload, + payloadSize = payload.size.toInt(), + chunkStreamId = chunkStreamId +) + +/** + * Creates a video message with a [RawSource] payload. + * + * @param timestamp The timestamp of the message. + * @param messageStreamId The stream ID of the message. + * @param payload The raw source containing the video data. + * @param payloadSize The size of the payload in bytes. + * @param chunkStreamId The chunk stream ID for this message, defaulting to the video channel. + */ +class Video internal constructor( + timestamp: Int, + messageStreamId: Int, + payload: RawSource, + payloadSize: Int, + chunkStreamId: Int = ChunkStreamId.VIDEO_CHANNEL.value +) : Message( - chunkStreamId = ChunkStreamId.VIDEO_CHANNEL.value, + chunkStreamId = chunkStreamId, messageStreamId = messageStreamId, timestamp = timestamp, messageType = MessageType.VIDEO, - payload = payload - ) + payload = payload, + payloadSize = payloadSize + ) { + override fun toString(): String { + return "Video(timestamp=$timestamp, messageStreamId=$messageStreamId, payload=$payload)" + } +} + +fun Video.decode() = + VideoData.decode(payload.buffered(), payloadSize, false) diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/WindowAcknowledgementSize.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/WindowAcknowledgementSize.kt index 9bff8ab..4855516 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/WindowAcknowledgementSize.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/WindowAcknowledgementSize.kt @@ -15,15 +15,23 @@ */ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.rtmp.chunk.ChunkStreamId +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId import kotlinx.io.Buffer -internal fun WindowAcknowledgementSize(timestamp: Int, payload: Buffer): WindowAcknowledgementSize = - WindowAcknowledgementSize(timestamp, payload.readInt()) +internal fun WindowAcknowledgementSize( + timestamp: Int, + chunkStreamId: Int, + payload: Buffer +): WindowAcknowledgementSize = + WindowAcknowledgementSize(timestamp, payload.readInt(), chunkStreamId) -internal class WindowAcknowledgementSize(timestamp: Int, val windowSize: Int) : +internal class WindowAcknowledgementSize( + timestamp: Int, + val windowSize: Int, + chunkStreamId: Int = ChunkStreamId.PROTOCOL_CONTROL.value +) : Message( - chunkStreamId = ChunkStreamId.PROTOCOL_CONTROL.value, + chunkStreamId = chunkStreamId, messageStreamId = MessageStreamId.PROTOCOL_CONTROL.value, timestamp = timestamp, messageType = MessageType.WINDOW_ACK_SIZE, diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/BasicHeader.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/BasicHeader.kt similarity index 95% rename from rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/BasicHeader.kt rename to rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/BasicHeader.kt index e938dcb..cab0a65 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/BasicHeader.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/BasicHeader.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.rtmp.chunk +package io.github.thibaultbee.krtmp.rtmp.messages.chunk import io.github.thibaultbee.krtmp.rtmp.extensions.shl import io.github.thibaultbee.krtmp.rtmp.extensions.shr @@ -68,12 +68,11 @@ internal data class BasicHeader( val basicHeader = channel.readByte() val headerType = MessageHeader.HeaderType.entryOf(((basicHeader shr 6) and 0x3).toByte()) - val chunkStreamId = when (val firstByte = basicHeader and 0x3F) { + val chunkStreamId = when (val firstByte = (basicHeader and 0x3F)) { 0.toByte() -> channel.readByte() + 64 1.toByte() -> channel.readShort() + 64 else -> firstByte } - return BasicHeader(headerType, chunkStreamId) } } diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/Chunk.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/Chunk.kt similarity index 81% rename from rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/Chunk.kt rename to rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/Chunk.kt index e24327a..36f679c 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/Chunk.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/Chunk.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.rtmp.chunk +package io.github.thibaultbee.krtmp.rtmp.messages.chunk import io.github.thibaultbee.krtmp.rtmp.extensions.readFully import io.ktor.utils.io.ByteReadChannel @@ -21,21 +21,52 @@ import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.writeBuffer import io.ktor.utils.io.writeInt import kotlinx.io.Buffer +import kotlinx.io.RawSource import kotlin.math.min +/** + * Creates a chunk with a [Buffer] payload. + */ +internal fun Chunk( + basicHeader: BasicHeader, + messageHeader: MessageHeader, + data: Buffer +) = Chunk( + basicHeader, + messageHeader, + data, + data.size.toInt() +) + +/** + * Creates a chunk with a [RawSource] payload. + */ +internal fun Chunk( + chunkStreamId: Number, + messageHeader: MessageHeader, + data: RawSource, + dataSize: Int +) = Chunk( + BasicHeader(messageHeader.type, chunkStreamId), + messageHeader, + data, + dataSize +) + /** * RTMP chunk */ internal class Chunk( val basicHeader: BasicHeader, val messageHeader: MessageHeader, - val data: Buffer + val data: RawSource, + val dataSize: Int ) { private val extendedTimestamp = messageHeader.extendedTimestamp val size = basicHeader.size + messageHeader.size + (extendedTimestamp?.let { 4 } - ?: 0) + data.size + ?: 0) + dataSize init { if (extendedTimestamp != null) { @@ -43,16 +74,6 @@ internal class Chunk( } } - constructor( - chunkStreamId: Number, - messageHeader: MessageHeader, - data: Buffer - ) : this( - BasicHeader(messageHeader.type, chunkStreamId), - messageHeader, - data - ) - suspend fun write(channel: ByteWriteChannel) { basicHeader.write(channel) messageHeader.write(channel) diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/ChunkStreamId.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/ChunkStreamId.kt similarity index 95% rename from rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/ChunkStreamId.kt rename to rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/ChunkStreamId.kt index 1d0817d..50d038f 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/ChunkStreamId.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/ChunkStreamId.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.rtmp.chunk +package io.github.thibaultbee.krtmp.rtmp.messages.chunk internal enum class ChunkStreamId(val value: Int) { PROTOCOL_CONTROL(0x02), // Mandatory value the following could be dynamic diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/MessageHeader.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/MessageHeader.kt similarity index 88% rename from rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/MessageHeader.kt rename to rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/MessageHeader.kt index 08fa700..e9565c2 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/chunk/MessageHeader.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/chunk/MessageHeader.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.rtmp.chunk +package io.github.thibaultbee.krtmp.rtmp.messages.chunk import io.github.thibaultbee.krtmp.rtmp.extensions.readInt24 import io.github.thibaultbee.krtmp.rtmp.extensions.writeInt24 @@ -97,6 +97,10 @@ internal class MessageHeader0( channel.writeIntLittleEndian(messageStreamId) } + override fun toString(): String { + return "MessageHeader0(timestamp=$timestamp, messageLength=$messageLength, messageType=$messageType, messageStreamId=$messageStreamId, hasExtendedTimestamp=$hasExtendedTimestamp, extendedTimestamp=$extendedTimestamp)" + } + companion object { /** * Read message header from input stream @@ -136,6 +140,10 @@ internal class MessageHeader1( channel.writeByte(messageType.value) } + override fun toString(): String { + return "MessageHeader1(timestampDelta=$timestampDelta, messageLength=$messageLength, messageType=$messageType, hasExtendedTimestamp=$hasExtendedTimestamp, extendedTimestamp=$extendedTimestamp)" + } + companion object { /** * Read message header from input stream @@ -169,6 +177,10 @@ internal class MessageHeader2( channel.writeInt24(min(timestampDelta, TIMESTAMP_EXTENDED)) } + override fun toString(): String { + return "MessageHeader2(timestampDelta=$timestampDelta, hasExtendedTimestamp=$hasExtendedTimestamp, extendedTimestamp=$extendedTimestamp)" + } + companion object { /** * Read message header from input stream @@ -196,4 +208,8 @@ internal class MessageHeader3 : MessageHeader(HeaderType.TYPE_3) { override suspend fun write(channel: ByteWriteChannel) { // Do nothing } + + override fun toString(): String { + return "MessageHeader3()" + } } \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/ConnectObject.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/ConnectObject.kt new file mode 100644 index 0000000..3e5ec21 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/ConnectObject.kt @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.messages.command + +import io.github.thibaultbee.krtmp.flv.config.AudioMediaType +import io.github.thibaultbee.krtmp.flv.config.VideoFourCC +import io.github.thibaultbee.krtmp.flv.config.VideoMediaType +import io.github.thibaultbee.krtmp.rtmp.extensions.orNull +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject.Companion.DEFAULT_AUDIO_CODECS +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject.Companion.DEFAULT_CAPABILITIES +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject.Companion.DEFAULT_FLASH_VER +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject.Companion.DEFAULT_VIDEO_CODECS +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject.Companion.DEFAULT_VIDEO_FUNCTION +import kotlinx.serialization.Serializable + +/** + * The AMF encoding version used in the connect object. + * + * @param value The AMF encoding version value. + */ +enum class ObjectEncoding(val value: Int) { + AMF0(0), + AMF3(3) +} + +/** + * A builder for creating a [ConnectObject]. + * + * @param app The server application name the client is connected to + * @param flashVer The flash Player version + * @param tcUrl The server IP address the client is connected to (format: rtmp://ip:port/app/instance) + * @param swfUrl The URL of the source SWF file + * @param fpad True if proxy is used + * @param audioCodecs The supported (by the client) audio codecs + * @param videoCodecs The supported (by the client) video codecs + * @param videoFunction The supported (by the client) video functions + * @param pageUrl The URL of the web page in which the media was embedded + * @param objectEncoding The AMF encoding version + */ +class ConnectObjectBuilder( + var app: String, + var flashVer: String = DEFAULT_FLASH_VER, + var tcUrl: String, + var swfUrl: String? = null, + var fpad: Boolean = false, + var capabilities: Int = DEFAULT_CAPABILITIES, + var audioCodecs: List? = DEFAULT_AUDIO_CODECS, + var videoCodecs: List? = DEFAULT_VIDEO_CODECS, + var videoFunction: List = DEFAULT_VIDEO_FUNCTION, + var pageUrl: String? = null, + var objectEncoding: ObjectEncoding = ObjectEncoding.AMF0 +) { + fun build() = ConnectObject( + app, + flashVer, + tcUrl, + swfUrl, + fpad, + capabilities.toDouble(), + audioCodecs?.filter { ConnectObject.AudioCodec.isSupportedCodec(it) }?.map { + ConnectObject.AudioCodec.fromMediaTypes(listOf(it)) + }?.fold(0) { acc, audioCodec -> + acc or audioCodec.value + }?.toDouble(), + videoCodecs?.filter { ConnectObject.VideoCodec.isSupportedCodec(it) }?.map { + ConnectObject.VideoCodec.fromMimeType(it) + }?.fold(0) { acc, videoCodec -> + acc or videoCodec.value + }?.toDouble(), + videoCodecs?.filter { ConnectObject.ExVideoCodec.isSupportedCodec(it) }?.map { + ConnectObject.ExVideoCodec.fromMediaType(it).value.toString() + }?.orNull(), + videoFunction.fold(0) { acc, vFunction -> + acc or vFunction.value + }.toDouble(), + pageUrl, + objectEncoding.value.toDouble() + ) +} + +/** + * The object sent by the client to the connect command. + * + * @param app The server application name the client is connected to + * @param flashVer The flash Player version + * @param tcUrl The server IP address the client is connected to (format: rtmp://ip:port/app/instance) + * @param swfUrl The URL of the source SWF file + * @param fpad True if proxy is used + * @param audioCodecs The supported (by the client) audio codecs + * @param videoCodecs The supported (by the client) video codecs + * @param fourCcList The supported (by the client) video codecs (extended RTMP) + * @param pageUrl The URL of the web page in which the media was embedded + * @param objectEncoding The AMF encoding version + */ +@Serializable +class ConnectObject +internal constructor( + val app: String, + val flashVer: String = DEFAULT_FLASH_VER, + val tcUrl: String, + val swfUrl: String? = null, + val fpad: Boolean = false, + val capabilities: Double = DEFAULT_CAPABILITIES.toDouble(), + val audioCodecs: Double?, + val videoCodecs: Double?, + val fourCcList: List?, + val videoFunction: Double = 0.0, + val pageUrl: String?, + val objectEncoding: Double = ObjectEncoding.AMF0.value.toDouble() +) { + override fun toString(): String { + return "ConnectObject(app='$app', flashVer='$flashVer', tcUrl='$tcUrl', swfUrl=$swfUrl, fpad=$fpad, capabilities=$capabilities, audioCodecs=$audioCodecs, videoCodecs=$videoCodecs, fourCcList=$fourCcList, videoFunction=$videoFunction, pageUrl=$pageUrl, objectEncoding=$objectEncoding)" + } + + companion object { + internal const val DEFAULT_FLASH_VER = "FMLE/3.0 (compatible; FMSc/1.0)" + internal const val DEFAULT_CAPABILITIES = 239 + internal val DEFAULT_VIDEO_FUNCTION = emptyList() + internal val DEFAULT_AUDIO_CODECS = listOf( + AudioMediaType.AAC, AudioMediaType.G711_ALAW, AudioMediaType.G711_MLAW + ) + internal val DEFAULT_VIDEO_CODECS = listOf( + VideoMediaType.SORENSON_H263, VideoMediaType.AVC + ) + } + + enum class AudioCodec(val value: Int, val mediaType: AudioMediaType?) { + NONE(0x0001, null), + ADPCM(0x0002, AudioMediaType.ADPCM), + MP3(0x0004, AudioMediaType.MP3), + INTEL(0x0008, null), + UNUSED(0x0010, null), + NELLY8(0x0020, AudioMediaType.NELLYMOSER_8KHZ), + NELLY(0x0040, AudioMediaType.NELLYMOSER), + G711A(0x0080, AudioMediaType.G711_ALAW), + G711U(0x0100, AudioMediaType.G711_MLAW), + NELLY16(0x0200, AudioMediaType.NELLYMOSER_16KHZ), + AAC(0x0400, AudioMediaType.AAC), + SPEEX(0x0800, AudioMediaType.SPEEX); + + companion object { + fun isSupportedCodec(mediaType: AudioMediaType): Boolean { + return entries.any { it.mediaType == mediaType } + } + + fun fromMediaTypes(mediaTypes: List): AudioCodec { + return entries.firstOrNull { it.mediaType in mediaTypes } + ?: throw IllegalArgumentException("Unsupported codec: $mediaTypes") + } + } + } + + enum class VideoCodec(val value: Int, val mediaType: VideoMediaType?) { + UNUSED(0x01, null), + JPEG(0x02, null), + SORENSON(0x04, VideoMediaType.SORENSON_H263), + HOMEBREW(0x08, null), + VP6(0x10, VideoMediaType.VP6), + VP6_ALPHA(0x20, VideoMediaType.VP6_ALPHA), + HOMEBREWV(0x40, null), + H264(0x80, VideoMediaType.AVC); + + companion object { + fun isSupportedCodec(mediaType: VideoMediaType): Boolean { + return entries.any { it.mediaType == mediaType } + } + + fun fromMimeType(mediaType: VideoMediaType): VideoCodec { + return entries.firstOrNull { it.mediaType == mediaType } + ?: throw IllegalArgumentException("Unsupported codec: $mediaType") + } + } + } + + class ExVideoCodec { + companion object { + private val supportedCodecs = listOf( + VideoMediaType.VP9, VideoMediaType.HEVC, VideoMediaType.AV1 + ) + + fun isSupportedCodec(mediaType: VideoMediaType): Boolean { + return supportedCodecs.contains(mediaType) + } + + fun fromMediaType(mediaType: VideoMediaType): VideoFourCC { + if (!isSupportedCodec(mediaType)) { + throw IllegalArgumentException("Unsupported codec: $mediaType") + } + return mediaType.fourCCs ?: throw IllegalArgumentException( + "Unsupported codec: $mediaType" + ) + } + } + } + + enum class VideoFunction(val value: Int) { + CLIENT_SEEK(0x1), + + // Enhanced RTMP v1 + CLIENT_HDR(0x2), + CLIENT_PACKET_TYPE_METADATA(0x4), + CLIENT_LARGE_SCALE_TILE(0x8), + } +} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/NetConnectionResultInformation.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/NetConnectionResultInformation.kt new file mode 100644 index 0000000..9b2a9a7 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/NetConnectionResultInformation.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.messages.command + +import io.github.thibaultbee.krtmp.rtmp.messages.ResultInformation +import io.github.thibaultbee.krtmp.rtmp.util.NetConnectionConnectCode +import kotlinx.serialization.Serializable + +fun NetConnectionResultInformation( + level: String, + code: NetConnectionConnectCode, + description: String, + objectEncoding: ObjectEncoding +) = NetConnectionResultInformation( + level = level, + code = code, + description = description, + objectEncoding = objectEncoding.value.toDouble() +) + +/** + * Represents the result information for a NetConnection command. + * + * @property level The level of the result (e.g., "status", "error"). + * @property code The specific code indicating the result type. + * @property description A human-readable description of the result. + * @property objectEncoding The object encoding version used in the connection. + */ +@Serializable +class NetConnectionResultInformation( + override val level: String, + override val code: NetConnectionConnectCode, + override val description: String, + val objectEncoding: Double, +) : ResultInformation diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/NetConnectionResultObject.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/NetConnectionResultObject.kt new file mode 100644 index 0000000..3bb1092 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/NetConnectionResultObject.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.messages.command + +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject.Companion.DEFAULT_CAPABILITIES +import kotlinx.serialization.Serializable + +/** + * Represents the result of a NetConnection command in RTMP. + * + * @property fmsVer The version of the Flash Media Server. + * @property capabilities The capabilities of the server. + */ +@Serializable +class NetConnectionResultObject( + val fmsVer: String = DEFAULT_FMS_VER, + val capabilities: Double = DEFAULT_CAPABILITIES.toDouble(), +) { + companion object { + internal const val DEFAULT_FMS_VER = "FMS/3,0,1,123" + + internal val default = NetConnectionResultObject() + } +} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/StreamPublishType.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/StreamPublishType.kt new file mode 100644 index 0000000..1ba5dfe --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/command/StreamPublishType.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.messages.command + +/** + * Represents the type of stream publishing. + * + * @property value The string representation of the stream publish type. + */ +enum class StreamPublishType(val value: String) { + LIVE("live"), RECORD("record"), APPEND("append"); + + companion object { + fun valueOf(value: String): StreamPublishType { + return entries.firstOrNull { it.value == value } + ?: throw IllegalArgumentException("Unknown stream type: $value") + } + } +} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/RtmpServer.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/RtmpServer.kt new file mode 100644 index 0000000..ff9f9e3 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/RtmpServer.kt @@ -0,0 +1,509 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.server + +import io.github.thibaultbee.krtmp.amf.elements.primitives.AmfNumber +import io.github.thibaultbee.krtmp.common.logger.KrtmpLogger +import io.github.thibaultbee.krtmp.rtmp.client.RtmpClient +import io.github.thibaultbee.krtmp.rtmp.connection.RtmpConnection +import io.github.thibaultbee.krtmp.rtmp.connection.RtmpConnectionCallback +import io.github.thibaultbee.krtmp.rtmp.extensions.serverHandshake +import io.github.thibaultbee.krtmp.rtmp.messages.Audio +import io.github.thibaultbee.krtmp.rtmp.messages.Command +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_CLOSE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_CONNECT_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_CREATE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_DELETE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_FCPUBLISH_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_FCUNPUBLISH_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_PLAY_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_PUBLISH_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Companion.COMMAND_RELEASE_STREAM_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Error +import io.github.thibaultbee.krtmp.rtmp.messages.Command.Result +import io.github.thibaultbee.krtmp.rtmp.messages.CommandOnFCPublish +import io.github.thibaultbee.krtmp.rtmp.messages.DataAmf +import io.github.thibaultbee.krtmp.rtmp.messages.DataAmf.Companion.DATAAMF_SET_DATA_FRAME_NAME +import io.github.thibaultbee.krtmp.rtmp.messages.Message +import io.github.thibaultbee.krtmp.rtmp.messages.SetPeerBandwidth +import io.github.thibaultbee.krtmp.rtmp.messages.Video +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusCodePublishFailed +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusCodePublishStart +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusLevelError +import io.github.thibaultbee.krtmp.rtmp.util.NetStreamOnStatusLevelStatus +import io.github.thibaultbee.krtmp.rtmp.util.extensions.startWithScheme +import io.github.thibaultbee.krtmp.rtmp.util.sockets.tcp.TcpSocket +import io.github.thibaultbee.krtmp.rtmp.util.sockets.tcp.TcpSocketFactory +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.ServerSocket +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.SocketAddress +import kotlinx.coroutines.job +import kotlin.coroutines.cancellation.CancellationException + +/** + * Creates a new RTMP server that listens on the specified URL. + * + * @param urlString the URL to bind the server to. If null, the server will bind to all available addresses. + * @param callback the callback to handle RTMP server events. + * @param settings the settings for the RTMP server. + * @return a [RtmpServer] instance. + */ +suspend fun RtmpServer( + urlString: String? = null, + callback: RtmpServerCallback, + settings: RtmpServerSettings = RtmpServerSettings, +): RtmpServer { + return if (urlString == null) { + return RtmpServer(localAddress = null, callback = callback, settings = settings) + } else { + val url = if (urlString.startWithScheme()) { + Url(urlString) + } else { + Url("tcp://$urlString") + } + RtmpServer( + InetSocketAddress(url.host, url.port), callback, settings + ) + } +} + +/** + * Creates a new RTMP server that listens on the specified local address. + * + * @param localAddress the local address to bind the server to. If null, the server will bind to all available addresses. + * @param callback the callback to handle RTMP server events. + * @param settings the settings for the RTMP server. + * @return a [RtmpServer] instance. + */ +suspend fun RtmpServer( + localAddress: SocketAddress? = null, + callback: RtmpServerCallback, + settings: RtmpServerSettings = RtmpServerSettings, +) = RtmpServer( + TcpSocketFactory.default.server(localAddress), callback, settings +) + +/** + * The RTMP server. + * + * @param serverSocket the server socket to accept connections on. + * @param callback the callback to handle RTMP server events. + * @param settings the settings for the RTMP server. + */ +class RtmpServer internal constructor( + private val serverSocket: ServerSocket, + private val callback: RtmpServerCallback, + private val settings: RtmpServerSettings +) { + /** + * Local socket address. Could throw an exception if no address bound yet. + */ + val localAddress: SocketAddress + get() = serverSocket.localAddress + + /** + * Accepts a new client connection and returns a [RtmpClient] instance. + * + * @param onAccept a callback that is called when a new client connection is accepted. You can throw an exception to reject the connection. + * @return a [RtmpClient] instance for the accepted connection. + */ + suspend fun accept(onAccept: (Socket) -> Unit = {}): RtmpClient { + val clientSocket = serverSocket.accept() + KrtmpLogger.i(TAG, "New client connection: ${clientSocket.remoteAddress}") + onAccept(clientSocket) + + val connection = TcpSocket(clientSocket, URLBuilder(clientSocket.remoteAddress.toString())) + connection.serverHandshake(settings.clock) + + val rtmpConnection = RtmpConnection( + connection, settings, RtmpServerConnectionCallback.Factory(callback, settings) + ) + return RtmpClient(rtmpConnection) + } + + /** + * Listens for incoming connections and handles them in a loop. + * + * Some as calling [accept] in a loop, but handles exceptions and logs them. + */ + suspend fun listen() { + while (serverSocket.socketContext.isActive) { + try { + val client = accept() + client.coroutineContext.job.join() + } catch (t: CancellationException) { + KrtmpLogger.e(TAG, "Cancelling server") + } catch (t: Throwable) { + KrtmpLogger.e(TAG, "Error with connection", t) + KrtmpLogger.i(TAG, "Waiting for new connection") + } + } + } + + /** + * Closes the RTMP server. + */ + fun close() { + serverSocket.close() + } + + companion object { + private const val TAG = "RtmpServer" + } +} + +internal class RtmpServerConnectionCallback( + private val connection: RtmpConnection, + private val callback: RtmpServerCallback, + private val settings: RtmpServerSettings +) : RtmpConnectionCallback { + override suspend fun onCommand(command: Command) { + when (command.name) { + COMMAND_CONNECT_NAME -> { + try { + callback.onConnect(command) + + val ackSize = 2_500_000 // TODO + val bandwidth = 2_500_000 // TODO + val type = SetPeerBandwidth.LimitType.DYNAMIC // TODO + KrtmpLogger.i(TAG, "Set window acknowledgement size: $ackSize") + KrtmpLogger.i(TAG, "Set peer bandwidth: $bandwidth type: 2") + connection.replyConnect(ackSize, bandwidth, type) + } catch (t: Throwable) { + connection.writeAmfMessage( + Error( + command.messageStreamId, + 1, + connection.settings.clock.nowInMs, + null, + null + ) + ) + } + } + + COMMAND_CREATE_STREAM_NAME -> { + try { + callback.onCreateStream(command) + val streamId = settings.streamIdProvider.create() + require(streamId > 0) { "Stream ID must be greater than 0" } + require((streamId != 2)) { "Stream ID must not be 2, reserved for control messages" } + connection.writeAmfMessage( + Result( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + null, + AmfNumber(streamId.toDouble()) + ) + ) + } catch (t: Throwable) { + connection.writeAmfMessage( + Error( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + null, + null + ) + ) + } + } + + COMMAND_RELEASE_STREAM_NAME -> { + try { + callback.onReleaseStream(command) + connection.writeAmfMessage( + Result( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + null, + AmfNumber(1.0) + ) + ) + } catch (t: Throwable) { + connection.writeAmfMessage( + Error( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + null, + null + ) + ) + } + } + + COMMAND_DELETE_STREAM_NAME -> { + callback.onDeleteStream(command) + // Delete the stream ID from the provider + try { + require(command.arguments.isNotEmpty()) { + "deleteStream command must have at least one argument (stream ID)" + } + val streamId = (command.arguments[0] as AmfNumber).value.toInt() + settings.streamIdProvider.delete(streamId) + } catch (t: Throwable) { + KrtmpLogger.e(TAG, "Error deleting stream ID", t) + } + } + + COMMAND_PUBLISH_NAME -> { + try { + require(command.arguments.size >= 2) { + "publish command must have at least two arguments (stream key and type)" + } + val streamKey = command.arguments[0].toString() + KrtmpLogger.i( + TAG, "Publishing stream: $streamKey for ${command.arguments[1]}" + ) + callback.onPublish(command) + connection.writeAmfMessage( + Command.OnStatus( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + Command.OnStatus.NetStreamOnStatusInformation( + level = NetStreamOnStatusLevelStatus, + code = NetStreamOnStatusCodePublishStart, + description = "$streamKey is now published" + ) + ) + ) + } catch (t: Throwable) { + connection.writeAmfMessage( + Command.OnStatus( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + Command.OnStatus.NetStreamOnStatusInformation( + level = NetStreamOnStatusLevelError, + code = NetStreamOnStatusCodePublishFailed, + description = "Publish failed" + ) + ) + ) + } + } + + COMMAND_PLAY_NAME -> { + try { + callback.onPlay(command) + } catch (t: Throwable) { + connection.writeAmfMessage( + Error( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + null, + null + ) + ) + } + } + + COMMAND_FCPUBLISH_NAME -> { + try { + callback.onFCPublish(command) + connection.writeAmfMessage( + CommandOnFCPublish(command.transactionId, connection.settings.clock.nowInMs) + ) + } catch (t: Throwable) { + connection.writeAmfMessage( + Error( + command.messageStreamId, + command.transactionId, + connection.settings.clock.nowInMs, + null, + null + ) + ) + } + } + + COMMAND_FCUNPUBLISH_NAME -> { + callback.onFCUnpublish(command) + } + + COMMAND_CLOSE_STREAM_NAME -> { + callback.onCloseStream(command) + } + + else -> { + callback.onUnknownCommandMessage(command) + } + } + } + + override suspend fun onData(data: DataAmf) { + when (data.name) { + DATAAMF_SET_DATA_FRAME_NAME -> { + callback.onSetDataFrame(data) + } + + else -> { + callback.onUnknownDataMessage(data) + } + } + } + + override suspend fun onMessage(message: Message) { + when (message) { + is Audio -> { + require(settings.streamIdProvider.hasStreamId(message.messageStreamId)) { + "Audio message must have a valid stream ID" + } + callback.onAudio(message) + } + + is Video -> { + require(settings.streamIdProvider.hasStreamId(message.messageStreamId)) { + "Video message must have a valid stream ID" + } + callback.onVideo(message) + } + + else -> { + callback.onUnknownMessage(message) + } + } + } + + companion object { + private const val TAG = "RtmpServerCallbackImpl" + } + + internal class Factory( + private val callback: RtmpServerCallback, private val settings: RtmpServerSettings + ) : RtmpConnectionCallback.Factory { + override fun create(streamer: RtmpConnection): RtmpConnectionCallback = + RtmpServerConnectionCallback(streamer, callback, settings) + } +} + +/** + * Callback interface for RTMP server events. + */ +interface RtmpServerCallback { + /** + * Called when a new client connects to the server. + * + * @param connect the connect command received from the client + */ + fun onConnect(connect: Command) = Unit + + /** + * Called when a new stream is created. + * + * @param createStream the createStream command received from the client + */ + fun onCreateStream(createStream: Command) = Unit + + /** + * Called when a stream is released. + * + * @param releaseStream the releaseStream command received from the client + */ + fun onReleaseStream(releaseStream: Command) = Unit + + /** + * Called when a stream is deleted. + * + * @param deleteStream the deleteStream command received from the client + */ + fun onDeleteStream(deleteStream: Command) = Unit + + /** + * Called when a stream is published. + * + * @param publish the publish command received from the client + */ + fun onPublish(publish: Command) = Unit + + /** + * Called when a stream is played. + * + * @param play the play command received from the client + */ + fun onPlay(play: Command) = Unit + + /** + * Called when a stream is FCPublished. + * + * @param fcPublish the FCPublish command received from the client + */ + fun onFCPublish(fcPublish: Command) = Unit + + /** + * Called when a stream is FCUnpublished. + * + * @param fcUnpublish the FCUnpublish command received from the client + */ + fun onFCUnpublish(fcUnpublish: Command) = Unit + + /** + * Called when a stream is closed. + * + * @param closeStream the closeStream command received from the client + */ + fun onCloseStream(closeStream: Command) = Unit + + /** + * Called when a set data frame is received. + * + * @param setDataFrame the setDataFrame message received from the client + */ + fun onSetDataFrame(setDataFrame: DataAmf) = Unit + + /** + * Called when an audio message is received. + * + * @param audio the audio message received from the client + */ + fun onAudio(audio: Audio) = Unit + + /** + * Called when a video message is received. + * + * @param video the video message received from the client + */ + fun onVideo(video: Video) = Unit + + /** + * Called when an unknown message is received. + * + * @param message the unknown message received from the client + */ + fun onUnknownMessage(message: Message) = Unit + + /** + * Called when an unknown command message is received. + * + * @param command the unknown command message received from the client + */ + fun onUnknownCommandMessage(command: Command) = Unit + + /** + * Called when an unknown data message is received. + * + * @param data the unknown data message received from the client + */ + fun onUnknownDataMessage(data: DataAmf) = Unit +} diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/RtmpServerSettings.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/RtmpServerSettings.kt new file mode 100644 index 0000000..c911fc6 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/RtmpServerSettings.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.server + +import io.github.thibaultbee.krtmp.amf.AmfVersion +import io.github.thibaultbee.krtmp.rtmp.server.util.DefaultStreamIdProvider +import io.github.thibaultbee.krtmp.rtmp.server.util.IStreamIdProvider +import io.github.thibaultbee.krtmp.rtmp.connection.RtmpSettings +import io.github.thibaultbee.krtmp.rtmp.util.RtmpClock + +/** + * RTMP server settings. + */ +open class RtmpServerSettings( + writeChunkSize: Int = DEFAULT_CHUNK_SIZE, + writeWindowAcknowledgementSize: Int = Int.MAX_VALUE, + amfVersion: AmfVersion = AmfVersion.AMF0, + clock: RtmpClock = RtmpClock.Default(), + val streamIdProvider: IStreamIdProvider = DefaultStreamIdProvider() +) : RtmpSettings(writeChunkSize, writeWindowAcknowledgementSize, amfVersion, clock, false, 0L) { + /** + * The default instance of [RtmpServerSettings] + */ + companion object Default : RtmpServerSettings() +} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/util/StreamIdProvider.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/util/StreamIdProvider.kt new file mode 100644 index 0000000..ac61ac4 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/server/util/StreamIdProvider.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2025 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.server.util + +/** + * Interface for providing unique stream IDs to `createStream` and `deleteStream` calls. + */ +interface IStreamIdProvider { + /** + * Gets the next stream ID. + * + * The returned stream ID must be unique. + * It must be greater than 0 and different from 2 (reserved for control messages). + * + * @return the next stream ID. + */ + fun create(): Int + + /** + * Resets the stream ID counter. + * + * @param streamId the stream ID to delete. + */ + fun delete(streamId: Int) + + /** + * Whether the given stream ID is already in use. + * + * @param streamId the stream ID to check + * @return true if the stream ID is known by the [IStreamIdProvider] implementation, false otherwise. + */ + fun hasStreamId(streamId: Int): Boolean +} + +/** + * Default implementation of [IStreamIdProvider]. + * + * This implementation starts from 3 and increments the stream ID for each call to `create()`. + * It keeps track of used stream IDs to ensure uniqueness. + * + * The [hasStreamId] method always returns true, indicating that all stream IDs are considered valid. + */ +class DefaultStreamIdProvider : IStreamIdProvider { + private var nextStreamId = 3 // Start from 3 to avoid reserved IDs (0, 2) + private val usedStreamIds = mutableSetOf() + + override fun create(): Int { + val streamId = nextStreamId++ + usedStreamIds.add(streamId) + return streamId + } + + override fun delete(streamId: Int) { + usedStreamIds.remove(streamId) + } + + override fun hasStreamId(streamId: Int) = true +} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/AmfUtil.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/AmfUtil.kt index 9efdfc5..60967bf 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/AmfUtil.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/AmfUtil.kt @@ -3,13 +3,14 @@ package io.github.thibaultbee.krtmp.rtmp.util import io.github.thibaultbee.krtmp.amf.Amf import kotlinx.serialization.ExperimentalSerializationApi -internal object AmfUtil { +object AmfUtil { /** * AMF serializer for FLV and RTMP. */ @OptIn(ExperimentalSerializationApi::class) - internal val amf = Amf { + val amf = Amf { encodeDefaults = true explicitNulls = false + ignoreUnknownKeys = true } } \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/Handshake.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/Handshake.kt similarity index 74% rename from rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/Handshake.kt rename to rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/Handshake.kt index 27858f0..2f5c91a 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/Handshake.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/Handshake.kt @@ -13,11 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.rtmp +package io.github.thibaultbee.krtmp.rtmp.util -import io.github.thibaultbee.krtmp.rtmp.util.RtmpClock -import io.github.thibaultbee.krtmp.rtmp.util.connections.IConnection -import io.github.thibaultbee.krtmp.rtmp.util.connections.TcpConnection +import io.github.thibaultbee.krtmp.rtmp.util.sockets.ISocket +import io.github.thibaultbee.krtmp.rtmp.util.sockets.tcp.TcpSocket import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.readByte @@ -32,11 +31,11 @@ import kotlin.random.Random * Implementation of RTMP handshake. */ internal class Handshake( - private val connection: IConnection, + private val connection: ISocket, private val clock: RtmpClock, private val version: Byte = 0x3, ) { - suspend fun startClient() { + internal suspend fun startClient() { val c0 = Zero(version) val c1 = One(0, Random.nextBytes(RANDOM_DATA_SIZE)) connection.write(Zero.LENGTH + One.LENGTH.toLong()) { @@ -53,9 +52,7 @@ internal class Handshake( One.read(it) } - val time2 = clock.nowInMs - - val c2 = Two(s1.timestamp, time2, s1.random) + val c2 = Two(s1.timestamp, clock.nowInMs, s1.random) connection.write(Two.LENGTH.toLong()) { c2.write(it) } @@ -64,12 +61,43 @@ internal class Handshake( Two.read(it) } - if (connection is TcpConnection) { + if (connection is TcpSocket) { require(s2.timestamp == c1.timestamp) { "Handshake failed: S2 and C1 must have the same timestamp" } require(s2.random.contentEquals(c1.random)) { "Handshake failed: S2 and C1 must have the same random sequence" } } } + internal suspend fun starServer() { + val c0 = connection.read { + Zero.read(it) + } + require(c0.version == version) { "Handshake failed: S0 and C0 must have the same version: ${c0.version} instead of $version" } + + val s0 = Zero(version) + val s1 = One(0, Random.nextBytes(RANDOM_DATA_SIZE)) + connection.write(Zero.LENGTH + One.LENGTH.toLong()) { + s0.write(it) + s1.write(it) + } + + val c1 = connection.read { + One.read(it) + } + val s2 = Two(c1.timestamp, clock.nowInMs, c1.random) + connection.write(Two.LENGTH.toLong()) { + s2.write(it) + } + + val c2 = connection.read { + Two.read(it) + } + + if (connection is TcpSocket) { + require(c2.timestamp == c1.timestamp) { "Handshake failed: C2 and S1 must have the same timestamp" } + require(c2.random.contentEquals(s1.random)) { "Handshake failed: C2 and S1 must have the same random sequence" } + } + } + private class Zero(val version: Byte) { suspend fun write(writeChannel: ByteWriteChannel) { writeChannel.writeByte(version) diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/MessagesManager.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/MessagesManager.kt index e8c1284..78ac10e 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/MessagesManager.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/MessagesManager.kt @@ -42,8 +42,8 @@ internal class MessagesManager { ) { writeMutex.withLock { val previousMessage = writeChunkStreamMessageMap[message.chunkStreamId] - onPreviousMessage(previousMessage) writeChunkStreamMessageMap[message.chunkStreamId] = message + onPreviousMessage(previousMessage) } } diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/NetCommands.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/NetCommands.kt new file mode 100644 index 0000000..29fac44 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/NetCommands.kt @@ -0,0 +1,41 @@ +package io.github.thibaultbee.krtmp.rtmp.util + +internal typealias NetStreamOnStatusLevel = String + +internal const val NetStreamOnStatusLevelStatus: NetStreamOnStatusLevel = "status" +internal const val NetStreamOnStatusLevelError: NetStreamOnStatusLevel = "error" + + +internal typealias NetStreamOnStatusCode = String + +internal const val NetStreamOnStatusCodeConnectSuccess: NetStreamOnStatusCode = + "NetStream.Connect.Success" +internal const val NetStreamOnStatusCodeConnectClosed: NetStreamOnStatusCode = + "NetStream.Connect.Closed" +internal const val NetStreamOnStatusCodeMuticastStreamReset: NetStreamOnStatusCode = + "NetStream.MulticastStream.Reset" +internal const val NetStreamOnStatusCodePlayStart: NetStreamOnStatusCode = + "NetStream.Play.Start" +internal const val NetStreamOnStatusCodePlayFailed: NetStreamOnStatusCode = + "NetStream.Play.Failed" +internal const val NetStreamOnStatusCodePlayComplete: NetStreamOnStatusCode = + "NetStream.Play.Complete" +internal const val NetStreamOnStatusCodePublish = "NetStream.Publish" +internal const val NetStreamOnStatusCodePublishBadName: NetStreamOnStatusCode = + "NetStream.Publish.BadName" +internal const val NetStreamOnStatusCodePublishFailed: NetStreamOnStatusCode = + "NetStream.Publish.Failed" +internal const val NetStreamOnStatusCodePublishStart: NetStreamOnStatusCode = + "NetStream.Publish.Start" +internal const val NetStreamOnStatusCodeUnpublishSuccess: NetStreamOnStatusCode = + "NetStream.Unpublish.Success" + + +typealias NetConnectionConnectCode = String + +internal const val NetConnectionConnectCodeSuccess: NetConnectionConnectCode = + "NetConnection.Connect.Success" +internal const val NetConnectionConnectCodeFailed: NetConnectionConnectCode = + "NetConnection.Connect.Failed" +internal const val NetConnectionConnectCodeClosed: NetConnectionConnectCode = + "NetConnection.Connect.Closed" \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/NetStreamCommand.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/NetStreamCommand.kt deleted file mode 100644 index ba56072..0000000 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/NetStreamCommand.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2024 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.rtmp.util - -internal object NetStreamCommand { - const val CONNECT = "NetStream.Connect" - const val PUBLISH = "NetStream.Publish" - const val PLAY = "NetStream.Play" - const val RECORD = "NetStream.Record" - - const val PUBLISH_BAD_NAME = "NetStream.Publish.BadName" - const val PUBLISH_IDLE = "NetStream.Publish.Idle" - const val PUBLISH_START = "NetStream.Publish.Start" -} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/RtmpURLBuilder.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/RtmpURLBuilder.kt index 9a65b8e..23b06ac 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/RtmpURLBuilder.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/RtmpURLBuilder.kt @@ -16,8 +16,29 @@ package io.github.thibaultbee.krtmp.rtmp.util import io.ktor.http.URLBuilder +import io.ktor.http.Url import io.ktor.http.takeFrom +/** + * Creates a [URLBuilder] from an RTMP URL. + * + * @param url the RTMP URL to build from + * @return a [URLBuilder] initialized with the RTMP URL + */ +fun RtmpURLBuilder(url: Url): URLBuilder { + val urlBuilder = URLBuilder().takeFrom(url) + if (urlBuilder.port == 0) { + urlBuilder.port = RtmpURLProtocol.createOrDefault(urlBuilder.protocol.name).defaultPort + } + return urlBuilder +} + +/** + * Creates a [URLBuilder] from a string representation of an RTMP URL. + * + * @param urlString the string representation of the RTMP URL + * @return a [URLBuilder] initialized with the RTMP URL + */ fun RtmpURLBuilder(urlString: String): URLBuilder { val urlBuilder = URLBuilder().takeFrom(urlString) if (urlBuilder.port == 0) { diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/TransactionCommandCompletion.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/TransactionCommandCompletion.kt index 616eaf5..8d3949c 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/TransactionCommandCompletion.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/TransactionCommandCompletion.kt @@ -15,7 +15,7 @@ */ package io.github.thibaultbee.krtmp.rtmp.util -import io.github.thibaultbee.krtmp.rtmp.client.RemoteServerException +import io.github.thibaultbee.krtmp.rtmp.connection.RemoteCommandException import io.github.thibaultbee.krtmp.rtmp.messages.Command import kotlinx.coroutines.CompletableDeferred @@ -39,7 +39,7 @@ internal class TransactionCommandCompletion { fun completeExceptionally(key: Any, error: Command) { val deferred = deferreds[key] deferred?.completeExceptionally( - RemoteServerException( + RemoteCommandException( "Command failed with error: $error", error ) diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/HttpConnection.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/HttpConnection.kt deleted file mode 100644 index 580d5c5..0000000 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/HttpConnection.kt +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright (C) 2024 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.rtmp.util.connections - -import io.ktor.client.HttpClient -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.request.headers -import io.ktor.client.request.post -import io.ktor.client.statement.bodyAsChannel -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpStatusCode -import io.ktor.http.URLBuilder -import io.ktor.http.content.OutgoingContent -import io.ktor.http.encodedPath -import io.ktor.http.takeFrom -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.ByteWriteChannel -import io.ktor.utils.io.InternalAPI -import io.ktor.utils.io.availableForRead -import io.ktor.utils.io.discard -import kotlinx.coroutines.CompletionHandler -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.job -import kotlin.coroutines.cancellation.CancellationException - -internal class HttpConnection(private val urlBuilder: URLBuilder) : IConnection { - private val client = HttpClient { - defaultRequest { - headers { - rtmptHeaders.forEach { (key, value) -> - append(key, value) - } - } - } - } - private var connectionId: String? = null - - private val postByteReadChannels = mutableListOf() - - private var _index = 0L - private val index: Long - get() = _index++ - - override val coroutineContext = client.coroutineContext - - private var _isClosed: Boolean = true - override val isClosed: Boolean - get() = _isClosed - - private var _totalBytesRead: Long = 0 - override val totalBytesRead: Long - get() = _totalBytesRead - - private var _totalBytesWritten: Long = 0 - override val totalBytesWritten: Long - get() = _totalBytesWritten - - private var _closedCause: Throwable? = null - override val closedCause: Throwable? - get() = _closedCause - - override fun invokeOnCompletion(handler: CompletionHandler) { - client.coroutineContext.job.invokeOnCompletion { handler(it) } - } - - override suspend fun connect() { - try { - var response = post("fcs/ident2", byteArrayOf(0x00)) - // Expected 404 but some servers return other error code - if (response.status.value !in 400..499) { - throw IllegalStateException("Connection failed. Expected 404, got ${response.status}") - } - response = post("open/1") - if (response.status != HttpStatusCode.OK) { - throw IllegalStateException("Connection failed. Expected 200, got ${response.status}") - } - val sessionId = response.bodyAsText().trimIndent() - this.connectionId = sessionId - - readIdle {} - _isClosed = false - } catch (t: Throwable) { - throwException(t) - throw t - } - } - - override suspend fun write( - length: Long, - block: suspend (ByteWriteChannel) -> Unit - ) { - require(!isClosed) { "Connection is closed" } - - try { - val response = - post( - "send/${connectionId!!}/$index", - object : OutgoingContent.WriteChannelContent() { - override val contentLength = length - - override suspend fun writeTo(channel: ByteWriteChannel) { - block(channel) - } - }) - if (response.status != HttpStatusCode.OK) { - throw IllegalStateException("Send failed. Expected 200, got ${response.status}") - } - _totalBytesWritten += length - - val body = response.bodyAsChannel().apply { - discard(1) // Discard first byte - } - if (body.availableForRead > 0) { - _totalBytesRead += body.availableForRead - postByteReadChannels.add(body) - } - } catch (t: Throwable) { - throwException(t) - throw t - } - } - - override suspend fun read(block: suspend (ByteReadChannel) -> T): T { - require(!isClosed) { "Connection is closed" } - - val result = readMemory(block) - if (result != null) { - return result - } - - val coroutine = client.async { - var res: T? = null - while (isActive) { - val read = readMemory(block) - if (read != null) { - res = read - break - } - delay(500) - } - res ?: throw CancellationException() - } - return coroutine.await() - } - - private suspend fun readMemory(block: suspend (ByteReadChannel) -> T): T? { - return if (postByteReadChannels.isNotEmpty()) { - val body = postByteReadChannels.first() - val result = block(body) - if (body.availableForRead == 0) { - postByteReadChannels.removeFirst() - } - result - } else { - null - } - } - - private suspend fun readIdle(block: suspend (ByteReadChannel) -> T): T? { - return try { - val response = post("idle/${connectionId!!}/$index") - if (response.status != HttpStatusCode.OK) { - throw IllegalStateException("Send failed. Expected 200, got ${response.status}") - } - val body = response.bodyAsChannel().apply { - discard(1) // Discard first byte - } - if (body.availableForRead > 0) { - block(body) - } else { - null - } - } catch (t: Throwable) { - throwException(t) - throw t - } - } - - override suspend fun close() { - connectionId?.let { - post("close/$it", ByteArray(0)) - } - client.close() - client.coroutineContext.cancelChildren() - postByteReadChannels.clear() - connectionId = null - _isClosed = true - } - - private suspend fun post(path: String, array: ByteArray) = - post(path, array as Any) - - @OptIn(InternalAPI::class) - private suspend fun post(path: String, anyBody: Any? = null) = - client.post { - url { - takeFrom(urlBuilder.buildString()) - encodedPath = path - } - if (anyBody != null) { - body = anyBody - } - } - - private fun throwException(t: Throwable) { - _closedCause = t - } - - companion object { - private val rtmptHeaders = mapOf( - "Content-Type" to "application/x-fcs", - "User-Agent" to "Shockwave Flash" - ) - } -} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/TcpConnection.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/TcpConnection.kt deleted file mode 100644 index d47a517..0000000 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/TcpConnection.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2024 Thibault B. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.github.thibaultbee.krtmp.rtmp.util.connections - -import io.github.thibaultbee.krtmp.rtmp.extensions.isSecureRtmp -import io.ktor.http.URLBuilder -import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.Connection -import io.ktor.network.sockets.SocketOptions -import io.ktor.network.sockets.aSocket -import io.ktor.network.sockets.connection -import io.ktor.network.sockets.isClosed -import io.ktor.network.tls.tls -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.ByteWriteChannel -import io.ktor.utils.io.CountedByteReadChannel -import io.ktor.utils.io.CountedByteWriteChannel -import kotlinx.coroutines.CompletionHandler -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.InternalCoroutinesApi -import kotlin.coroutines.CoroutineContext - -internal class TcpConnection( - private val urlBuilder: URLBuilder, - private val dispatcher: CoroutineDispatcher, - private val socketOptions: SocketOptions.PeerSocketOptions.() -> Unit = {}, -) : IConnection { - private val selectorManager = SelectorManager(dispatcher) - private val tcpSocket = aSocket(selectorManager).tcp() - private var connection: Connection? = null - private val input by lazy { - connection?.let { CountedByteReadChannel(it.input) } - ?: throw IllegalStateException("Trying to get input without connection") - } - private val output by lazy { - connection?.let { CountedByteWriteChannel(it.output) } - ?: throw IllegalStateException("Trying to get output without connection") - } - - override val coroutineContext: CoroutineContext - get() = connection?.socket?.coroutineContext - ?: throw IllegalStateException("Connection is closed") - - override val isClosed: Boolean - get() = connection?.socket?.isClosed ?: true - - override val totalBytesRead: Long - get() = input.totalBytesRead - - override val totalBytesWritten: Long - get() = output.totalBytesWritten - - @OptIn(InternalCoroutinesApi::class) - override val closedCause: Throwable? - get() = connection?.socket?.socketContext?.getCancellationException()?.cause - - override fun invokeOnCompletion(handler: CompletionHandler) { - connection!!.socket.socketContext.invokeOnCompletion(handler) - } - - override suspend fun connect() { - var socket = tcpSocket.connect(urlBuilder.host, urlBuilder.port, socketOptions) - if (urlBuilder.protocol.isSecureRtmp) { - socket = socket.tls(dispatcher) - } - connection = socket.connection() - } - - override suspend fun write( - length: Long, - block: suspend (ByteWriteChannel) -> Unit - ) { - require(!isClosed) { "Connection is closed" } - block(output) - output.flush() - } - - override suspend fun read(block: suspend (ByteReadChannel) -> T): T { - require(!isClosed) { "Connection is closed" } - return block(input) - } - - override suspend fun close() { - connection?.socket?.close() - selectorManager.close() - } -} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/extensions/StringExtensions.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/extensions/StringExtensions.kt new file mode 100644 index 0000000..bc3b9f9 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/extensions/StringExtensions.kt @@ -0,0 +1,12 @@ +package io.github.thibaultbee.krtmp.rtmp.util.extensions + + +private val schemeRegex = "^[a-zA-Z][a-zA-Z0-9+.-]*://".toRegex() + +/** + * Whether the string starts with a scheme. + * + * For example, "rtmp://", "http://", "https://", etc. + */ +internal fun String.startWithScheme() = + schemeRegex.containsMatchIn(this) \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/IConnection.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/ISocket.kt similarity index 77% rename from rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/IConnection.kt rename to rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/ISocket.kt index 07b2227..498017b 100644 --- a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/connections/IConnection.kt +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/ISocket.kt @@ -13,22 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.github.thibaultbee.krtmp.rtmp.util.connections +package io.github.thibaultbee.krtmp.rtmp.util.sockets +import io.ktor.http.URLBuilder +import io.ktor.network.sockets.ASocket import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteWriteChannel -import kotlinx.coroutines.CompletionHandler import kotlinx.coroutines.CoroutineScope -internal interface IConnection : CoroutineScope { +internal interface ISocket : CoroutineScope, ASocket { + val urlBuilder: URLBuilder + val isClosed: Boolean val totalBytesRead: Long val totalBytesWritten: Long - val closedCause: Throwable? - - fun invokeOnCompletion(handler: CompletionHandler) - - suspend fun connect() suspend fun write( length: Long, @@ -37,5 +35,5 @@ internal interface IConnection : CoroutineScope { suspend fun read(block: suspend (ByteReadChannel) -> T): T - suspend fun close() + override fun close() } \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/SocketFactory.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/SocketFactory.kt new file mode 100644 index 0000000..cd0eb67 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/SocketFactory.kt @@ -0,0 +1,33 @@ +package io.github.thibaultbee.krtmp.rtmp.util.sockets + +import io.github.thibaultbee.krtmp.rtmp.extensions.isTunneledRtmp +import io.github.thibaultbee.krtmp.rtmp.extensions.validateRtmp +import io.github.thibaultbee.krtmp.rtmp.util.sockets.http.HttpSocket +import io.github.thibaultbee.krtmp.rtmp.util.sockets.tcp.TcpSocket +import io.github.thibaultbee.krtmp.rtmp.util.sockets.tcp.TcpSocketFactory +import io.ktor.http.URLBuilder +import io.ktor.network.sockets.SocketOptions + +/** + * Factory for creating connections. + */ +internal class SocketFactory(private val tcpSocketFactory: TcpSocketFactory = TcpSocketFactory.default) { + /** + * Creates a connection based on the URL scheme. + * + * @param urlBuilder The URL builder containing the connection details. + * @param socketOptions Options for the TCP socket. Only for RTMP and RTMPS connections. + * @return An instance of [ISocket] for the specified URL scheme. + */ + suspend fun connect( + urlBuilder: URLBuilder, socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {} + ): ISocket { + urlBuilder.validateRtmp() + val connection = if (urlBuilder.protocol.isTunneledRtmp) { + HttpSocket(urlBuilder) + } else { + TcpSocket(tcpSocketFactory.client(urlBuilder, socketOptions), urlBuilder) + } + return connection + } +} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/http/HttpClientExtensions.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/http/HttpClientExtensions.kt new file mode 100644 index 0000000..7a2cbbc --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/http/HttpClientExtensions.kt @@ -0,0 +1,15 @@ +package io.github.thibaultbee.krtmp.rtmp.util.sockets.http + +import io.ktor.client.HttpClient +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.post +import io.ktor.client.statement.HttpResponse +import io.ktor.http.URLBuilder +import io.ktor.http.appendPathSegments + +suspend fun HttpClient.post( + urlBuilder: URLBuilder, + encodedPath: String, + block: HttpRequestBuilder.() -> Unit = {} +): HttpResponse = + post(urlBuilder.appendPathSegments(encodedPath).buildString(), block) \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/http/HttpSocket.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/http/HttpSocket.kt new file mode 100644 index 0000000..bb1f022 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/http/HttpSocket.kt @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2024 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.util.sockets.http + +import io.github.thibaultbee.krtmp.rtmp.util.sockets.ISocket +import io.github.thibaultbee.krtmp.rtmp.util.sockets.http.HttpSocket.Companion.createRtmptClient +import io.ktor.client.HttpClient +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.headers +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.http.URLBuilder +import io.ktor.http.content.OutgoingContent +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.availableForRead +import io.ktor.utils.io.discard +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.cancellation.CancellationException + + +internal suspend fun HttpSocket( + urlBuilder: URLBuilder, +): HttpSocket { + val client = createRtmptClient() + try { + val sessionId = HttpSocket.connect(urlBuilder, client) + return HttpSocket(urlBuilder, client, sessionId) + } catch (e: Throwable) { + client.close() + throw e + } +} + +internal class HttpSocket internal constructor( + override val urlBuilder: URLBuilder, + private val client: HttpClient, + private val sessionId: String +) : + ISocket { + private val postByteReadChannels = mutableListOf() + + private var _index = 1L + private val index: Long + get() = _index++ + + override val coroutineContext = client.coroutineContext + override val socketContext = coroutineContext as CompletableJob + + override val isClosed: Boolean + get() = !client.isActive + + override var totalBytesRead: Long = 0 + private set + + override var totalBytesWritten: Long = 0 + private set + + override suspend fun write( + length: Long, + block: suspend (ByteWriteChannel) -> Unit + ) { + require(!isClosed) { "Connection is closed" } + + val response = + post( + "send/$sessionId/$index", + object : OutgoingContent.WriteChannelContent() { + override val contentLength = length + + override suspend fun writeTo(channel: ByteWriteChannel) { + block(channel) + } + }) + if (response.status != HttpStatusCode.OK) { + throw IllegalStateException("Send failed. Expected 200, got ${response.status}") + } + totalBytesWritten += length + + val body = response.bodyAsChannel().apply { + discard(1) // Discard first byte + } + if (body.availableForRead > 0) { + totalBytesRead += body.availableForRead + postByteReadChannels.add(body) + } + } + + override suspend fun read(block: suspend (ByteReadChannel) -> T): T { + require(!isClosed) { "Connection is closed" } + + val result = readMemory(block) + if (result != null) { + return result + } + + val coroutine = client.async { + var res: T? = null + while (isActive) { + val read = readMemory(block) + if (read != null) { + res = read + break + } + delay(500) + } + res ?: throw CancellationException() + } + return coroutine.await() + } + + private suspend fun readMemory(block: suspend (ByteReadChannel) -> T): T? { + return if (postByteReadChannels.isNotEmpty()) { + val body = postByteReadChannels.first() + val result = block(body) + if (body.availableForRead == 0) { + postByteReadChannels.removeFirst() + } + result + } else { + null + } + } + + override fun close() { + runBlocking { + post("close/$sessionId", ByteArray(0)) + } + client.close() + postByteReadChannels.clear() + } + + private suspend fun post(path: String, body: Any? = null) = + client.post(urlBuilder, path) { + setBody(body) + } + + companion object { + private val rtmptHeaders = mapOf( + "Content-Type" to "application/x-fcs", + "User-Agent" to "Shockwave Flash" + ) + + internal fun createRtmptClient(): HttpClient { + return HttpClient { + defaultRequest { + headers { + rtmptHeaders.forEach { (key, value) -> + append(key, value) + } + } + } + } + } + + internal suspend fun connect( + urlBuilder: URLBuilder, + client: HttpClient + ): String { + try { + var response = client.post(urlBuilder, "fcs/ident2") { + setBody(byteArrayOf(0x00)) + } + // Expected 404 but some servers return other error code + if (response.status.value !in 400..499) { + throw IllegalStateException("Connection failed. Expected 404, got ${response.status}") + } + + response = client.post(urlBuilder, "open/1") + if (response.status != HttpStatusCode.OK) { + throw IllegalStateException("Connection failed. Expected 200, got ${response.status}") + } + + val sessionId = response.bodyAsText().trimIndent() + + readIdle(urlBuilder, client, sessionId) {} + return sessionId + } catch (t: Throwable) { + throw t + } + } + + private suspend fun readIdle( + urlBuilder: URLBuilder, + client: HttpClient, + sessionId: String, + block: suspend (ByteReadChannel) -> T + ): T? { + return try { + val response = client.post(urlBuilder, "idle/$sessionId/0") + if (response.status != HttpStatusCode.OK) { + throw IllegalStateException("Send failed. Expected 200, got ${response.status}") + } + val body = response.bodyAsChannel().apply { + discard(1) // Discard first byte + } + if (body.availableForRead > 0) { + block(body) + } else { + null + } + } catch (t: Throwable) { + throw t + } + } + } +} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/tcp/TcpSocket.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/tcp/TcpSocket.kt new file mode 100644 index 0000000..de72195 --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/tcp/TcpSocket.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2024 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.krtmp.rtmp.util.sockets.tcp + +import io.github.thibaultbee.krtmp.rtmp.util.sockets.ISocket +import io.ktor.http.URLBuilder +import io.ktor.network.sockets.Connection +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.connection +import io.ktor.network.sockets.isClosed +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.CountedByteReadChannel +import io.ktor.utils.io.CountedByteWriteChannel +import kotlin.coroutines.CoroutineContext + +internal fun TcpSocket( + socket: Socket, + urlBuilder: URLBuilder +): TcpSocket = TcpSocket(socket.connection(), urlBuilder) + +/** + * TCP connection implementation of [ISocket]. + */ +internal open class TcpSocket( + private val connection: Connection, + override val urlBuilder: URLBuilder +) : ISocket { + private val input by lazy { + CountedByteReadChannel(connection.input) + } + private val output by lazy { + CountedByteWriteChannel(connection.output) + } + + override val coroutineContext: CoroutineContext + get() = connection.socket.socketContext + + override val socketContext = connection.socket.socketContext + + override val isClosed: Boolean + get() = connection.socket.isClosed + + override val totalBytesRead: Long + get() = input.totalBytesRead + + override val totalBytesWritten: Long + get() = output.totalBytesWritten + + override suspend fun write( + length: Long, + block: suspend (ByteWriteChannel) -> Unit + ) { + require(!isClosed) { "Connection is closed" } + block(output) + output.flush() + } + + override suspend fun read(block: suspend (ByteReadChannel) -> T): T { + require(!isClosed) { "Connection is closed" } + return block(input) + } + + override fun close() { + connection.socket.close() + } +} \ No newline at end of file diff --git a/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/tcp/TcpSocketFactory.kt b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/tcp/TcpSocketFactory.kt new file mode 100644 index 0000000..3a305ac --- /dev/null +++ b/rtmp/src/commonMain/kotlin/io/github/thibaultbee/krtmp/rtmp/util/sockets/tcp/TcpSocketFactory.kt @@ -0,0 +1,42 @@ +package io.github.thibaultbee.krtmp.rtmp.util.sockets.tcp + +import io.github.thibaultbee.krtmp.rtmp.extensions.isSecureRtmp +import io.ktor.http.URLBuilder +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.SocketAddress +import io.ktor.network.sockets.SocketOptions +import io.ktor.network.sockets.aSocket +import io.ktor.network.tls.tls +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +internal class TcpSocketFactory(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) { + private val selectorManager = SelectorManager(dispatcher) + + suspend fun client( + urlBuilder: URLBuilder, socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {} + ) = aSocket(selectorManager).tcp() + .connect(urlBuilder.host, urlBuilder.port, socketOptions).apply { + if (urlBuilder.protocol.isSecureRtmp) { + tls(dispatcher) + } + } + + suspend fun server( + urlBuilder: URLBuilder, socketOptions: SocketOptions.AcceptorOptions.() -> Unit = {} + ) = aSocket(selectorManager).tcp().bind(urlBuilder.host, urlBuilder.port, socketOptions) + + suspend fun server( + localAddress: SocketAddress?, socketOptions: SocketOptions.AcceptorOptions.() -> Unit = {} + ) = aSocket(selectorManager).tcp().bind(localAddress, socketOptions) + + + fun close() { + selectorManager.close() + } + + companion object { + internal val default = TcpSocketFactory(Dispatchers.IO) + } +} \ No newline at end of file diff --git a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/Resource.kt b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/Resource.kt index 384e6db..9079027 100644 --- a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/Resource.kt +++ b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/Resource.kt @@ -6,15 +6,13 @@ import kotlinx.io.files.SystemFileSystem import kotlinx.io.readByteArray private const val RESOURCE_PATH = "./src/commonTest/resources" -class ResourcePath(path: String) { - val path = Path("${RESOURCE_PATH}/${path}") -} + +fun ResourcePath(path: String) = Path("${RESOURCE_PATH}/${path}") fun Resource(path: String) = Resource(ResourcePath(path)) -class Resource(private val path: ResourcePath) { - private val source = SystemFileSystem.source(path.path) - fun toByteArray() = SystemFileSystem.source(path.path).buffered().readByteArray() +class Resource(val path: Path) { + fun toByteArray() = SystemFileSystem.source(path).buffered().readByteArray() - fun toSource() = SystemFileSystem.source(path.path).buffered() + fun toSource() = SystemFileSystem.source(path).buffered() } \ No newline at end of file diff --git a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensionsTest.kt b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensionsTest.kt index ea67ccf..122358c 100644 --- a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensionsTest.kt +++ b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/extensions/URLBuilderExtensionsTest.kt @@ -2,6 +2,7 @@ package io.github.thibaultbee.krtmp.rtmp.extensions import io.ktor.http.URLBuilder import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.fail class URLBuilderExtensionsTest { @@ -13,6 +14,7 @@ class URLBuilderExtensionsTest { URLBuilder("rtmp://192.168.1.12:1234/app/stream").validateRtmp() URLBuilder("rtmp://192.168.1.12/app/stream").validateRtmp() URLBuilder("rtmp://192.168.1.12/app/app2/stream").validateRtmp() + URLBuilder("rtmp://192.168.1.12/stream").validateRtmp() } catch (e: Exception) { fail("Exception thrown: ${e.message}", e) } @@ -21,7 +23,7 @@ class URLBuilderExtensionsTest { @Test fun `test invalid RTMP URL`() { try { - URLBuilder("rtmp://host:1234/app").validateRtmp() + URLBuilder("rtmp://host:1234/app/").validateRtmp() fail("Exception must be thrown for missing stream key") } catch (_: Exception) { } @@ -41,4 +43,16 @@ class URLBuilderExtensionsTest { } catch (_: Exception) { } } + + @Test + fun `test stream key`() { + assertEquals("stream", URLBuilder("rtmp://host:1234/app/stream").rtmpStreamKey) + assertEquals("stream2", URLBuilder("rtmp://192.168.1.12/stream2").rtmpStreamKey) + + try { + URLBuilder("rtmp://192.168.1.12/stream2/").rtmpStreamKey + fail("Exception must be thrown for missing stream key") + } catch (_: Exception) { + } + } } \ No newline at end of file diff --git a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AudioTest.kt b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AudioTest.kt index 77af7b2..17fab88 100644 --- a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AudioTest.kt +++ b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AudioTest.kt @@ -19,7 +19,7 @@ class AudioTest { val writeChannel = ByteChannel(false) - audio.write(writeChannel, 128, Audio(0, 10, Buffer())) // Empty previous audio + audio.write(writeChannel, 128, Audio(0, 10, Buffer(), 0)) // Empty previous audio writeChannel.flush() val actual = ByteArray(writeChannel.availableForRead) @@ -33,11 +33,11 @@ class AudioTest { val expected = Resource("frames/audio/aac/sequence/expected").toByteArray() val raw = Resource("frames/audio/aac/sequence/sequence").toByteArray() - val audio = Audio(78, 10, Buffer().apply { write(raw) }) + val audio = Audio(78, 10, Buffer().apply { write(raw) }, raw.size) val writeChannel = ByteChannel(false) - audio.write(writeChannel, 128, Audio(0, 10, Buffer())) + audio.write(writeChannel, 128, Audio(0, 10, Buffer(), 0)) writeChannel.flush() val actual = ByteArray(writeChannel.availableForRead) diff --git a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/CommandTest.kt b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/CommandTest.kt index 3a0944e..0827e67 100644 --- a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/CommandTest.kt +++ b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/CommandTest.kt @@ -1,8 +1,12 @@ package io.github.thibaultbee.krtmp.rtmp.messages -import io.github.thibaultbee.krtmp.amf.Amf import io.github.thibaultbee.krtmp.amf.AmfVersion -import io.github.thibaultbee.krtmp.common.MimeType +import io.github.thibaultbee.krtmp.flv.config.AudioMediaType +import io.github.thibaultbee.krtmp.flv.config.VideoMediaType +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObjectBuilder +import io.github.thibaultbee.krtmp.rtmp.messages.command.ObjectEncoding +import io.github.thibaultbee.krtmp.rtmp.util.AmfUtil.amf import io.ktor.utils.io.ByteChannel import io.ktor.utils.io.availableForRead import io.ktor.utils.io.readAvailable @@ -17,21 +21,29 @@ class CommandTest { fun `encode amf0 connect object`() = runTest { val expected = "030003617070020007746573744170700008666c61736856657202000c74657374466c6173685665720005746355726c02000974657374546355726c000673776655726c02000a7465737453776655726c0004667061640100000c6361706162696c697469657300406de00000000000000b617564696f436f64656373004096000000000000000b766964656f436f64656373004060800000000000000a666f757243634c6973740a0000000102000468766331000d766964656f46756e6374696f6e00000000000000000000077061676555726c02000b746573745061676555726c000e6f626a656374456e636f64696e67000000000000000000000009" - val connectObject = Command.Connect.ConnectObject( + val connectObjectBuilder = ConnectObjectBuilder( app = "testApp", flashVer = "testFlashVer", tcUrl = "testTcUrl", swfUrl = "testSwfUrl", fpad = false, capabilities = 239, - audioCodecs = listOf(MimeType.AUDIO_G711A, MimeType.AUDIO_G711U, MimeType.AUDIO_AAC), - videoCodecs = listOf(MimeType.VIDEO_AVC, MimeType.VIDEO_H263, MimeType.VIDEO_HEVC), + audioCodecs = listOf( + AudioMediaType.G711_ALAW, + AudioMediaType.G711_MLAW, + AudioMediaType.AAC + ), + videoCodecs = listOf( + VideoMediaType.AVC, + VideoMediaType.SORENSON_H263, + VideoMediaType.HEVC + ), videoFunction = emptyList(), pageUrl = "testPageUrl", - objectEncoding = Command.Connect.ObjectEncoding.AMF0 + objectEncoding = ObjectEncoding.AMF0 ) val actual = - Amf.encodeToByteArray(Command.Connect.ConnectObject.serializer(), connectObject) + amf.encodeToByteArray(ConnectObject.serializer(), connectObjectBuilder.build()) assertEquals(expected, actual.toHexString()) } @@ -40,24 +52,32 @@ class CommandTest { fun `write amf0 connect command`() = runTest { val expected = "020000000001121400000000020007636f6e6e656374003ff0000000000000030003617070020007746573744170700008666c61736856657202000c74657374466c6173685665720005746355726c02000974657374546355726c000673776655726c02000a7465737453776655726c0004667061640100000c6361706162696c697469657300406de00000c2000000000b617564696f436f64656373004096000000000000000b766964656f436f64656373004060800000000000000a666f757243634c6973740a0000000102000468766331000d766964656f46756e6374696f6e00000000000000000000077061676555726c02000b746573745061676555726c000e6f626a656374456ec2636f64696e67000000000000000000000009" - val connectObject = Command.Connect.ConnectObject( + val connectObjectBuilder = ConnectObjectBuilder( app = "testApp", flashVer = "testFlashVer", tcUrl = "testTcUrl", swfUrl = "testSwfUrl", fpad = false, capabilities = 239, - audioCodecs = listOf(MimeType.AUDIO_G711A, MimeType.AUDIO_G711U, MimeType.AUDIO_AAC), - videoCodecs = listOf(MimeType.VIDEO_AVC, MimeType.VIDEO_H263, MimeType.VIDEO_HEVC), + audioCodecs = listOf( + AudioMediaType.G711_ALAW, + AudioMediaType.G711_MLAW, + AudioMediaType.AAC + ), + videoCodecs = listOf( + VideoMediaType.AVC, + VideoMediaType.SORENSON_H263, + VideoMediaType.HEVC + ), videoFunction = emptyList(), pageUrl = "testPageUrl", - objectEncoding = Command.Connect.ObjectEncoding.AMF0 + objectEncoding = ObjectEncoding.AMF0 ) - val connectCommand = Command.Connect( + val connectCommand = CommandConnect( transactionId = 1, timestamp = 0, - connectObject = connectObject + connectObject = connectObjectBuilder.build() ) val writeChannel = ByteChannel(false) connectCommand.write(writeChannel, AmfVersion.AMF0) @@ -108,12 +128,12 @@ class CommandTest { 0x05 ) - val previousMessage = Command.FCPublish( + val previousMessage = CommandFCPublish( transactionId = 3, timestamp = 0, streamKey = "streamKey" ).createMessage(AmfVersion.AMF0) - val createStreamCommand = Command.CreateStream( + val createStreamCommand = CommandCreateStream( timestamp = 0, transactionId = 4, ) diff --git a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/VideoTest.kt b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/VideoTest.kt index e055bab..ccc7967 100644 --- a/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/VideoTest.kt +++ b/rtmp/src/commonTest/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/VideoTest.kt @@ -11,7 +11,7 @@ import kotlin.test.assertContentEquals class VideoTest { @Test - fun `write video after a sequence header with same timestamp`() = runTest { + fun `write video after a sequence header with same timestamp from buffer`() = runTest { val expected = Resource("frames/video/avc/key/expected").toByteArray() val raw = Resource("frames/video/avc/key/raw").toByteArray() @@ -19,7 +19,25 @@ class VideoTest { val writeChannel = ByteChannel(false) - video.write(writeChannel, 128, Video(0, 10, Buffer())) // Empty previous audio + video.write(writeChannel, 128, Video(0, 10, Buffer(), 0)) // Empty previous video + writeChannel.flush() + + val actual = ByteArray(writeChannel.availableForRead) + writeChannel.readAvailable(actual, 0, actual.size) + + assertContentEquals(expected, actual) + } + + @Test + fun `write video after a sequence header with same timestamp from byte array`() = runTest { + val expected = Resource("frames/video/avc/key/expected").toByteArray() + + val raw = Resource("frames/video/avc/key/raw").toByteArray() + val video = Video(0, 10, raw) + + val writeChannel = ByteChannel(false) + + video.write(writeChannel, 128, Video(0, 10, byteArrayOf(), 0)) // Empty previous video writeChannel.flush() val actual = ByteArray(writeChannel.availableForRead) diff --git a/rtmp/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AudioJvmAndroid.kt b/rtmp/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AudioJvmAndroid.kt new file mode 100644 index 0000000..49c2466 --- /dev/null +++ b/rtmp/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/AudioJvmAndroid.kt @@ -0,0 +1,26 @@ +package io.github.thibaultbee.krtmp.rtmp.messages + +import io.github.thibaultbee.krtmp.flv.sources.ByteBufferBackedRawSource +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId +import java.nio.ByteBuffer + +/** + * Creates a audio message with a [ByteBuffer] payload. + * + * @param timestamp The timestamp of the message. + * @param messageStreamId The stream ID of the message. + * @param payload The byte buffer containing the audio data. + * @param chunkStreamId The chunk stream ID for this message, defaulting to the video channel. + */ +fun Audio( + timestamp: Int, + messageStreamId: Int, + payload: ByteBuffer, + chunkStreamId: Int = ChunkStreamId.AUDIO_CHANNEL.value +) = Audio( + timestamp = timestamp, + messageStreamId = messageStreamId, + payload = ByteBufferBackedRawSource(payload), + payloadSize = payload.remaining(), + chunkStreamId = chunkStreamId +) \ No newline at end of file diff --git a/rtmp/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/VideoJvmAndroid.kt b/rtmp/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/VideoJvmAndroid.kt new file mode 100644 index 0000000..b36faad --- /dev/null +++ b/rtmp/src/jvmMain/kotlin/io/github/thibaultbee/krtmp/rtmp/messages/VideoJvmAndroid.kt @@ -0,0 +1,26 @@ +package io.github.thibaultbee.krtmp.rtmp.messages + +import io.github.thibaultbee.krtmp.flv.sources.ByteBufferBackedRawSource +import io.github.thibaultbee.krtmp.rtmp.messages.chunk.ChunkStreamId +import java.nio.ByteBuffer + +/** + * Creates a video message with a [ByteBuffer] payload. + * + * @param timestamp The timestamp of the message. + * @param messageStreamId The stream ID of the message. + * @param payload The byte buffer containing the video data. + * @param chunkStreamId The chunk stream ID for this message, defaulting to the video channel. + */ +fun Video( + timestamp: Int, + messageStreamId: Int, + payload: ByteBuffer, + chunkStreamId: Int = ChunkStreamId.VIDEO_CHANNEL.value +) = Video( + timestamp = timestamp, + messageStreamId = messageStreamId, + payload = ByteBufferBackedRawSource(payload), + payloadSize = payload.remaining(), + chunkStreamId = chunkStreamId +) \ No newline at end of file diff --git a/samples/flvparser-cli/README.md b/samples/flvparser-cli/README.md new file mode 100644 index 0000000..d329b2b --- /dev/null +++ b/samples/flvparser-cli/README.md @@ -0,0 +1,20 @@ +# FLVParser cli + +In this directory, you can find a simple command line interface (CLI) for the `flv` library. This +CLI allows you to parse FLV files and extract information about the tags contained within them. + +## Installation + +Run the `gradlew` command to install: + +```bash +./gradlew --quiet ":samples:flvparser-cli:installDist" +``` + +## Usage + +Run the `flvparser-cli` command with the path to the FLV file you want to parse. For example: + +```bash +./samples/flvparser-cli/build/install/flvparser-cli/bin/flvparser-cli -i /path/to/your/file.flv +``` diff --git a/samples/flvparser-cli/build.gradle.kts b/samples/flvparser-cli/build.gradle.kts new file mode 100644 index 0000000..d046d1d --- /dev/null +++ b/samples/flvparser-cli/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + kotlin("jvm") + application +} + +application { + mainClass.set("io.github.thibaultbee.krtmp.flvparser.cli.MainKt") +} + +dependencies { + implementation(project(":flv")) + + implementation(libs.kotlinx.io.core) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.clikt) + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(21) +} \ No newline at end of file diff --git a/samples/flvparser-cli/src/main/kotlin/Main.kt b/samples/flvparser-cli/src/main/kotlin/Main.kt new file mode 100644 index 0000000..04a433b --- /dev/null +++ b/samples/flvparser-cli/src/main/kotlin/Main.kt @@ -0,0 +1,93 @@ +package io.github.thibaultbee.krtmp.flvparser.cli + +import com.github.ajalt.clikt.command.SuspendingCliktCommand +import com.github.ajalt.clikt.command.main +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import io.github.thibaultbee.krtmp.flv.FLVDemuxer +import io.github.thibaultbee.krtmp.flv.decodeAllRaw +import io.github.thibaultbee.krtmp.flv.tags.FLVData +import io.github.thibaultbee.krtmp.flv.tags.FLVTag +import io.github.thibaultbee.krtmp.flv.tags.audio.ExtendedAudioData +import io.github.thibaultbee.krtmp.flv.tags.audio.LegacyAudioData +import io.github.thibaultbee.krtmp.flv.tags.script.ScriptDataObject +import io.github.thibaultbee.krtmp.flv.tags.video.ExtendedVideoData +import io.github.thibaultbee.krtmp.flv.tags.video.LegacyVideoData +import kotlinx.io.files.Path + +class FLVParserCli : SuspendingCliktCommand() { + override fun help(context: Context): String { + return "Parse a FLV file" + } + + private val filePath: String by option("-i", "--input", help = "The FLV file to parse") + .required() + + private fun prettyTag(index: Int, tag: FLVTag): String { + return """ + |FLV Tag: [$index] + | Type: ${tag.type} + | Timestamp: ${tag.timestampMs} ms + | Data: [${prettyData(tag.data)}] + """.trimMargin() + } + + private fun prettyData(data: FLVData): String { + return when (data) { + is LegacyAudioData -> { + """ + |Legacy Audio Data: Sound Format: ${data.soundFormat} Sound Rate: ${data.soundRate} Sound Size: ${data.soundSize} Sound Type: ${data.soundType} Body: ${data.body} + """.trimMargin() + } + + is ExtendedAudioData -> { + """ + |Extended Audio Data: Packet Type: ${data.packetType} Packet Descriptor: ${data.packetDescriptor} ModExs: ${data.modExs} Body: ${data.body} + """.trimMargin() + } + + is LegacyVideoData -> { + """ + |Legacy Video Data: Codec ID: ${data.codecID} Frame Type: ${data.frameType} AVCPacketType: ${data.packetType} Composition Time: ${data.compositionTime} Body: ${data.body} + """.trimMargin() + } + + is ExtendedVideoData -> { + """ + |Extended Video Data: Packet Type: ${data.packetType} Frame Type: ${data.frameType} Packet Descriptor: ${data.packetDescriptor} ModExs: ${data.modExs} Body: ${data.body} + """.trimMargin() + } + + is ScriptDataObject -> { + """ + |Script Data Object: Name: ${data.name} Value: ${data.value} + """.trimMargin() + } + + else -> data.toString() + } + } + + override suspend fun run() { + require(filePath.endsWith(".flv")) { "The file must be a .flv file" } + + echo("Parsing FLV file: $filePath") + val path = Path(filePath) + val parser = FLVDemuxer(path = path) + val header = parser.decodeFlvHeader() + echo("Parsed FLV header: $header") + var i = 0 + + parser.decodeAllRaw { tag -> + try { + val decodedTag = tag.decode() + echo(prettyTag(i++, decodedTag)) + } catch (t: Throwable) { + echo("${i++}: failed to decode: ${t.message}") + } + } + } +} + +suspend fun main(args: Array) = FLVParserCli().main(args) \ No newline at end of file diff --git a/samples/rtmpclient-cli/README.md b/samples/rtmpclient-cli/README.md new file mode 100644 index 0000000..0f05fe2 --- /dev/null +++ b/samples/rtmpclient-cli/README.md @@ -0,0 +1,20 @@ +# RTMPServer cli + +In this directory, you can find a command line interface (CLI) for the `RtmpClient`. This CLI allows +you to run the RTMPServer in a standalone mode, which is useful for testing and debugging purposes. + +## Installation + +Run the `gradlew` command to install: + +```bash +./gradlew --quiet ":samples:rtmpclient-cli:installDist" +``` + +## Usage + +Run the `rtmpclient-cli` command with the listening address. For example: + +```bash +./samples/rtmpclient-cli/build/install/rtmpclient-cli/bin/rtmpclient-cli 192.168.1.11:1935 -i /path/to/your/file.flv +``` diff --git a/samples/rtmpclient-cli/build.gradle.kts b/samples/rtmpclient-cli/build.gradle.kts new file mode 100644 index 0000000..d8309aa --- /dev/null +++ b/samples/rtmpclient-cli/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + kotlin("jvm") + application +} + +application { + mainClass.set("io.github.thibaultbee.krtmp.rtmpclient.cli.MainKt") +} + +dependencies { + implementation(project(":rtmp")) + + implementation(libs.kotlinx.io.core) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.clikt) + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(21) +} \ No newline at end of file diff --git a/samples/rtmpclient-cli/src/main/kotlin/Main.kt b/samples/rtmpclient-cli/src/main/kotlin/Main.kt new file mode 100644 index 0000000..f67ce9f --- /dev/null +++ b/samples/rtmpclient-cli/src/main/kotlin/Main.kt @@ -0,0 +1,114 @@ +package io.github.thibaultbee.krtmp.rtmpclient.cli + +import com.github.ajalt.clikt.command.SuspendingCliktCommand +import com.github.ajalt.clikt.command.main +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import io.github.thibaultbee.krtmp.common.logger.IKrtmpLogger +import io.github.thibaultbee.krtmp.common.logger.KrtmpLogger +import io.github.thibaultbee.krtmp.flv.FLVDemuxer +import io.github.thibaultbee.krtmp.flv.decodeAll +import io.github.thibaultbee.krtmp.flv.decodeAllRaw +import io.github.thibaultbee.krtmp.rtmp.client.RtmpClient +import kotlinx.io.files.Path + +class RTMPClientCli : SuspendingCliktCommand() { + init { + KrtmpLogger.logger = EchoLogger() + } + + override fun help(context: Context): String { + return "Send a FLV file to a RTMP server on the specified address" + } + + private val filePath: String by option("-i", "--input", help = "The FLV file to send") + .required() + private val rtmpUrlPath: String by argument( + "RTMP URL", + help = "The RTMP URL to send the FLV file to (expected format: rtmp://://)" + ) + + override suspend fun run() { + echo("Trying to open file $filePath") + val path = Path(filePath) + val parser = FLVDemuxer(path = path) + + echo("Trying to connect to $rtmpUrlPath") + // Create the RTMP client + val client = RtmpClient(rtmpUrlPath) + try { + val result = client.connect { + // Configure the connect object + // videoCodecs = listOf(VideoMediaType.AVC) + } + echo("Connected to RTMP server: $result") + + client.createStream() + client.publish() + } catch (t: Throwable) { + echo("Error connecting to connect server: ${t.message}") + client.close() + throw t + } + + // Read the FLV file and send it to the RTMP server + try { + echo("Sending FLV file: $filePath") + + val header = parser.decodeFlvHeader() + echo("Parsed FLV header: $header") + + /* + parser.decodeAllRaw { tag -> + echo("Sending: $tag") + client.write(tag) + }*/ + parser.decodeAll { tag -> + echo("Sending: $tag") + client.write(tag) + } + } catch (t: Throwable) { + echo("Error reading FLV file: ${t.message}") + client.close() + throw t + } + + // Close the connection gracefully + try { + echo("Closing connection to the server") + client.deleteStream() + client.close() + } catch (t: Throwable) { + echo("Error connecting to close connection to the server: ${t.message}") + client.close() + throw t + } + } + + + private inner class EchoLogger : IKrtmpLogger { + override fun e(tag: String, message: String, tr: Throwable?) { + echo("E[$tag] $message") + } + + override fun w(tag: String, message: String, tr: Throwable?) { + echo("W[$tag] $message") + } + + override fun i(tag: String, message: String, tr: Throwable?) { + echo("I[$tag] $message") + } + + override fun v(tag: String, message: String, tr: Throwable?) { + echo("V[$tag] $message") + } + + override fun d(tag: String, message: String, tr: Throwable?) { + echo("D[$tag] $message") + } + } +} + +suspend fun main(args: Array) = RTMPClientCli().main(args) \ No newline at end of file diff --git a/samples/rtmpserver-cli/README.md b/samples/rtmpserver-cli/README.md new file mode 100644 index 0000000..1cca792 --- /dev/null +++ b/samples/rtmpserver-cli/README.md @@ -0,0 +1,20 @@ +# RTMPServer cli + +In this directory, you can find a command line interface (CLI) for the `RtmpServer`. This CLI allows +you to run the RTMPServer in a standalone mode, which is useful for testing and debugging purposes. + +## Installation + +Run the `gradlew` command to install: + +```bash +./gradlew --quiet ":samples:rtmpserver-cli:installDist" +``` + +## Usage + +Run the `rtmpserver-cli` command with the listening address. For example: + +```bash +./samples/rtmpserver-cli/build/install/rtmpserver-cli/bin/rtmpserver-cli 0.0.0.0:1935 +``` diff --git a/samples/rtmpserver-cli/build.gradle.kts b/samples/rtmpserver-cli/build.gradle.kts new file mode 100644 index 0000000..001b333 --- /dev/null +++ b/samples/rtmpserver-cli/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + kotlin("jvm") + application +} + +application { + mainClass.set("io.github.thibaultbee.krtmp.rtmpserver.cli.MainKt") +} + +dependencies { + implementation(project(":rtmp")) + + implementation(libs.kotlinx.io.core) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.clikt) + + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} +kotlin { + jvmToolchain(21) +} \ No newline at end of file diff --git a/samples/rtmpserver-cli/src/main/kotlin/Main.kt b/samples/rtmpserver-cli/src/main/kotlin/Main.kt new file mode 100644 index 0000000..1322d86 --- /dev/null +++ b/samples/rtmpserver-cli/src/main/kotlin/Main.kt @@ -0,0 +1,145 @@ +package io.github.thibaultbee.krtmp.rtmpserver.cli + +import com.github.ajalt.clikt.command.SuspendingCliktCommand +import com.github.ajalt.clikt.command.main +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.argument +import io.github.thibaultbee.krtmp.amf.elements.containers.AmfContainer +import io.github.thibaultbee.krtmp.amf.elements.containers.AmfEcmaArray +import io.github.thibaultbee.krtmp.common.logger.IKrtmpLogger +import io.github.thibaultbee.krtmp.common.logger.KrtmpLogger +import io.github.thibaultbee.krtmp.flv.tags.script.OnMetadata +import io.github.thibaultbee.krtmp.rtmp.messages.Audio +import io.github.thibaultbee.krtmp.rtmp.messages.Command +import io.github.thibaultbee.krtmp.rtmp.messages.command.ConnectObject +import io.github.thibaultbee.krtmp.rtmp.messages.DataAmf +import io.github.thibaultbee.krtmp.rtmp.messages.Message +import io.github.thibaultbee.krtmp.rtmp.messages.Video +import io.github.thibaultbee.krtmp.rtmp.messages.decode +import io.github.thibaultbee.krtmp.rtmp.server.RtmpServer +import io.github.thibaultbee.krtmp.rtmp.server.RtmpServerCallback +import io.github.thibaultbee.krtmp.rtmp.util.AmfUtil.amf + +class RTMPServerCli : SuspendingCliktCommand() { + init { + KrtmpLogger.logger = EchoLogger() + } + + override fun help(context: Context): String { + return "Launches a RTMP server on the specified address" + } + + private val address: String by argument( + "address", + help = "The address to bind the RTMP server to" + ) + + override suspend fun run() { + echo("RTMP server listening on $address") + + // Create the RTMP server + val server = RtmpServer(address, object : RtmpServerCallback { + override fun onConnect(connect: Command) { + echo("Client connected: ${connect.commandObject}") + + // Deserialize the connect object + connect.commandObject?.let { + val connectObject = + amf.decodeFromAmfElement(ConnectObject.serializer(), it) + echo("Connect object: $connectObject") + } + } + + override fun onCreateStream(createStream: Command) { + echo("Stream created: $createStream") + } + + override fun onReleaseStream(releaseStream: Command) { + echo("Stream released: $releaseStream") + } + + override fun onDeleteStream(deleteStream: Command) { + echo("Stream deleted: $deleteStream") + } + + override fun onPublish(publish: Command) { + echo("Stream published: $publish") + } + + override fun onPlay(play: Command) { + echo("Stream played: $play") + } + + override fun onFCPublish(fcPublish: Command) { + echo("Stream FCPublished: $fcPublish") + } + + override fun onFCUnpublish(fcUnpublish: Command) { + echo("Stream FCUnpublished: $fcUnpublish") + } + + override fun onCloseStream(closeStream: Command) { + echo("Stream close: $closeStream") + } + + override fun onSetDataFrame(setDataFrame: DataAmf) { + echo("Set data frame: $setDataFrame") + + val parameters = setDataFrame.parameters + // Deserialize the onMetadata object + if ((parameters is AmfContainer) && (parameters.size >= 2)) { + val onMetadata = OnMetadata.Metadata.decode(parameters[1] as AmfEcmaArray) + echo("onMetadata: $onMetadata") + } + } + + override fun onAudio(audio: Audio) { + echo("Audio data received: ${audio.decode()}") + } + + override fun onVideo(video: Video) { + echo("Video data received: ${video.decode()}") + } + + override fun onUnknownMessage(message: Message) { + echo("Unknown message received: $message") + } + + override fun onUnknownCommandMessage(command: Command) { + echo("Unknown command received: $command") + } + + override fun onUnknownDataMessage(data: DataAmf) { + echo("Unknown data message received: $data") + } + }) + + // Start the RTMP server + server.listen() + } + + + private inner class EchoLogger : IKrtmpLogger { + override fun e(tag: String, message: String, tr: Throwable?) { + echo("E[$tag] $message") + } + + override fun w(tag: String, message: String, tr: Throwable?) { + echo("W[$tag] $message") + } + + override fun i(tag: String, message: String, tr: Throwable?) { + echo("I[$tag] $message") + } + + override fun v(tag: String, message: String, tr: Throwable?) { + echo("V[$tag] $message") + } + + override fun d(tag: String, message: String, tr: Throwable?) { + echo("D[$tag] $message") + } + } +} + +suspend fun main(args: Array) = RTMPServerCli().main(args) \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b500134..47778d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,9 @@ pluginManagement { mavenCentral() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} dependencyResolutionManagement { repositories { @@ -18,4 +21,7 @@ rootProject.name = "krtmp" include(":rtmp") include(":flv") include(":amf") -include(":common") \ No newline at end of file +include(":common") +include("samples:flvparser-cli") +include("samples:rtmpserver-cli") +include("samples:rtmpclient-cli")