mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
fix(stream-proxy): stream upstream audio instead of buffering the whole track
Ktor's request() buffers the full body before returning, so ExoPlayer timed
out waiting for headers. Use prepareRequest().execute{} and tee the body to
the cache file, committing only complete 200 responses. Treat player
disconnects (broken pipe) as debug-level noise rather than upstream errors.
This commit is contained in:
parent
ba62ff03c3
commit
61dd89f3ed
@ -157,12 +157,12 @@ class CacheManager(
|
|||||||
suspend fun commitCacheEntry(
|
suspend fun commitCacheEntry(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
allBytes: ByteArray,
|
sizeBytes: Long,
|
||||||
contentType: String) {
|
contentType: String) {
|
||||||
val entry = CacheEntry(
|
val entry = CacheEntry(
|
||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
sizeBytes = allBytes.size.toLong(),
|
sizeBytes = sizeBytes,
|
||||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||||
contentType = contentType)
|
contentType = contentType)
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.prepareRequest
|
||||||
import io.ktor.client.request.request
|
import io.ktor.client.request.request
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import io.ktor.client.statement.bodyAsChannel
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
@ -38,16 +39,19 @@ import io.ktor.http.decodeURLQueryComponent
|
|||||||
import io.ktor.http.encodeURLParameter
|
import io.ktor.http.encodeURLParameter
|
||||||
import io.ktor.server.application.ApplicationCall
|
import io.ktor.server.application.ApplicationCall
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.util.cio.ChannelWriteException
|
||||||
|
import io.ktor.utils.io.ClosedWriteChannelException
|
||||||
import io.ktor.utils.io.copyTo
|
import io.ktor.utils.io.copyTo
|
||||||
import io.ktor.utils.io.readRemaining
|
import io.ktor.utils.io.readAvailable
|
||||||
import io.ktor.utils.io.writeFully
|
import io.ktor.utils.io.writeFully
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.io.readByteArray
|
|
||||||
import okio.FileSystem
|
import okio.FileSystem
|
||||||
|
import okio.IOException
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
import okio.SYSTEM
|
import okio.SYSTEM
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
@ -260,18 +264,25 @@ internal class StreamProxy(
|
|||||||
) {
|
) {
|
||||||
logger.d { "Proxying segment for track $trackId" }
|
logger.d { "Proxying segment for track $trackId" }
|
||||||
|
|
||||||
val response = runCatching {
|
runCatching {
|
||||||
httpClient.request(segmentUrl) {
|
httpClient.prepareRequest(segmentUrl) {
|
||||||
method = HttpMethod.Get
|
method = HttpMethod.Get
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
|
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
|
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
|
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
|
||||||
|
}.execute { response -> respondWithSegment(call, response, trackId) }
|
||||||
|
}.onFailure { throwable ->
|
||||||
|
if (throwable is CancellationException) throw throwable
|
||||||
|
if (throwable.isClientDisconnect()) {
|
||||||
|
logger.d { "Player closed the connection while streaming segment of track $trackId" }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}.getOrElse { throwable ->
|
|
||||||
logger.w(throwable) { "Failed to fetch segment for track $trackId" }
|
logger.w(throwable) { "Failed to fetch segment for track $trackId" }
|
||||||
call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment")
|
runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch segment") }
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun respondWithSegment(call: ApplicationCall, response: HttpResponse, trackId: String) {
|
||||||
|
|
||||||
if (response.status.value >= 400) {
|
if (response.status.value >= 400) {
|
||||||
logger.w { "Segment request failed for track $trackId with status ${response.status.value}" }
|
logger.w { "Segment request failed for track $trackId with status ${response.status.value}" }
|
||||||
@ -305,48 +316,65 @@ internal class StreamProxy(
|
|||||||
|
|
||||||
var currentUrl = streamUrl
|
var currentUrl = streamUrl
|
||||||
var attemptedRefresh = false
|
var attemptedRefresh = false
|
||||||
var upstream: HttpResponse?
|
|
||||||
|
|
||||||
|
// `request()` buffers the whole body before returning, which stalls the player until
|
||||||
|
// the full track is downloaded. `execute {}` hands over the response as soon as headers
|
||||||
|
// arrive so the body can be streamed.
|
||||||
while (true) {
|
while (true) {
|
||||||
val response = runCatching {
|
var retryUrl: String? = null
|
||||||
httpClient.request(currentUrl) {
|
val failure = runCatching {
|
||||||
|
httpClient.prepareRequest(currentUrl) {
|
||||||
method = requestMethod
|
method = requestMethod
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
|
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Range)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange)
|
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.IfRange)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
|
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.Accept)
|
||||||
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
|
call.forwardRequestHeaderIfPresent(headers, HttpHeaders.UserAgent)
|
||||||
|
}.execute { response ->
|
||||||
|
if (response.status.value < 400) {
|
||||||
|
respondWithUpstream(call, response, trackId, requestMethod)
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" }
|
||||||
|
streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl)
|
||||||
|
|
||||||
|
if (!attemptedRefresh) {
|
||||||
|
val refreshedUrl =
|
||||||
|
streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url
|
||||||
|
if (!refreshedUrl.isNullOrBlank() && refreshedUrl != currentUrl) {
|
||||||
|
retryUrl = refreshedUrl
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respondWithUpstream(call, response, trackId, requestMethod)
|
||||||
}
|
}
|
||||||
}.getOrElse { throwable ->
|
}.exceptionOrNull()
|
||||||
logger.w(throwable) { "Failed to fetch upstream stream for track $trackId" }
|
|
||||||
call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream")
|
if (failure != null) {
|
||||||
|
if (failure is CancellationException) throw failure
|
||||||
|
if (failure.isClientDisconnect()) {
|
||||||
|
logger.d { "Player closed the connection while streaming track $trackId" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.w(failure) { "Failed to fetch upstream stream for track $trackId" }
|
||||||
|
// Throws if the response already started streaming; nothing more to send then
|
||||||
|
runCatching { call.respond(HttpStatusCode.BadGateway, "Failed to fetch upstream stream") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status.value < 400) {
|
val next = retryUrl ?: return
|
||||||
upstream = response
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.w { "Upstream stream request failed for track $trackId with status ${response.status.value}" }
|
|
||||||
streamingUrlRepository.invalidateCachedStreamUrl(trackId, currentUrl)
|
|
||||||
|
|
||||||
if (attemptedRefresh) {
|
|
||||||
upstream = response
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
val refreshedUrl =
|
|
||||||
streamingUrlRepository.resolveStreamInfo(trackId, forceRefresh = true)?.url
|
|
||||||
if (refreshedUrl.isNullOrBlank() || refreshedUrl == currentUrl) {
|
|
||||||
upstream = response
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.d { "Retrying stream for track $trackId using refreshed URL" }
|
logger.d { "Retrying stream for track $trackId using refreshed URL" }
|
||||||
currentUrl = refreshedUrl
|
currentUrl = next
|
||||||
attemptedRefresh = true
|
attemptedRefresh = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun respondWithUpstream(
|
||||||
|
call: ApplicationCall,
|
||||||
|
upstream: HttpResponse,
|
||||||
|
trackId: String,
|
||||||
|
requestMethod: HttpMethod
|
||||||
|
) {
|
||||||
if (upstream.status.value >= 400 && isCachingEnabled()) {
|
if (upstream.status.value >= 400 && isCachingEnabled()) {
|
||||||
val cachedEntry = cacheManager.findCachedEntry(trackId)
|
val cachedEntry = cacheManager.findCachedEntry(trackId)
|
||||||
if (cachedEntry != null) {
|
if (cachedEntry != null) {
|
||||||
@ -380,7 +408,9 @@ internal class StreamProxy(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val doCache = requestMethod == HttpMethod.Get && isCachingEnabled()
|
// Only a full 200 response can be cached; 206 partial responses must not be stored as the whole track
|
||||||
|
val doCache = requestMethod == HttpMethod.Get && isCachingEnabled() &&
|
||||||
|
upstream.status == HttpStatusCode.OK
|
||||||
val cacheTrack = if (doCache) getTrackMetadata(trackId) else null
|
val cacheTrack = if (doCache) getTrackMetadata(trackId) else null
|
||||||
val cacheFilename = if (cacheTrack != null) {
|
val cacheFilename = if (cacheTrack != null) {
|
||||||
cacheManager.resolveCacheFilename(cacheTrack, downstreamHeaders[HttpHeaders.ContentType])
|
cacheManager.resolveCacheFilename(cacheTrack, downstreamHeaders[HttpHeaders.ContentType])
|
||||||
@ -388,6 +418,7 @@ internal class StreamProxy(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
val trackIdForCache = trackId
|
val trackIdForCache = trackId
|
||||||
|
val expectedLength = downstreamContentLength
|
||||||
val cacheContentType = downstreamHeaders[HttpHeaders.ContentType] ?: "application/octet-stream"
|
val cacheContentType = downstreamHeaders[HttpHeaders.ContentType] ?: "application/octet-stream"
|
||||||
|
|
||||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||||
@ -402,26 +433,38 @@ internal class StreamProxy(
|
|||||||
val cacheDir = cacheManager.resolveCacheDir()
|
val cacheDir = cacheManager.resolveCacheDir()
|
||||||
val cacheFile = cacheDir / cacheFilename.toPath()
|
val cacheFile = cacheDir / cacheFilename.toPath()
|
||||||
fileSystem.createDirectories(cacheDir)
|
fileSystem.createDirectories(cacheDir)
|
||||||
|
var completed = false
|
||||||
|
var totalBytes = 0L
|
||||||
try {
|
try {
|
||||||
val packet = upstream.bodyAsChannel().readRemaining()
|
val source = upstream.bodyAsChannel()
|
||||||
val allBytes = packet.readByteArray()
|
val buffer = ByteArray(64 * 1024)
|
||||||
channel.writeFully(allBytes)
|
|
||||||
fileSystem.sink(cacheFile).buffer().use { sink ->
|
fileSystem.sink(cacheFile).buffer().use { sink ->
|
||||||
sink.write(allBytes)
|
while (true) {
|
||||||
|
val read = source.readAvailable(buffer, 0, buffer.size)
|
||||||
|
if (read < 0) break
|
||||||
|
if (read == 0) continue
|
||||||
|
channel.writeFully(buffer, 0, read)
|
||||||
|
channel.flush()
|
||||||
|
sink.write(buffer, 0, read)
|
||||||
|
totalBytes += read
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cacheManager.commitCacheEntry(
|
completed = expectedLength == null || expectedLength == totalBytes
|
||||||
trackId = trackIdForCache,
|
if (completed) {
|
||||||
filename = cacheFilename,
|
cacheManager.commitCacheEntry(
|
||||||
allBytes = allBytes,
|
trackId = trackIdForCache,
|
||||||
contentType = cacheContentType)
|
filename = cacheFilename,
|
||||||
scope.launch {
|
sizeBytes = totalBytes,
|
||||||
cacheManager.evictIfNeeded()
|
contentType = cacheContentType)
|
||||||
|
scope.launch {
|
||||||
|
cacheManager.evictIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} finally {
|
||||||
if (fileSystem.exists(cacheFile)) {
|
// Player disconnected or stream was incomplete: drop the partial file
|
||||||
|
if (!completed && fileSystem.exists(cacheFile)) {
|
||||||
fileSystem.delete(cacheFile)
|
fileSystem.delete(cacheFile)
|
||||||
}
|
}
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
upstream.bodyAsChannel().copyTo(channel)
|
upstream.bodyAsChannel().copyTo(channel)
|
||||||
@ -487,6 +530,16 @@ internal class StreamProxy(
|
|||||||
return rewritten
|
return rewritten
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the failure came from writing to the player's socket (seek, skip or stop closes
|
||||||
|
* it mid-stream), as opposed to a failure fetching from upstream.
|
||||||
|
*/
|
||||||
|
private fun Throwable.isClientDisconnect(): Boolean =
|
||||||
|
generateSequence(this) { it.cause }.take(10).any {
|
||||||
|
it is ChannelWriteException || it is ClosedWriteChannelException ||
|
||||||
|
(it is IOException && it.message?.contains("Broken pipe", ignoreCase = true) == true)
|
||||||
|
}
|
||||||
|
|
||||||
private fun encodeUrl(url: String): String = url.encodeURLParameter()
|
private fun encodeUrl(url: String): String = url.encodeURLParameter()
|
||||||
private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent()
|
private fun decodeUrl(encoded: String): String = encoded.decodeURLQueryComponent()
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user