Improve track persistence for series & episodes (#1802)

## Description
This PR overhauls how audio and subtitle track persistence works for
series! Previously, when you chose an audio or subtitle track for an
episode, only the language was saved and used for subsequent episodes.
This meant aspects about why that choice were lost such as a forced or
hearing impaired track.

This PR changes the persistence to include more details about the chosen
track. Additionally, the persistence is saved at both the season and
series levels.

## How it works

Selecting the track uses the first success option:
1. Episode specific - User has explicitly chosen an audio/subtitle track
for this episode
2. Season specific - User has chosen the track for another episode in
this season
3. Series specific - User has chosen the track for another episode in
this series
4. User preferences - User has _not_ chosen a track for the episode,
season, nor series, so determine track based on the preferred audio
language, preferred subtitle language, and subtitle mode

Additionally, the season or series specific choices use a scoring
algorithm to find the best suited track. For example, if the chosen
tracks for an episode in the season had the forced flag, this will be
preferred for subsequent episodes. Same for SDH/hearing impaired or
title, etc. This means that the common case, where a season has
substantially identical tracks, will use the same ones across all
episodes (after choosing for one, otherwise falling back to the series
or user preferences).

Finally, this also means that users that prefer SDH/hearing impaired
subtitles can be prioritized now, though there is no global preference
for this yet.

The previous track language choices are migrated to the new algorithm.
They lack the additional context, but this makes for a seamless
transition.

## UI changes

The context menu for choosing audio or subtitle tracks now displays why
that track was chosen.

### Related issues
Fixes #1795
Maybe fixes #1783
Related to #1136 & #1798
Closes #1470

### Testing
Previous unit tests for track selection all pass, plus there are a few
additional unit tests. I've done some testing on the emulator and it
seems to work well. But I think real world testing will be needed.

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-10-16 10:53:44 -04:00 • committed by GitHub
parent 55960c2462
commit 3ef221bf91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2780 additions and 286 deletions

View file

@ -0,0 +1,149 @@
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.test.TestTracks
import com.github.damontecres.wholphin.test.assertNotNull
import org.junit.Assert.assertEquals
import org.junit.Test
class RelativeMediaStreamTest {
@Test
fun `Test getRelativeIndex`() {
val source =
TestTracks
.Builder()
.addSubtitle(2)
.addAudio(2)
.addVideo()
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(0, relativeIndex)
}
RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(1, relativeIndex)
}
RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(0, relativeIndex)
}
RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(1, relativeIndex)
}
}
@Test
fun `Test getRelativeIndex for stream`() {
val source =
TestTracks
.Builder()
.addSubtitle(2)
.addAudio(2)
.addVideo()
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex ->
assertNotNull(relativeIndex)
assertEquals(1, relativeIndex)
}
}
@Test
fun `Test asRelative basic`() {
val source =
TestTracks
.Builder()
.addSubtitle(2)
.addAudio(2)
.addVideo()
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
assertEquals(5, streams.size)
val relative = streams.asRelative()
// Subtitle
assertEquals(0, relative[0].relativeIndex)
assertEquals(0, relative[0].stream.index)
assertEquals(1, relative[1].relativeIndex)
assertEquals(1, relative[1].stream.index)
// Audio
assertEquals(0, relative[2].relativeIndex)
assertEquals(2, relative[2].stream.index)
assertEquals(1, relative[3].relativeIndex)
assertEquals(3, relative[3].stream.index)
// Video
assertEquals(0, relative[4].relativeIndex)
assertEquals(4, relative[4].stream.index)
}
@Test
fun `Test asRelative with external`() {
val source =
TestTracks
.Builder()
.addExternalSubtitle(2)
.addSubtitle(2)
.addAudio(2)
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
assertEquals(6, streams.size)
val relative = streams.asRelative()
// External Subtitle
assertEquals(0, relative[0].relativeIndex)
assertEquals(0, relative[0].stream.index)
assertEquals(1, relative[1].relativeIndex)
assertEquals(1, relative[1].stream.index)
// Subtitle
assertEquals(0, relative[2].relativeIndex)
assertEquals(2, relative[2].stream.index)
assertEquals(1, relative[3].relativeIndex)
assertEquals(3, relative[3].stream.index)
// Audio
assertEquals(0, relative[4].relativeIndex)
assertEquals(4, relative[4].stream.index)
assertEquals(1, relative[5].relativeIndex)
assertEquals(5, relative[5].stream.index)
}
@Test
fun `Test asRelative with external mixed`() {
val source =
TestTracks
.Builder()
.addExternalSubtitle(1)
.addSubtitle(1)
.addAudio(1)
.addExternalSubtitle(1)
.addAudio(1)
.addSubtitle(1)
.buildMediaSourceInfo()
val streams = source.mediaStreams!!
assertEquals(6, streams.size)
val relative = streams.asRelative()
// External Subtitle
assertEquals(0, relative[0].relativeIndex)
assertEquals(0, relative[0].stream.index)
assertEquals(1, relative[3].relativeIndex)
assertEquals(3, relative[3].stream.index)
// Subtitle
assertEquals(0, relative[1].relativeIndex)
assertEquals(1, relative[1].stream.index)
assertEquals(1, relative[5].relativeIndex)
assertEquals(5, relative[5].stream.index)
// Audio
assertEquals(0, relative[2].relativeIndex)
assertEquals(2, relative[2].stream.index)
assertEquals(1, relative[4].relativeIndex)
assertEquals(4, relative[4].stream.index)
}
}

View file

@ -1,10 +1,12 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced(
),
streamAudioLang = "eng",
),
TestInput(
0,
SubtitlePlaybackMode.NONE,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = true),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
TestInput(
0,
SubtitlePlaybackMode.DEFAULT,
userSubtitleLang = "eng",
subtitles =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
TestInput(
null,
SubtitlePlaybackMode.NONE,
userSubtitleLang = null,
subtitles =
listOf(
subtitle(0, "eng", forced = false),
subtitle(1, "spa", forced = false),
),
streamAudioLang = "eng",
plc = plc(subtitlesOnlyForced = true),
),
)
}
}
@ -885,7 +935,7 @@ data class TestInput(
val streamAudioLang: String? = "eng",
val subtitles: List<MediaStream>,
val itemPlayback: ItemPlayback? = null,
val plc: PlaybackLanguageChoice? = null,
val plc: List<SeriesTrackChoice> = emptyList(),
val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE,
val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE,
@ -895,7 +945,7 @@ data class TestInput(
private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)"
private fun serverRepo(
fun mockServerRepo(
audioLang: String?,
subtitleMode: SubtitlePlaybackMode?,
subtitleLang: String?,
@ -918,15 +968,15 @@ private fun serverRepo(
private fun runTest(input: TestInput) {
val service =
StreamChoiceService(
serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
mockk<PlaybackLanguageChoiceDao>(),
mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang),
mockk<SeriesTrackChoiceDao>(),
)
val result =
service.chooseSubtitleStream(
audioStreamLang = input.streamAudioLang,
candidates = input.subtitles,
itemPlayback = input.itemPlayback,
playbackLanguageChoice = input.plc,
stc = input.plc,
prefs =
UserPreferences(
AppPreferences.getDefaultInstance(),
@ -937,7 +987,7 @@ private fun runTest(input: TestInput) {
),
),
)
Assert.assertEquals(input.expectedIndex, result?.index)
Assert.assertEquals(input.expectedIndex, result.stream?.index)
}
fun subtitle(
@ -945,6 +995,8 @@ fun subtitle(
lang: String?,
default: Boolean = false,
forced: Boolean = false,
sdh: Boolean = false,
external: Boolean = false,
title: String? = null,
): MediaStream =
MediaStream(
@ -952,6 +1004,26 @@ fun subtitle(
language = lang,
isDefault = default,
isForced = forced,
isHearingImpaired = sdh,
isInterlaced = false,
index = index,
isExternal = external,
isTextSubtitleStream = true,
supportsExternalStream = true,
title = title,
)
fun audio(
index: Int,
lang: String?,
default: Boolean = false,
title: String? = null,
): MediaStream =
MediaStream(
type = MediaStreamType.AUDIO,
language = lang,
isDefault = default,
isForced = false,
isHearingImpaired = false,
isInterlaced = false,
index = index,
@ -978,11 +1050,63 @@ 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,
)
subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null,
): List<SeriesTrackChoice> =
buildList {
audioLang
?.let {
SeriesTrackChoice(
userId = 1,
language = audioLang,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.AUDIO,
activation = ActivationFlag.ACTIVATED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
)
}?.let(::add)
if (subtitleLang != null) {
SeriesTrackChoice(
userId = 1,
language = subtitleLang,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
).let(::add)
} else if (subtitlesDisabled == true) {
SeriesTrackChoice(
userId = 1,
language = null,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
).let(::add)
} else if (subtitlesOnlyForced == true) {
SeriesTrackChoice(
userId = 1,
language = null,
parentId = UUID.randomUUID(),
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ONLY_FORCED,
trackFlags = 0,
codec = null,
relativeTrackIndex = null,
title = null,
audioChannels = null,
).let(::add)
}
}

View file

@ -0,0 +1,534 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.model.ActivationFlag
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences
import com.github.damontecres.wholphin.data.model.SeriesTrackChoice
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.TrackChoiceParentType
import com.github.damontecres.wholphin.data.model.TrackFlag
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.StreamChoiceReason
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.audio
import com.github.damontecres.wholphin.services.mockServerRepo
import com.github.damontecres.wholphin.services.subtitle
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class TestSeriesTrackChoice {
private val mockServerRepository =
mockServerRepo(
audioLang = "eng",
subtitleLang = "eng",
subtitleMode = SubtitlePlaybackMode.DEFAULT,
)
private val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = false)
private val streamChoiceService =
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
private val seriesId = UUID.randomUUID()
private val season1Id = UUID.randomUUID()
private val season2Id = UUID.randomUUID()
private val season3Id = UUID.randomUUID()
private val user =
JellyfinUser(
1,
UUID.randomUUID(),
null,
UUID.randomUUID(),
null,
)
@Before
fun before() {
every { mockServerRepository.currentUser } returns user
}
@Test
fun `Test StreamChoiceService call correct dao function`() =
runTest {
val mockSeriesTrackChoiceDao = mockk<SeriesTrackChoiceDao>(relaxed = true)
val streamChoiceService =
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
val seriesId = UUID.randomUUID()
val seasonId = UUID.randomUUID()
// Has both series & season
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
seriesId = seriesId,
parentId = seasonId,
).let { item ->
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
coVerify(exactly = 1) {
mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
}
}
// Has only series
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
seriesId = seriesId,
parentId = null,
).let { item ->
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
coVerify(exactly = 1) {
mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE)
}
}
// Has only season
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
seriesId = null,
parentId = seasonId,
).let { item ->
streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE)
coVerify(exactly = 1) {
mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE)
}
}
}
@Test
fun `Test series disabled`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
),
)
assertNull(result.stream)
}
@Test
fun `Test series only forced`() =
runTest {
val mockServerRepository =
mockServerRepo(
audioLang = "eng",
subtitleLang = "eng",
subtitleMode = SubtitlePlaybackMode.NONE,
)
val streamChoiceService =
StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao)
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs =
UserPreferences(
AppPreferences.getDefaultInstance(),
JellyfinUserPreferences(),
),
candidates =
listOf(
subtitle(0, "eng", forced = true),
subtitle(1, "eng", forced = false),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ONLY_FORCED,
),
),
)
assertNotNull(result.stream)
assertEquals(0, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test series disabled with season`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
),
)
assertNull(result.stream)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test item playback overrides STC for subtitles`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback =
ItemPlayback(
userId = user.rowId,
itemId = UUID.randomUUID(),
subtitleIndex = 1,
),
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.DISABLED,
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Item)
}
@Test
fun `Test item playback overrides STC for audio`() =
runTest {
val result =
streamChoiceService.chooseAudioStream(
itemPlayback =
ItemPlayback(
userId = user.rowId,
itemId = UUID.randomUUID(),
audioIndex = 1,
),
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
audio(0, "eng", default = true),
audio(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.AUDIO,
language = "eng",
),
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.AUDIO,
language = "eng",
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Item)
}
@Test
fun `Test season STC doesn't apply but series STC does - subtitle`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback =
ItemPlayback(
userId = user.rowId,
itemId = UUID.randomUUID(),
audioIndex = 1,
),
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true),
subtitle(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ONLY_FORCED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
language = "spa",
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test season STC doesn't apply but series STC does - audio`() =
runTest {
val result =
streamChoiceService.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
audio(0, "eng", default = true),
audio(1, "spa"),
),
stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.AUDIO,
activation = ActivationFlag.ONLY_FORCED,
),
SeriesTrackChoice(
userId = user.rowId,
parentId = seriesId,
parentType = TrackChoiceParentType.SERIES,
type = SeriesTrackChoiceType.AUDIO,
activation = ActivationFlag.ACTIVATED,
language = "spa",
),
),
)
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Series)
}
@Test
fun `Test STC has forced flag`() =
runTest {
val stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = listOf(TrackFlag.FORCED).calculateFlag(),
),
)
// Choose forced track
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = true),
subtitle(1, "spa"),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(0, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
// No forced track to choose from, so nothing should be chosen
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = false, forced = false),
subtitle(1, "spa"),
),
stc = stc,
).let { result ->
assertNull(result.stream)
}
}
@Test
fun `Test prefer SDH`() =
runTest {
val stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = listOf(TrackFlag.SDH).calculateFlag(),
),
)
// Prefer SDH over default and non-SDH
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = true),
subtitle(1, "eng", default = false, forced = false, sdh = false),
subtitle(2, "eng", default = false, forced = false, sdh = true),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(2, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
// No SDH, so prefer regular over forced
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = true),
subtitle(1, "eng", default = false, forced = false, sdh = false),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
}
@Test
fun `Test prefer default track with no other flags`() =
runTest {
val stc =
listOf(
SeriesTrackChoice(
userId = user.rowId,
parentId = season1Id,
parentType = TrackChoiceParentType.SEASON,
type = SeriesTrackChoiceType.SUBTITLE,
activation = ActivationFlag.ACTIVATED,
trackFlags = 0,
),
)
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = true, forced = false),
subtitle(1, "eng", default = false, forced = false),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(0, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
// Test out-of-order tracks
streamChoiceService
.chooseSubtitleStream(
audioStreamLang = "eng",
itemPlayback = null,
prefs = UserPreferences(AppPreferences.getDefaultInstance(), null),
candidates =
listOf(
subtitle(0, "eng", default = false, forced = false),
subtitle(1, "eng", default = true, forced = false),
),
stc = stc,
).let { result ->
assertNotNull(result.stream)
assertEquals(1, result.stream!!.index)
assertTrue(result.reason is StreamChoiceReason.Season)
}
}
}

View file

@ -0,0 +1,44 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.data.model.TrackFlag
import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.EnumSet
class TestTrackFlag {
@Test
fun basicTest() {
val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag()
assertTrue(TrackFlag.DEFAULT.within(flag))
assertTrue(TrackFlag.EXTERNAL.within(flag))
assertFalse(TrackFlag.FORCED.within(flag))
assertFalse(TrackFlag.SDH.within(flag))
}
@Test
fun none() {
val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag()
assertFalse(TrackFlag.DEFAULT.within(flag))
assertFalse(TrackFlag.EXTERNAL.within(flag))
assertFalse(TrackFlag.FORCED.within(flag))
assertFalse(TrackFlag.SDH.within(flag))
}
@Test
fun all() {
val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag()
TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) }
}
@Test
fun checkFlags() {
TrackFlag.entries.forEach {
assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0)
}
val distinctFlags = TrackFlag.entries.map { it.flag }.distinct()
assertEquals(TrackFlag.entries.size, distinctFlags.size)
}
}

View file

@ -11,7 +11,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.NameGuidPair
import org.jellyfin.sdk.model.api.UserDto
import org.junit.Assert.assertNotNull
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@ -149,3 +148,16 @@ inline fun <reified T : Any> assertIs(obj: Any?) {
Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ")
}
}
/**
* Asserts that the object is not null
*
* Includes a [contract] to smart-cast the object to T
*/
@OptIn(ExperimentalContracts::class)
fun assertNotNull(obj: Any?) {
contract {
returns() implies (obj != null)
}
org.junit.Assert.assertNotNull(obj)
}

