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:
parent
55960c2462
commit
3ef221bf91
29 changed files with 2780 additions and 286 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue