Repository: tipsy/javalin
Pull request #869 (2020-01-30 22:26:22)

[media-streaming] Add media streaming

tipsy: @dessalines @franklinexpress It's been a few years, and this is a different project, but I would really appreciate it if you have a quick look and see if this seems right. I used your code as a starting point @franklinexpress.

Patch:
diff --git a/src/main/java/io/javalin/http/Context.kt b/src/main/java/io/javalin/http/Context.kt
index cf3311c4..58fb3708 100644
--- a/src/main/java/io/javalin/http/Context.kt
+++ b/src/main/java/io/javalin/http/Context.kt
@@ -13,6 +13,7 @@ import io.javalin.core.validation.Validator
 import io.javalin.http.util.ContextUtil
 import io.javalin.http.util.CookieStore
 import io.javalin.http.util.MultipartUtil
+import io.javalin.http.util.SeekableWriter
 import io.javalin.plugin.json.JavalinJson
 import io.javalin.plugin.rendering.JavalinRenderer
 import java.io.InputStream
@@ -361,6 +362,9 @@ open class Context(@JvmField val req: HttpServletRequest, @JvmField val res: Htt
         return this
     }
 
+    /** Writes the specified inputStream as a seekable stream */
+    fun seekableStream(inputStream: InputStream, contentType: String) = SeekableWriter.write(this, inputStream, contentType)
+
     /** Gets the current context result as an [InputStream] (if set). */
     fun resultStream(): InputStream? = resultStream
 
diff --git a/src/main/java/io/javalin/http/util/SeekableWriter.kt b/src/main/java/io/javalin/http/util/SeekableWriter.kt
new file mode 100644
index 00000000..1bb57881
--- /dev/null
+++ b/src/main/java/io/javalin/http/util/SeekableWriter.kt
@@ -0,0 +1,41 @@
+package io.javalin.http.util
+
+import io.javalin.core.util.Header
+import io.javalin.http.Context
+import java.io.InputStream
+import java.io.OutputStream
+import kotlin.math.min
+
+object SeekableWriter {
+    var chunkSize = 128000
+    fun write(ctx: Context, inputStream: InputStream, contentType: String) {
+        if (ctx.header(Header.RANGE) == null) {
+            ctx.result(inputStream)
+            return
+        }
+        val totalBytes = inputStream.available()
+        val requestedRange = ctx.header(Header.RANGE)!!.split("=")[1].split("-").filter { it.isNotEmpty() }
+        val from = requestedRange[0].toInt()
+        val to = when {
+            from + chunkSize > totalBytes -> totalBytes // chunk bigger than file, write all
+            requestedRange.size == 2 -> min(requestedRange[1].toInt(), totalBytes) // chunk smaller than file, to/from specified
+            else -> from + chunkSize // chunk smaller then file, to/from not specified
+        }
+        ctx.status(206)
+        ctx.header(Header.CONTENT_TYPE, contentType)
+        ctx.header(Header.ACCEPT_RANGES, "bytes")
+        ctx.header(Header.CONTENT_RANGE, "bytes $from-$to/$totalBytes")
+        ctx.header(Header.CONTENT_LENGTH, "${to - from}")
+        ctx.res.outputStream.write(inputStream, from, to)
+    }
+
+    private fun OutputStream.write(inputStream: InputStream, from: Int, to: Int, buffer: ByteArray = ByteArray(1024)) = inputStream.use {
+        it.skip(from.toLong())
+        var bytesLeft = to - from
+        while (bytesLeft != 0) {
+            val read = it.read(buffer, 0, min(buffer.size, bytesLeft))
+            this.write(buffer, 0, read)
+            bytesLeft -= read
+        }
+    }
+}
diff --git a/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt b/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
index 042e1770..a0813624 100644
--- a/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
+++ b/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
@@ -62,7 +62,7 @@ object JavalinVue {
     }
 
     private fun String.replaceWebjarsWithCdn() =
-            this.replace("@cdnWebjar/", if (useCdn) "//cdn.jsdelivr.net/webjars/" else "/webjars/")
+            this.replace("@cdnWebjar/", if (useCdn) "//cdn.jsdelivr.net/webjars/org.webjars.npm/" else "/webjars/")
 
 }
 


Test patch:
diff --git a/src/test/java/io/javalin/TestResponse.kt b/src/test/java/io/javalin/TestResponse.kt
index d373df3f..59739a09 100644
--- a/src/test/java/io/javalin/TestResponse.kt
+++ b/src/test/java/io/javalin/TestResponse.kt
@@ -8,7 +8,9 @@
 package io.javalin
 
 import com.mashape.unirest.http.HttpMethod
+import com.mashape.unirest.http.Unirest
 import io.javalin.core.util.Header
+import io.javalin.http.util.SeekableWriter
 import org.apache.commons.io.FileUtils
 import org.apache.commons.io.IOUtils
 import org.assertj.core.api.Assertions.assertThat
@@ -42,7 +44,7 @@ class TestResponse {
     fun `setting a byte array result works`() = TestUtil.test { app, http ->
         val bytes = ByteArray(512)
 
-        for(i in 0 until 512) {
+        for (i in 0 until 512) {
             bytes[i] = (i % 256).toByte()
         }
 
@@ -147,4 +149,42 @@ class TestResponse {
         assertThat(http.getBody("/test")).isEqualTo(result)
     }
 
+    private fun getSeekableInput(repeats: Int = SeekableWriter.chunkSize) = ByteArrayInputStream(
+            setOf("a", "b", "c").joinToString("") { it.repeat(repeats) }.toByteArray(Charsets.UTF_8)
+    )
+
+    @Test
+    fun `seekable - range works`() = TestUtil.test { app, http ->
+        app.get("/seekable") { ctx -> ctx.seekableStream(getSeekableInput(), "text/plain") }
+        val response = Unirest.get(http.origin + "/seekable")
+                .headers(mapOf(Header.RANGE to "bytes=${SeekableWriter.chunkSize}-${SeekableWriter.chunkSize * 2 - 1}"))
+                .asString().body
+        assertThat(response).doesNotContain("a").contains("b").doesNotContain("c")
+    }
+
+    @Test
+    fun `seekable - no-range works`() = TestUtil.test { app, http ->
+        app.get("/seekable-2") { ctx -> ctx.seekableStream(getSeekableInput(), "text/plain") }
+        val response = Unirest.get(http.origin + "/seekable-2").asString().body
+        assertThat(response.length).isEqualTo(getSeekableInput().available())
+    }
+
+    @Test
+    fun `seekable - overreaching range works`() = TestUtil.test { app, http ->
+        app.get("/seekable-3") { ctx -> ctx.seekableStream(getSeekableInput(), "text/plain") }
+        val response = Unirest.get(http.origin + "/seekable-3")
+                .headers(mapOf(Header.RANGE to "bytes=0-${SeekableWriter.chunkSize * 4}"))
+                .asString().body
+        assertThat(response.length).isEqualTo(SeekableWriter.chunkSize * 3)
+    }
+
+    @Test
+    fun `seekable - file smaller than chunksize works`() = TestUtil.test { app, http ->
+        app.get("/seekable-4") { ctx -> ctx.seekableStream(getSeekableInput(repeats = 50), "text/plain") }
+        val response = Unirest.get(http.origin + "/seekable-4")
+                .headers(mapOf(Header.RANGE to "bytes=0-${SeekableWriter.chunkSize}"))
+                .asString().body
+        assertThat(response.length).isEqualTo(150)
+    }
+
 }
diff --git a/src/test/java/io/javalin/TestWebBrowser.kt b/src/test/java/io/javalin/TestWebBrowser.kt
index 5d9b020b..e36920a5 100644
--- a/src/test/java/io/javalin/TestWebBrowser.kt
+++ b/src/test/java/io/javalin/TestWebBrowser.kt
@@ -8,6 +8,7 @@ package io.javalin
 
 import io.github.bonigarcia.wdm.WebDriverManager
 import io.javalin.core.compression.Brotli
+import io.javalin.http.util.SeekableWriter.chunkSize
 import io.javalin.misc.captureStdOut
 import org.assertj.core.api.AssertionsForClassTypes.assertThat
 import org.junit.AfterClass
@@ -17,7 +18,9 @@ import org.junit.Test
 import org.openqa.selenium.WebDriver
 import org.openqa.selenium.chrome.ChromeDriver
 import org.openqa.selenium.chrome.ChromeOptions
+import java.io.File
 import java.util.*
+import kotlin.math.ceil
 
 class TestWebBrowser {
 
@@ -47,10 +50,9 @@ class TestWebBrowser {
     }
 
     @Test
-    fun `hello world works in chrome`() {
-        val app = Javalin.create().start(0)
+    fun `hello world works in chrome`()  = TestUtil.test { app, http ->
         app.get("/hello") { ctx -> ctx.result("Hello, Selenium!") }
-        driver.get("http://localhost:" + app.port() + "/hello")
+        driver.get(http.origin + "/hello")
         assertThat(driver.pageSource).contains("Hello, Selenium")
     }
 
@@ -66,6 +68,21 @@ class TestWebBrowser {
         assertThat(driver.pageSource).contains(payload)
         assertThat(logResult).contains("Content-Encoding=br")
         assertThat(logResult).contains("Body is brotlied (${payload.length} bytes, not logged)")
+        app.stop();
+    }
+
+    @Test
+    fun `seeking works in chrome`() = TestUtil.test { app, http ->
+        chunkSize = 30000
+        val file = File("src/test/resources/upload-test/sound.mp3")
+        var chunkCount = 0
+        app.get("/file") { it.seekableStream(file.inputStream(), "audio/mpeg").also { chunkCount++ } }
+        app.get("/audio-player") { it.html("""<audio src="/file"></audio>""")}
+        driver.get(http.origin + "/audio-player")
+        val expectedChunkCount = ceil(file.inputStream().available() / chunkSize.toDouble()).toInt()
+        assertThat(chunkCount).isEqualTo(expectedChunkCount)
+        println("Chunk count was '$chunkCount' when seeking in Chrome")
+        chunkSize = 128000
     }
 
 }