diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt index b1e5941d..18792453 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/DebugConfig.kt @@ -39,4 +39,6 @@ object DebugConfig { * Keep it `false` when committing. */ const val FORCE_SUPPORT_REMINDER: Boolean = false + + const val FORCE_VLC_LOGS: Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt index 900d830a..338803bc 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamProxy.kt @@ -425,7 +425,7 @@ internal class StreamProxy( if (requestMethod == HttpMethod.Head) { call.respond(object : OutgoingContent.NoContent() { override val status: HttpStatusCode = upstream.status - override val headers: Headers = downstreamHeaders + override val headers: Headers = downstreamHeaders.passthroughForPlayer() override val contentLength: Long? = downstreamContentLength }) return @@ -446,7 +446,7 @@ internal class StreamProxy( call.respond(object : OutgoingContent.WriteChannelContent() { override val status: HttpStatusCode = upstream.status - override val headers: Headers = downstreamHeaders + override val headers: Headers = downstreamHeaders.passthroughForPlayer() override val contentLength: Long? = downstreamContentLength override val contentType: ContentType? = downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) @@ -594,4 +594,28 @@ internal class StreamProxy( } }.build() } + +} + +private val PLAYER_PASSTHROUGH_HEADERS = listOf( + HttpHeaders.ContentRange, + HttpHeaders.AcceptRanges, + HttpHeaders.ETag, + HttpHeaders.LastModified, +) + +/** + * The upstream headers a player actually needs. Copying them all makes Ktor emit duplicate + * Content-Length/Content-Type (also set through the OutgoingContent) and forwards hop-by-hop + * headers like `Connection: keep-alive`, which VLC rejects by closing the socket right after + * the response headers. Content-Range and Accept-Ranges must stay: without them the player + * treats a 206 as invalid and the stream as unseekable, and e.g. WebM can't be demuxed. + */ +internal fun Headers.passthroughForPlayer(): Headers { + val upstream = this + return HeadersBuilder().apply { + PLAYER_PASSTHROUGH_HEADERS.forEach { name -> + upstream.getAll(name)?.forEach { value -> append(name, value) } + } + }.build() } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt index 19692bf6..11a8ae60 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/StreamingUrlRepository.kt @@ -80,7 +80,8 @@ class StreamingUrlRepository( private val audioPlayerQueue: AudioPlayerQueue, private val matchedTracksRepository: TrackSourceRepository, private val settingsRepository: UserSettingsSource, - private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { + private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds, + private val platform: PlatformType = currentPlatform) : KoinComponent { private val logger by injectLogger() private val streamUrlCacheMutex = Mutex() @@ -145,7 +146,7 @@ class StreamingUrlRepository( } else { stream.streams } - return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) + return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform) .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } } @@ -259,7 +260,7 @@ class StreamingUrlRepository( "lowest wins):\n" + streams.joinToString("\n") { stream -> val marker = if (stream === selected) " -> " else " " - "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " + + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " + "${formatMismatchCount(stream, preferredFormat)}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + @@ -268,8 +269,8 @@ class StreamingUrlRepository( } when { selected == null -> logger.w { "[Resolve $trackId] no stream selected" } - !selected.isPlayableOn(currentPlatform) -> logger.w { - "[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " + + !selected.isPlayableOn(platform) -> logger.w { + "[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " + "${selected.describeForLog()}, which the player will likely reject" } formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { @@ -424,12 +425,16 @@ internal fun selectPreferredAudioStream( } ) -// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load. -private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska") +private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska") + +// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there. +private val UNSUPPORTED_CONTAINERS: Map> = mapOf( + // AVPlayer has no WebM/Ogg/Matroska demuxer. + PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga")) /** - * Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android) - * handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. + * Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle + * everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. */ internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = isContainerPlayableOn(container, platform) && @@ -437,7 +442,7 @@ internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = /** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */ internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean = - platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS + container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty() private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index 8d0fa86d..f22688a3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -108,14 +108,14 @@ data class JamBroker( val connectionTimeoutSeconds: Int = 10) /** - * opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, so it streams AAC. + * opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC. * TODO: revisit once iOS can play Opus (e.g. remuxing to CAF or a non-AVPlayer backend). */ internal fun defaultStreamingMusicFormat(platform: PlatformType = currentPlatform): AudioFormat { - val ios = platform == PlatformType.IOS + val aac = !isContainerPlayableOn("webm", platform) return AudioFormat( - codec = if (ios) "aac" else "opus", - container = if (ios) "mp4" else "webm", + codec = if (aac) "aac" else "opus", + container = if (aac) "mp4" else "webm", qualities = listOf( AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 96_000), diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt new file mode 100644 index 00000000..441a88ff --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamProxyHeadersTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.server + +import io.ktor.http.HeadersBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class StreamProxyHeadersTest { + + // Lowercase names and values as googlevideo sends them + private val upstream = HeadersBuilder().apply { + append("accept-ranges", "bytes") + append("alt-svc", "h3=\":443\"") + append("cache-control", "private, max-age=21298") + append("connection", "keep-alive") + append("content-length", "3523553") + append("content-range", "bytes 0-3523552/3523553") + append("content-type", "audio/webm") + append("last-modified", "Tue, 18 Feb 2025 22:42:11 GMT") + }.build() + + @Test + fun `keeps the headers a player needs to seek`() { + val forwarded = upstream.passthroughForPlayer() + assertEquals("bytes 0-3523552/3523553", forwarded["Content-Range"]) + assertEquals("bytes", forwarded["Accept-Ranges"]) + assertEquals("Tue, 18 Feb 2025 22:42:11 GMT", forwarded["Last-Modified"]) + } + + @Test + fun `drops headers that are duplicated or hop-by-hop`() { + val forwarded = upstream.passthroughForPlayer() + // Length and type are set on the OutgoingContent; forwarding them too emits them twice + assertNull(forwarded["Content-Length"]) + assertNull(forwarded["Content-Type"]) + assertNull(forwarded["Connection"]) + assertNull(forwarded["Alt-Svc"]) + assertNull(forwarded["Cache-Control"]) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt index f297b17a..28feb80c 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryBehaviorTest.kt @@ -5,6 +5,7 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.PlatformType import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.modules.settings.UserSettings import co.touchlab.kermit.Logger @@ -99,7 +100,9 @@ class StreamingUrlRepositoryBehaviorTest { audioPlayerQueue = queue, matchedTracksRepository = matchedTracksRepository, settingsRepository = settings, - streamUrlCacheTtlMs = cacheTtlMs) + streamUrlCacheTtlMs = cacheTtlMs, + // Not the host's platform: Windows and iOS don't prefer opus/webm + platform = PlatformType.Linux) fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService { val service = FakeAudioPluginService(pluginId, audioAPI) @@ -236,6 +239,11 @@ class StreamingUrlRepositoryBehaviorTest { ) } + // The default format depends on the host platform (aac/mp4 on Windows and iOS) + h.settings.update { + copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "opus", container = "webm")) + } + val first = h.repository.resolveStreamInfo(t) assertEquals("https://example.com/opus.webm", first?.url) diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt index cc35c849..c25d82a6 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -35,8 +35,10 @@ class StreamingFormatDefaultsTest { @Test fun `other platforms keep opus in webm`() { - val format = defaultStreamingMusicFormat(PlatformType.Android) - assertEquals("opus" to "webm", format.codec to format.container) + listOf(PlatformType.Android, PlatformType.Linux, PlatformType.MacOS, PlatformType.Windows).forEach { platform -> + val format = defaultStreamingMusicFormat(platform) + assertEquals("opus" to "webm", format.codec to format.container, platform.name) + } } @Test diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt index 37cb7a42..a5f771bc 100644 --- a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayer.jvm.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.DebugConfig import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery import uk.co.caprica.vlcj.medialist.MediaList @@ -87,11 +88,13 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val _error = MutableSharedFlow(extraBufferCapacity = 1) actual override val playerStateFlow: StateFlow = _playerState.asStateFlow() - actual override val currentMediaItemFlow: StateFlow = _currentMediaItem.asStateFlow() + actual override val currentMediaItemFlow: StateFlow = + _currentMediaItem.asStateFlow() actual override val playlistFlow: StateFlow> = _playlist.asStateFlow() actual override val durationFlow: StateFlow = _duration.asStateFlow() actual override val positionFlow: StateFlow = _position.asStateFlow() - actual override val bufferingPositionFlow: StateFlow = _bufferingPosition.asStateFlow() + actual override val bufferingPositionFlow: StateFlow = + _bufferingPosition.asStateFlow() actual override val loopStateFlow: StateFlow = _loopState.asStateFlow() actual override val shuffleModeFlow: StateFlow = _shuffleMode.asStateFlow() actual override val playbackSpeedFlow: StateFlow = _playbackSpeed.asStateFlow() @@ -101,6 +104,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface init { VLCBundleLoaderGenerated.getVerifiedPath() + val vlcLogArgs = if (DebugConfig.FORCE_VLC_LOGS) arrayOf("--verbose=2") else emptyArray() mediaPlayerFactory = MediaPlayerFactory( null as NativeDiscovery?, "--no-video", @@ -108,8 +112,7 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface "--aout=any", "--no-osd", "--no-snapshot-preview", -// "--verbose=2" -// "--quiet" + *vlcLogArgs ) audioListPlayerComponent = object : AudioListPlayerComponent(mediaPlayerFactory) { override fun opening(mediaPlayer: MediaPlayer) { @@ -122,7 +125,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface ) { val durationMs = mediaPlayer.status().length() _bufferingPosition.tryEmit( - if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO) + if (durationMs > 0) (durationMs * (newCache / 100f)).toLong().milliseconds else Duration.ZERO + ) _playerState.tryEmit(if (newCache < 100f) PlayerState.BUFFERING else PlayerState.READY) } @@ -184,7 +188,8 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface override fun nextItem( mediaListPlayer: uk.co.caprica.vlcj.player.list.MediaListPlayer, - item: uk.co.caprica.vlcj.media.MediaRef) { + item: uk.co.caprica.vlcj.media.MediaRef + ) { lock.withLock { val targetIndex = pendingNextIndex ?: run { val currentMrl = runCatching { @@ -336,7 +341,11 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface } } - actual override suspend fun load(playlist: List, autoPlay: Boolean, startPosition: Int) { + actual override suspend fun load( + playlist: List, + autoPlay: Boolean, + startPosition: Int + ) { lock.withLock { if (disposed) return diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 838df455..c167d59c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,7 +72,7 @@ ksp = "2.3.12" spotube-gradle = "0.1.2" cryptography = "0.6.0" reorderable = "3.1.0" -vlcjBundler = "0.2.0" +vlcjBundler = "0.2.1" vanniktechMavenPublish = "0.37.0" uniffi = "0.3.7" kotlinx-atomicfu = "0.33.0"