From cb8316d1417fe0f3f536dfadea055e05e39baa7a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 26 Oct 2026 13:40:00 +0600 Subject: [PATCH] fix(ios): stream formats AVPlayer can play AVPlayer can't open WebM/Ogg or DASH, so the default opus/webm stream failed silently on iOS. - Rank streams the platform's player can play above format/quality prefs - Default iOS streaming to aac/mp4, migrate saved WebM/Ogg prefs on iOS and hide unplayable formats in the picker (desktop/Android keep opus/webm) - Normalize codec/container names on iOS like androidJvm (mp4a -> aac, m4a -> mp4) - Wait up to 30s for the audio plugin to load when resolving, so the queue restored at startup isn't failed permanently before plugins are ready --- .../core/newpipe/NewPipeService.androidJvm.kt | 8 --- .../spotube/core/newpipe/NewPipeModels.kt | 12 ++++- .../core/server/StreamingUrlRepository.kt | 52 ++++++++++++++++-- .../modules/settings/SettingsModels.kt | 34 ++++++++---- .../modules/settings/SettingsRepository.kt | 4 +- .../modules/settings/sections/utils.kt | 6 ++- .../StreamingUrlRepositoryBehaviorTest.kt | 18 +++++++ .../core/server/StreamingUrlRepositoryTest.kt | 35 +++++++++++- .../settings/StreamingFormatDefaultsTest.kt | 53 +++++++++++++++++++ .../core/newpipe/NewPipeService.ios.kt | 4 +- 10 files changed, 198 insertions(+), 28 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt index a79f5a03..1500c6f9 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -162,14 +162,6 @@ actual class NewPipeService { * "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded * aac/mp4 DASH/HLS manifests always won instead. */ -private fun normalizeCodec(codec: String?): String { - val value = codec.orEmpty() - return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value -} - -private fun normalizeContainer(suffix: String): String = - if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix - private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) { DeliveryMethod.DASH -> StreamProtocol.DASH DeliveryMethod.HLS -> StreamProtocol.HLS diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt index 8ca9471f..91fe54a2 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeModels.kt @@ -49,4 +49,14 @@ data class MediaStream( val container: String, val bitrate: Int, val isAudio: Boolean, - val isVideo: Boolean) \ No newline at end of file + val isVideo: Boolean) + +/** Maps NewPipe's codec names onto the plugin's AudioFormat names (mp4a.40.2 → aac). */ +internal fun normalizeCodec(codec: String?): String { + val value = codec.orEmpty() + return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value +} + +/** Maps NewPipe's file suffixes onto the plugin's AudioFormat containers (m4a → mp4). */ +internal fun normalizeContainer(suffix: String): String = + if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix 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 3ee50fa0..19692bf6 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 @@ -23,13 +23,18 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality 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.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.di.injectLogger +import org.spotube.spotube.getPlatform import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.settings.UserSettingsSource +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.component.KoinComponent import kotlin.math.abs import kotlin.time.Clock @@ -111,7 +116,14 @@ class StreamingUrlRepository( // read it, nor replace it with a stream playback didn't ask for. val useUrlCache = request == null + // A built-in audio plugin is always selected, so null means plugins are still loading. + // The player requests the restored queue's track right at startup; failing that request + // leaves the item permanently failed (AVPlayer), so wait for the plugin instead. val audioPlugin = pluginManager.selectedAudioPlugin.value + ?: withTimeoutOrNull(AUDIO_PLUGIN_LOAD_TIMEOUT) { + logger.i { "[Resolve $trackId] waiting for the audio plugin to load" } + pluginManager.selectedAudioPlugin.filterNotNull().first() + } if (audioPlugin == null) { logger.w { "No audio plugin selected while resolving stream for track $trackId" } return null @@ -243,10 +255,12 @@ class StreamingUrlRepository( logger.i { "[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " + "want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " + - "(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" + + "(rank key = unplayable, formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; " + + "lowest wins):\n" + streams.joinToString("\n") { stream -> val marker = if (stream === selected) " -> " else " " - "$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " + + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 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" }}, " + "${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}" @@ -254,6 +268,10 @@ 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.describeForLog()}, which the player will likely reject" + } formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { "[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " + "got ${selected.codec}/${selected.container} (${selected.protocol})" @@ -371,6 +389,8 @@ class StreamingUrlRepository( * Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are * resolved against the YouTube origin; progressive URLs are returned unchanged. */ +private val AUDIO_PLUGIN_LOAD_TIMEOUT = 30.seconds + internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String { if (url.startsWith("http")) return url return when (protocol) { @@ -379,12 +399,21 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String } } -/** Selects the available stream closest to the user's preferred format and quality. */ +internal val currentPlatform: PlatformType by lazy { getPlatform().type } + +/** + * Selects the available stream closest to the user's preferred format and quality. + * Streams the [platform]'s player can decode always win over ones it can't, whatever the + * preferences say: e.g. opus/webm is unplayable on iOS. + */ internal fun selectPreferredAudioStream( streams: List, preferredFormat: AudioFormat, - preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull( + preferredQuality: AudioQuality, + platform: PlatformType = currentPlatform): AudioStream? = streams.minWithOrNull( compareBy { stream -> + if (stream.isPlayableOn(platform)) 0 else 1 + }.thenBy { stream -> formatMismatchCount(stream, preferredFormat) }.thenBy { stream -> if (stream.matchesQualityType(preferredQuality)) 0 else 1 @@ -395,6 +424,21 @@ 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") + +/** + * 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. + */ +internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = + isContainerPlayableOn(container, platform) && + !(platform == PlatformType.IOS && protocol == StreamProtocol.DASH) + +/** 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 + private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.container.equals(preferredFormat.container, 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 d6da4e16..8d0fa86d 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 @@ -17,6 +17,9 @@ package org.spotube.spotube.modules.settings +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform +import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import kotlinx.serialization.SerialName @@ -42,15 +45,7 @@ data class UserSettings( val palette: MillenniumPalette = MillenniumPalette.Blue, // Playback - val streamingMusicFormat: AudioFormat = AudioFormat( - codec = "opus", - container = "webm", - qualities = listOf( - AudioQuality.Lossy(bitrate = 44_000), - AudioQuality.Lossy(bitrate = 96_000), - AudioQuality.Lossy(bitrate = 128_000), - AudioQuality.Lossy(bitrate = 256_000)) - ), + val streamingMusicFormat: AudioFormat = defaultStreamingMusicFormat(), val streamingMusicQuality: AudioQuality = AudioQuality.Lossy(bitrate = 256_000), val enableMusicCaching: Boolean = true, val cacheFolder: String? = null, @@ -111,3 +106,24 @@ data class JamBroker( val clientIdPrefix: String = "spotube", val keepAliveSeconds: Int = 30, val connectionTimeoutSeconds: Int = 10) + +/** + * opus/webm everywhere except iOS, whose AVPlayer can't open WebM/Ogg, 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 + return AudioFormat( + codec = if (ios) "aac" else "opus", + container = if (ios) "mp4" else "webm", + qualities = listOf( + AudioQuality.Lossy(bitrate = 44_000), + AudioQuality.Lossy(bitrate = 96_000), + AudioQuality.Lossy(bitrate = 128_000), + AudioQuality.Lossy(bitrate = 256_000))) +} + +/** Moves settings saved with a streaming format [platform] can't play (iOS' old opus/webm default) to the default. */ +internal fun UserSettings.withPlayableStreamingFormat(platform: PlatformType = currentPlatform): UserSettings = + if (isContainerPlayableOn(streamingMusicFormat.container, platform)) this + else copy(streamingMusicFormat = defaultStreamingMusicFormat(platform)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt index a69fd159..b8b3772c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -54,7 +54,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { override val userSettings: StateFlow = database.settingsDataStore.data.map { prefs -> val json = prefs[SETTINGS_KEY] if (json != null) { - settingsJson.decodeFromString(json as String) + settingsJson.decodeFromString(json as String).withPlayableStreamingFormat() } else { UserSettings() // Default value } @@ -73,6 +73,6 @@ class SettingsRepository(private val database: Database) : UserSettingsSource { suspend fun getCurrentSettings(): UserSettings { val preferences = database.settingsDataStore.data.first() val storedJson = preferences[SETTINGS_KEY] ?: return UserSettings() - return settingsJson.decodeFromString(storedJson) + return settingsJson.decodeFromString(storedJson).withPlayableStreamingFormat() } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt index ab59ce60..34849447 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/utils.kt @@ -18,6 +18,8 @@ package org.spotube.spotube.modules.settings.sections +import org.spotube.spotube.core.server.isContainerPlayableOn +import org.spotube.spotube.core.server.currentPlatform import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumSeparator import org.spotube.millennium.theme.MillenniumTheme @@ -56,6 +58,7 @@ private val standardLossyQualities = listOf( AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 256_000)) +// Formats the platform's player can't open aren't offered (iOS: no WebM/Ogg). internal val streamingFormatPresets = listOf( AudioFormat( codec = "opus", @@ -68,7 +71,8 @@ internal val streamingFormatPresets = listOf( AudioFormat( codec = "vorbis", container = "ogg", - qualities = standardLossyQualities)) + qualities = standardLossyQualities) +).filter { isContainerPlayableOn(it.container, currentPlatform) } internal val downloadFormatPresets = listOf( AudioFormat( 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 d7bda94a..f297b17a 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 @@ -8,6 +8,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.modules.settings.UserSettings import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.koin.core.context.startKoin import org.koin.core.context.stopKoin @@ -130,6 +132,22 @@ class StreamingUrlRepositoryBehaviorTest { assertNull(info) } + @Test + fun `waits for an audio plugin that is still loading`() = runTest { + val h = Harness() + val loader = launch { + delay(5_000) + h.selectPlugin("pluginA").audioAPI.onGetStreamsByTrack = { + listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus")))) + } + } + + val info = h.repository.resolveStreamInfo(track("t1")) + loader.join() + + assertEquals("https://example.com/a.opus", info?.url) + } + @Test fun `returns null when the plugin yields no sources`() = runTest { val h = Harness() diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt index e950747a..b5f68eb9 100644 --- a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/server/StreamingUrlRepositoryTest.kt @@ -1,5 +1,6 @@ package org.spotube.spotube.core.server +import org.spotube.spotube.PlatformType import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat import org.spotube.plugin_interfaces.plugin_apis.audio.AudioQuality import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream @@ -13,6 +14,7 @@ import kotlin.test.assertSame * Tests for the stream preference selection used by [StreamingUrlRepository]. * * Ordering contract (best first): + * 0. Playable by the platform's player (iOS: no WebM/Ogg containers, no DASH). * 1. Fewest codec/container mismatches against the preferred format. * 2. Matching quality family (lossy vs lossless). * 3. Closest bitrate / sample rate. @@ -54,7 +56,8 @@ class StreamingUrlRepositoryTest { private fun select( streams: List, format: AudioFormat = opusWebm(), - quality: AudioQuality = AudioQuality.Lossy(256_000)) = selectPreferredAudioStream(streams, format, quality) + quality: AudioQuality = AudioQuality.Lossy(256_000), + platform: PlatformType = PlatformType.Linux) = selectPreferredAudioStream(streams, format, quality, platform) // ---------- empty / single ---------- @@ -403,4 +406,34 @@ class StreamingUrlRepositoryTest { assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH)) assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS)) } + + // ---------- device playability ---------- + + @Test + fun `on iOS a playable m4a stream beats the preferred but unplayable opus webm`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(aac, select(listOf(opus, aac), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS dash manifests are skipped in favour of progressive streams`() { + val dash = lossy("dash", 256_000, codec = "mp4a.40.2", container = "m4a", protocol = StreamProtocol.DASH) + val progressive = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(progressive, select(listOf(dash, progressive), platform = PlatformType.IOS)) + } + + @Test + fun `on iOS an unplayable stream is still returned when nothing else exists`() { + val opus = lossy("opus-160", 160_000) + assertSame(opus, select(listOf(opus), platform = PlatformType.IOS)) + } + + @Test + fun `off iOS the preferred opus webm stream still wins`() { + val opus = lossy("opus-160", 160_000) + val aac = lossy("aac-128", 128_000, codec = "mp4a.40.2", container = "m4a") + assertSame(opus, select(listOf(opus, aac), platform = PlatformType.Android)) + } + } 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 new file mode 100644 index 00000000..cc35c849 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/modules/settings/StreamingFormatDefaultsTest.kt @@ -0,0 +1,53 @@ +/* + * 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.modules.settings + +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioFormat +import org.spotube.spotube.PlatformType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class StreamingFormatDefaultsTest { + + private val opusWebm = AudioFormat(codec = "opus", container = "webm", qualities = emptyList()) + + @Test + fun `ios defaults to aac in mp4`() { + val format = defaultStreamingMusicFormat(PlatformType.IOS) + assertEquals("aac" to "mp4", format.codec to format.container) + } + + @Test + fun `other platforms keep opus in webm`() { + val format = defaultStreamingMusicFormat(PlatformType.Android) + assertEquals("opus" to "webm", format.codec to format.container) + } + + @Test + fun `saved opus webm is moved to aac on ios`() { + val migrated = UserSettings(streamingMusicFormat = opusWebm).withPlayableStreamingFormat(PlatformType.IOS) + assertEquals("mp4", migrated.streamingMusicFormat.container) + } + + @Test + fun `saved opus webm is kept off ios`() { + val settings = UserSettings(streamingMusicFormat = opusWebm) + assertSame(settings, settings.withPlayableStreamingFormat(PlatformType.Linux)) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt index f70b3a39..95eb42c1 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeService.ios.kt @@ -77,9 +77,9 @@ actual class NewPipeService { .map { stream -> org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream.Lossy( url = stream.content, - codec = stream.codec ?: "unknown", + codec = normalizeCodec(stream.codec ?: "unknown"), bitrate = stream.bitrate, - container = stream.format?.suffix ?: "unknown") + container = normalizeContainer(stream.format?.suffix ?: "unknown")) }, videoStreams = listOf(), // Not yet supported id = id