diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d81c5e18..56513c4b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -43,7 +43,7 @@ jobs: - name: Build app id: buildapp 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/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Tar build dirs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f564ca84..3080e2ec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -249,4 +249,7 @@ dependencies { if (ffmpegModuleExists || isCI) { implementation(files("libs/lib-decoder-ffmpeg-release.aar")) } + + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 00bc0fb7..17ad9f0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -61,7 +61,7 @@ class ItemPlaybackRepository ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStream = audioStream, + audioStreamLang = audioStream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 703821fe..ada96c57 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -153,7 +153,7 @@ class StreamChoiceService return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } chooseSubtitleStream( - audioStream, + audioStream?.language, candidates, itemPlayback, plc, @@ -163,7 +163,7 @@ class StreamChoiceService } fun chooseSubtitleStream( - audioStream: MediaStream?, + audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, playbackLanguageChoice: PlaybackLanguageChoice?, @@ -198,48 +198,59 @@ class StreamChoiceService userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT } } + val candidates = + candidates.sortedWith( + compareBy( + { 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) { SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + it.language.equals(subtitleLanguage, true) || + it.language.isUnknown + } } else { candidates.firstOrNull() } } SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage != null) { + if (subtitleLanguage.isNotNullOrBlank()) { candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } } else { candidates.firstOrNull { it.isForced } } } SubtitlePlaybackMode.SMART -> { - val audioLanguage = userConfig?.audioLanguagePreference - val audioStreamLang = audioStream?.language - if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) { + 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 { - null + candidates.firstOrNull { it.isForced } } } SubtitlePlaybackMode.DEFAULT -> { - subtitleLanguage?.let { lang -> - // Find best track that is in the preferred language - ( - candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang } - ?: candidates.firstOrNull { it.isDefault && it.language == lang } - ?: candidates.firstOrNull { it.isForced && it.language == lang } - ) + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } } - ?: ( - // 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 -> { @@ -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) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt new file mode 100644 index 00000000..fa16c5ab --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -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 = + 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 = + 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 = + 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 = + 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, + 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() + 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(), + ) + 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, + ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79d1ae5c..9ed29469 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" +mockk = "1.14.7" multiplatformMarkdownRenderer = "0.38.1" programguide = "1.6.0" 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" } hilt-android = { module = "com.google.dagger:hilt-android", 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-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" }