View file

@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.RememberedTabDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
@ -266,6 +267,10 @@ object TestDatabaseModule {
@Singleton
fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao()
@Provides
@Singleton
fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao()
@Provides
@Singleton
fun userPreferencesDataStore(

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistItem
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.PlaybackPreferences
@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceReason
import com.github.damontecres.wholphin.services.StreamChoiceResult
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.test.TestTracks
@ -64,6 +67,7 @@ import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.api.operations.VideosApi
import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult
import org.jellyfin.sdk.model.api.MediaSegmentType
@ -201,7 +205,12 @@ class PlaybackViewModelTests {
coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null
coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource
coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null
coEvery {
mockStreamChoiceService.getSeriesTrackChoices(
any<BaseItemDto>(),
any<SeriesTrackChoiceType>(),
)
} returns emptyList()
coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns
PlayerCreation(mockPlayer)
every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true)
@ -214,18 +223,26 @@ class PlaybackViewModelTests {
any(),
any(),
)
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }
} returns
StreamChoiceResult(
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO },
StreamChoiceReason.Unknown,
)
coEvery {
mockStreamChoiceService.chooseSubtitleStream(
item = any(),
source = mediaSource,
any(),
any(),
any(),
any(),
any(),
audioStream = any(),
itemPlayback = any(),
stc = any(),
prefs = any(),
)
} returns
StreamChoiceResult(
mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE },
StreamChoiceReason.Unknown,
)
} returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }
}
@OptIn(ExperimentalCoroutinesApi::class)