Fix smart & default subtitle selection logic (#579)

## Description
Fixes the logic for subtitle selection when using `Smart` or `Default`
server modes. Also takes into account unknown languages.

This is heavily inspired by [the server selection
logic](https://github.com/jellyfin/jellyfin/blob/release-10.11.z/Emby.Server.Implementations/Library/MediaStreamSelector.cs).
For better or for worse, Wholphin implements this client side.

### Related issues
Fixes #570
This commit is contained in:
Ray 2025-12-27 14:22:40 -05:00 committed by GitHub
parent fd1feddab3
commit 494ff07b72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 591 additions and 25 deletions

View file

@ -43,7 +43,7 @@ jobs:
- name: Build app - name: Build app
id: buildapp id: buildapp
run: | run: |
./gradlew clean assembleDebug --no-daemon ./gradlew clean assembleDebug testDebugUnitTest --no-daemon
apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//')
echo "apks=$apks" >> "$GITHUB_OUTPUT" echo "apks=$apks" >> "$GITHUB_OUTPUT"
- name: Tar build dirs - name: Tar build dirs

View file

@ -249,4 +249,7 @@ dependencies {
if (ffmpegModuleExists || isCI) { if (ffmpegModuleExists || isCI) {
implementation(files("libs/lib-decoder-ffmpeg-release.aar")) implementation(files("libs/lib-decoder-ffmpeg-release.aar"))
} }
testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent)
} }

View file

@ -61,7 +61,7 @@ class ItemPlaybackRepository
) )
val subtitleStream = val subtitleStream =
streamChoiceService.chooseSubtitleStream( streamChoiceService.chooseSubtitleStream(
audioStream = audioStream, audioStreamLang = audioStream?.language,
candidates = candidates =
source.mediaStreams source.mediaStreams
?.filter { it.type == MediaStreamType.SUBTITLE } ?.filter { it.type == MediaStreamType.SUBTITLE }

View file

@ -153,7 +153,7 @@ class StreamChoiceService
return source.mediaStreams?.letNotEmpty { streams -> return source.mediaStreams?.letNotEmpty { streams ->
val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE }
chooseSubtitleStream( chooseSubtitleStream(
audioStream, audioStream?.language,
candidates, candidates,
itemPlayback, itemPlayback,
plc, plc,
@ -163,7 +163,7 @@ class StreamChoiceService
} }
fun chooseSubtitleStream( fun chooseSubtitleStream(
audioStream: MediaStream?, audioStreamLang: String?,
candidates: List<MediaStream>, candidates: List<MediaStream>,
itemPlayback: ItemPlayback?, itemPlayback: ItemPlayback?,
playbackLanguageChoice: PlaybackLanguageChoice?, playbackLanguageChoice: PlaybackLanguageChoice?,
@ -198,48 +198,59 @@ class StreamChoiceService
userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT
} }
} }
val candidates =
candidates.sortedWith(
compareBy<MediaStream>(
{ it.isDefault },
{ !it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.equals(subtitleLanguage, true) },
{ it.isForced && it.language.isUnknown },
{ it.isForced },
),
)
return when (subtitleMode) { return when (subtitleMode) {
SubtitlePlaybackMode.ALWAYS -> { SubtitlePlaybackMode.ALWAYS -> {
if (subtitleLanguage != null) { if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage } candidates.firstOrNull {
it.language.equals(subtitleLanguage, true) ||
it.language.isUnknown
}
} else { } else {
candidates.firstOrNull() candidates.firstOrNull()
} }
} }
SubtitlePlaybackMode.ONLY_FORCED -> { SubtitlePlaybackMode.ONLY_FORCED -> {
if (subtitleLanguage != null) { if (subtitleLanguage.isNotNullOrBlank()) {
candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } candidates.firstOrNull { it.language == subtitleLanguage && it.isForced }
?: candidates.firstOrNull { it.language.isUnknown && it.isForced }
} else { } else {
candidates.firstOrNull { it.isForced } candidates.firstOrNull { it.isForced }
} }
} }
SubtitlePlaybackMode.SMART -> { SubtitlePlaybackMode.SMART -> {
val audioLanguage = userConfig?.audioLanguagePreference if (subtitleLanguage.isNotNullOrBlank()) {
val audioStreamLang = audioStream?.language val audioLanguage = userConfig?.audioLanguagePreference
if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) {
candidates.firstOrNull { it.language == subtitleLanguage } candidates.firstOrNull { it.language == subtitleLanguage }
?: candidates.firstOrNull { it.language.isUnknown }
} else {
candidates.firstOrNull { it.isForced && it.language == subtitleLanguage }
?: candidates.firstOrNull { it.isForced && it.language.isUnknown }
}
} else { } else {
null candidates.firstOrNull { it.isForced }
} }
} }
SubtitlePlaybackMode.DEFAULT -> { SubtitlePlaybackMode.DEFAULT -> {
subtitleLanguage?.let { lang -> if (subtitleLanguage.isNotNullOrBlank()) {
// Find best track that is in the preferred language candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) }
( ?: candidates.firstOrNull { it.isDefault || it.isForced }
candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang } } else {
?: candidates.firstOrNull { it.isDefault && it.language == lang } candidates.firstOrNull { it.isDefault || it.isForced }
?: candidates.firstOrNull { it.isForced && it.language == lang }
)
} }
?: (
// If none in preferred language, just find the best track
candidates.firstOrNull { it.isDefault && it.isForced }
?: candidates.firstOrNull { it.isDefault }
?: candidates.firstOrNull { it.isForced }
)
} }
SubtitlePlaybackMode.NONE -> { SubtitlePlaybackMode.NONE -> {
@ -249,3 +260,12 @@ class StreamChoiceService
} }
} }
} }
private val String?.isUnknown: Boolean
get() =
this == null ||
this.equals("unknown", true) ||
this.equals("und", true) ||
this.equals("undetermined", true) ||
this.equals("mul", true) ||
this.equals("zxx", true)

View file

@ -0,0 +1,540 @@
package com.github.damontecres.wholphin.test
import androidx.lifecycle.MutableLiveData
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceService
import io.mockk.every
import io.mockk.mockk
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserDto
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class TestStreamChoiceServiceBasic(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
null,
SubtitlePlaybackMode.NONE,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
),
TestInput(
1,
SubtitlePlaybackMode.NONE,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitleLang = "spa"),
),
TestInput(
0,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
),
TestInput(
1,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
userSubtitleLang = "spa",
),
TestInput(
null,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitlesDisabled = true),
),
TestInput(
null,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.DISABLED),
),
TestInput(
0,
SubtitlePlaybackMode.ALWAYS,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED),
),
)
}
}
@RunWith(Parameterized::class)
class TestStreamChoiceServiceDefault(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
userSubtitleLang = null,
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", true),
),
userSubtitleLang = null,
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", true),
),
userSubtitleLang = null,
),
TestInput(
null,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", false),
),
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
itemPlayback = itemPlayback(subtitleIndex = 1),
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", false),
),
plc = plc(subtitleLang = "spa"),
),
TestInput(
1,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitleLang = "spa"),
),
TestInput(
null,
SubtitlePlaybackMode.DEFAULT,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
plc = plc(subtitlesDisabled = true),
),
)
}
}
@RunWith(Parameterized::class)
class TestStreamChoiceServiceSmart(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = null,
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "spa",
),
TestInput(
0,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "spa",
plc = plc(subtitleLang = "spa"),
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", false),
subtitle(1, "spa", false),
),
streamAudioLang = "spa",
plc = plc(subtitlesDisabled = true),
),
TestInput(
1,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "spa",
userAudioLang = "spa",
),
TestInput(
null,
SubtitlePlaybackMode.SMART,
subtitles =
listOf(
subtitle(0, "eng", true),
subtitle(1, "spa", false),
),
streamAudioLang = "eng",
userSubtitleLang = "spa",
userAudioLang = "eng",
),
)
}
}
@RunWith(Parameterized::class)
class TestStreamChoiceServiceOnlyForced(
val input: TestInput,
) {
@Test
fun test() {
runTest(input)
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}: {0}")
fun data(): Collection<TestInput> =
listOf(
TestInput(
null,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
),
TestInput(
1,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
itemPlayback = itemPlayback(subtitleIndex = 1),
),
TestInput(
0,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitleLang = "eng"),
),
TestInput(
null,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesDisabled = true),
),
TestInput(
0,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
),
TestInput(
null,
SubtitlePlaybackMode.ONLY_FORCED,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
),
TestInput(
1,
SubtitlePlaybackMode.ONLY_FORCED,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
),
)
}
}
data class TestInput(
val expectedIndex: Int?,
val userSubtitleMode: SubtitlePlaybackMode?,
val userAudioLang: String? = "eng",
val userSubtitleLang: String? = "eng",
val streamAudioLang: String? = "eng",
val subtitles: List<MediaStream>,
val itemPlayback: ItemPlayback? = null,
val plc: PlaybackLanguageChoice? = null,
) {
override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})"
}
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
private fun serverRepo(
audioLang: String?,
subtitleMode: SubtitlePlaybackMode?,
subtitleLang: String?,
): ServerRepository {
val mocked = mockk<ServerRepository>()
every { mocked.currentUserDto } returns
MutableLiveData(
UserDto(
id = UUID.randomUUID(),
hasPassword = true,
hasConfiguredPassword = true,
hasConfiguredEasyPassword = true,
configuration =
DefaultUserConfiguration.copy(
audioLanguagePreference = audioLang,
subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT,
subtitleLanguagePreference = subtitleLang,
),
),
)
return mocked
}
private fun runTest(input: TestInput) {
val service =
StreamChoiceService(
serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
mockk<PlaybackLanguageChoiceDao>(),
)
val result =
service.chooseSubtitleStream(
audioStreamLang = input.streamAudioLang,
candidates = input.subtitles,
itemPlayback = input.itemPlayback,
playbackLanguageChoice = input.plc,
prefs = UserPreferences(AppPreferences.getDefaultInstance()),
)
Assert.assertEquals(input.expectedIndex, result?.index)
}
fun subtitle(
index: Int,
lang: String?,
default: Boolean = false,
forced: Boolean = false,
): MediaStream =
MediaStream(
type = MediaStreamType.SUBTITLE,
language = lang,
isDefault = default,
isForced = forced,
isHearingImpaired = false,
isInterlaced = false,
index = index,
isExternal = false,
isTextSubtitleStream = true,
supportsExternalStream = true,
)
private fun itemPlayback(
audioIndex: Int = TrackIndex.UNSPECIFIED,
subtitleIndex: Int = TrackIndex.UNSPECIFIED,
): ItemPlayback =
ItemPlayback(
rowId = 1,
userId = 1,
itemId = UUID.randomUUID(),
sourceId = UUID.randomUUID(),
audioIndex = audioIndex,
subtitleIndex = subtitleIndex,
)
private fun plc(
audioLang: String? = null,
subtitleLang: String? = null,
subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null,
): PlaybackLanguageChoice =
PlaybackLanguageChoice(
userId = 1,
seriesId = UUID.randomUUID(),
audioLanguage = audioLang,
subtitleLanguage = subtitleLang,
subtitlesDisabled = subtitlesDisabled,
)

View file

@ -11,6 +11,7 @@ ksp = "2.3.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
appcompat = "1.7.1" appcompat = "1.7.1"
composeBom = "2025.12.01" composeBom = "2025.12.01"
mockk = "1.14.7"
multiplatformMarkdownRenderer = "0.38.1" multiplatformMarkdownRenderer = "0.38.1"
programguide = "1.6.0" programguide = "1.6.0"
slf4j2Timber = "1.2" slf4j2Timber = "1.2"
@ -64,6 +65,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" }