v1.1.0
All checks were successful
review/code-claude Claude cross-family review: approve, no blocking findings

-----BEGIN SSH SIGNATURE-----
 U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K
 Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
 AAAAQOwlHRfQqaoMZESsY18BqmXMIMwtJzU8cAEB2FWHNv5x2j/ZsGi8ES7VNW+rYtgV/B
 KF9ctdqeDoocL8r8da+AY=
 -----END SSH SIGNATURE-----

Merge upstream v1.1.0 while preserving Wolfskin customizations
This commit is contained in:
justinzeus 2026-11-05 01:39:09 +01:00
commit 670e95f2fa
159 changed files with 10810 additions and 2864 deletions

View file

@ -1,14 +1,12 @@
package com.github.damontecres.wholphin.test
package com.github.damontecres.wholphin
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.datastore.core.DataStore
import com.github.damontecres.wholphin.MainActivityViewModel
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.services.AppUpgradeHandler
@ -19,8 +17,10 @@ import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.test.currentUser
import com.github.damontecres.wholphin.test.server
import com.github.damontecres.wholphin.test.user
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
@ -36,11 +36,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.UUID
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -58,7 +58,6 @@ class TestMainActivityViewModel {
private val backdropService: BackdropService = mockk(relaxed = true)
private val appUpgradeHandler: AppUpgradeHandler = mockk()
private val intentService: IntentService = mockk()
private val serverDao: JellyfinServerDao = mockk()
val viewModel =
MainActivityViewModel(
@ -74,6 +73,7 @@ class TestMainActivityViewModel {
)
private val serverId = UUID.randomUUID()
private val server = server(serverId)
private val userId = UUID.randomUUID()
private val currentUser = currentUser(serverId, userId)
@ -88,13 +88,6 @@ class TestMainActivityViewModel {
WholphinDispatchers.configure(testDispatcher)
every { appUpgradeHandler.needUpgrade() } returns false
every { appUpgradeHandler.copySubfont(any()) } returns Unit
every { serverRepository.serverDao } returns serverDao
coEvery { serverDao.getServer(serverId) } returns
JellyfinServerUsers(
currentUser.server,
// User list is unused
emptyList(),
)
mockkStatic(Toast::class)
val mockToast = mockk<Toast>()
every { Toast.makeText(any(), any<CharSequence>(), any()) } returns mockToast
@ -121,22 +114,26 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.restoreLastSession() } returns
// RestoredSession.Success(currentUser)
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.AppContent,
)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
Assert.assertEquals(currentUser, destination.current)
}
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@Test
@ -144,27 +141,27 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = false
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
// coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.getMostRecentServer() } returns
RestoredSession.ServerOnly(server)
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
verify(exactly = 1) { serverDao.getServer(serverId) }
coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 1) { serverRepository.getMostRecentServer() }
val args = mutableListOf<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(args)) }
assertEquals(2, args.size)
Assert.assertEquals(1, args.size)
args[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
}
args[1].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -173,27 +170,30 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
every { serverRepository.current } returns
MutableStateFlow<CurrentUser?>(
protectedCurrentUser,
)
coEvery {
serverRepository.tryChangeUser(serverId, userId)
} returns null
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(server)
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
verify { serverDao.getServer(serverId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
val args = mutableListOf<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(args)) }
assertEquals(2, args.size)
Assert.assertEquals(1, args.size)
args[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading)
}
args[1].let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -202,22 +202,26 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(server)
coEvery {
serverRepository.tryChangeUser(serverId, userId)
} returns null
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -226,45 +230,49 @@ class TestMainActivityViewModel {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSession(null, null) } returns null
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
viewModel.appStart(null)
advanceUntilIdle()
viewModel.handleAppStart()
coVerify(exactly = 1) { serverRepository.restoreSession(null, null) }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.ServerList,
)
}
}
@Test
fun `Test intent result is noop`() =
fun `Test intent result is noop, hot reload`() =
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertFalse(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
}
// val slot = slot<SetupDestination>()
// verify { setupNavigationManager.navigateTo(capture(slot)) }
// slot.captured.let { destination ->
// Assert.assertTrue(
// "destination is ${destination::class}",
// destination is SetupDestination.AppContent,
// )
// destination as SetupDestination.AppContent
// Assert.assertEquals(currentUser, destination.current)
// }
coVerify(exactly = 0) { serverRepository.restoreLastSession() }
coVerify(exactly = 0) { serverRepository.getMostRecentServer() }
}
@Test
@ -272,22 +280,22 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.UserList,
)
destination as SetupDestination.UserList
assertEquals(currentUser.server, destination.server)
Assert.assertEquals(currentUser.server, destination.server)
}
}
@ -296,20 +304,20 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error")
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.ServerList,
)
}
}
@ -318,11 +326,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@ -331,21 +337,27 @@ class TestMainActivityViewModel {
val backstack = mutableListOf<Destination>()
every { navigationManager.backStack } returns backstack
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify { navigationManager.reloadHome() }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.AppContent,
)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
Assert.assertEquals(currentUser, destination.current)
}
// Note: navigation manager internally handles Home destination, so it won't be in this list
assertEquals(1, backstack.size)
Assert.assertEquals(1, backstack.size)
backstack[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
Assert.assertTrue(
"destination is ${destination::class}",
destination is Destination.Favorites,
)
}
}
@ -354,11 +366,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@ -367,20 +377,26 @@ class TestMainActivityViewModel {
val backstack = mutableListOf<Destination>()
every { navigationManager.backStack } returns backstack
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.AppContent,
)
destination as SetupDestination.AppContent
assertEquals(currentUser, destination.current)
Assert.assertEquals(currentUser, destination.current)
}
assertEquals(1, backstack.size)
Assert.assertEquals(1, backstack.size)
backstack[0].let { destination ->
assertTrue("destination is ${destination::class}", destination is Destination.Favorites)
Assert.assertTrue(
"destination is ${destination::class}",
destination is Destination.Favorites,
)
}
}
@ -389,11 +405,9 @@ class TestMainActivityViewModel {
runTest(testDispatcher) {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser
coEvery { intentService.parseIntent(any()) } returns
IntentResult.Target(
destinations = listOf(Destination.Favorites),
@ -402,14 +416,17 @@ class TestMainActivityViewModel {
val backstack = mutableListOf<Destination>()
every { navigationManager.backStack } returns backstack
viewModel.appStart(Intent())
advanceUntilIdle()
val result = viewModel.handleIntent(Intent())
assertTrue(result)
val slot = slot<SetupDestination>()
verify { setupNavigationManager.navigateTo(capture(slot)) }
verify(exactly = 0) { navigationManager.reloadHome() }
slot.captured.let { destination ->
assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList)
Assert.assertTrue(
"destination is ${destination::class}",
destination is SetupDestination.ServerList,
)
}
}
}

View file

@ -0,0 +1,48 @@
package com.github.damontecres.wholphin.data.model
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.UUID
class BaseItemSubtitleTest {
private fun album(
albumArtist: String? = null,
artists: List<String>? = null,
productionYear: Int? = null,
) = BaseItem(
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.MUSIC_ALBUM,
name = "Test Album",
albumArtist = albumArtist,
artists = artists,
productionYear = productionYear,
),
)
@Test
fun albumPrefersAlbumArtist() {
val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021)
assertEquals("Album Artist - 2021", item.subtitle)
}
@Test
fun albumFallsBackToArtists() {
val item = album(artists = listOf("A", "B"), productionYear = 2021)
assertEquals("A, B - 2021", item.subtitle)
}
@Test
fun albumWithoutYear() {
assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle)
}
@Test
fun albumWithoutArtist() {
assertEquals("2021", album(productionYear = 2021).subtitle)
assertNull(album(artists = emptyList()).subtitle)
}
}

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

@ -0,0 +1,207 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import io.mockk.every
import io.mockk.mockk
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.operations.ImageApi
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.ImageType
import org.jellyfin.sdk.model.serializer.toUUID
import org.junit.Assert
import org.junit.Before
import org.junit.Test
class ImageUrlServiceTest {
private val mockApiClient = mockk<ApiClient>()
private val imageUrlService = ImageUrlService(mockApiClient)
private val itemId = UUID.randomUUID()
private val seriesId = UUID.randomUUID()
private val seasonId = UUID.randomUUID()
@Before
fun setup() {
every { mockApiClient.baseUrl } returns "http://localhost"
every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() }
every { mockApiClient.imageApi } returns ImageApi(mockApiClient)
}
private fun episode(
imageTags: Map<ImageType, String> = emptyMap(),
parentLogoItemId: UUID? = null,
parentThumbItemId: UUID? = null,
parentBackdropItemId: UUID? = null,
) = BaseItemDto(
id = itemId,
type = BaseItemKind.EPISODE,
seriesId = seriesId,
seasonId = seasonId,
imageTags = imageTags,
seriesPrimaryImageTag = "seriesPrimary",
parentLogoItemId = parentLogoItemId,
parentLogoImageTag = parentLogoItemId?.let { "parentLogo" },
parentThumbItemId = parentThumbItemId,
parentThumbImageTag = parentThumbItemId?.let { "parentThumb" },
parentBackdropItemId = parentBackdropItemId,
parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") },
)
private fun movie(
imageTags: Map<ImageType, String> = emptyMap(),
backdropImageTags: List<String>? = null,
) = BaseItemDto(
id = itemId,
type = BaseItemKind.MOVIE,
imageTags = imageTags,
backdropImageTags = backdropImageTags,
)
private fun assertImage(
url: String?,
expectedId: UUID,
expectedType: ImageType,
expectedTag: String?,
) {
Assert.assertNotNull(url)
val path = url!!.substringBefore('?')
Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID())
Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/"))
val tag =
url
.substringAfter('?', "")
.split('&')
.firstOrNull { it.startsWith("tag=") }
?.substringAfter("tag=")
Assert.assertEquals(expectedTag, tag)
}
@Test
fun `Own images use own tags`() {
val item =
BaseItem(
movie(
imageTags =
mapOf(
ImageType.PRIMARY to "primary",
ImageType.LOGO to "logo",
ImageType.THUMB to "thumb",
ImageType.BANNER to "banner",
ImageType.ART to "art",
),
backdropImageTags = listOf("backdrop0", "backdrop1"),
),
)
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary")
assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo")
assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb")
assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner")
assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art")
assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0")
}
@Test
fun `Missing tags are omitted`() {
val item = BaseItem(movie())
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null)
assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null)
Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP))
}
@Test
fun `Thumb falls back to own backdrop`() {
val item = BaseItem(movie(backdropImageTags = listOf("backdrop0")))
assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0")
}
@Test
fun `Episode primary uses series tag`() {
val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true)
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null)
assertImage(
imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false),
itemId,
ImageType.PRIMARY,
"primary",
)
}
@Test
fun `Season without primary uses series tag`() {
val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null))
assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary")
}
@Test
fun `Episode logo uses parent logo tag only if from series`() {
assertImage(
imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO),
seriesId,
ImageType.LOGO,
"parentLogo",
)
assertImage(
imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO),
seriesId,
ImageType.LOGO,
null,
)
}
@Test
fun `Episode backdrop uses parent backdrop tag only if from series`() {
assertImage(
imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP),
seriesId,
ImageType.BACKDROP,
"parentBackdrop0",
)
assertImage(
imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP),
seriesId,
ImageType.BACKDROP,
null,
)
}
@Test
fun `Episode thumb uses parent tags`() {
assertImage(
imageUrlService.getItemImageUrl(
BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true),
ImageType.THUMB,
),
seriesId,
ImageType.THUMB,
"parentThumb",
)
assertImage(
imageUrlService.getItemImageUrl(
BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true),
ImageType.THUMB,
),
seriesId,
ImageType.BACKDROP,
"parentBackdrop0",
)
assertImage(
imageUrlService.getItemImageUrl(
BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true),
ImageType.THUMB,
),
itemId,
ImageType.PRIMARY,
"primary",
)
}
@Test
fun `Season without thumb uses parent thumb tag`() {
val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON))
assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb")
}
}

View file

@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
@ -20,7 +20,6 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
@ -42,7 +41,6 @@ import org.robolectric.annotation.Config
class IntentServiceTest {
private val api: ApiClient = mockk()
private val serverRepository: ServerRepository = mockk()
private val serverDao: JellyfinServerDao = mockk()
private val userPreferencesService: UserPreferencesService = mockk()
private val userLibraryApi: UserLibraryApi = mockk()
@ -64,7 +62,6 @@ class IntentServiceTest {
intentService = IntentService(api, serverRepository, userPreferencesService)
every { api.userLibraryApi } returns userLibraryApi
coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId))
every { serverRepository.serverDao } returns serverDao
}
private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) {
@ -82,11 +79,11 @@ class IntentServiceTest {
private fun setupAutoSignInUnprotected() {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
}
@Test
@ -98,7 +95,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
coVerify { serverRepository.restoreSession(serverId, userId) }
coVerify { serverRepository.restoreLastSession() }
}
@Test
@ -106,17 +103,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = false
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -124,17 +121,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(protectedCurrentUser)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -142,17 +139,17 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = serverId.toServerString()
currentUserId = userId.toServerString()
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.restoreLastSession() }
}
@Test
@ -160,17 +157,16 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None
// coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
val intent = Intent()
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -178,12 +174,11 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user
coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser
coEvery { serverRepository.restoreLastSession() } returns
RestoredSession.Success(currentUser)
val intent =
Intent().apply {
@ -193,7 +188,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertNull(result)
coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -201,12 +196,11 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user
coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
// coEvery { serverRepository.restoreLastSession() } returns
// RestoredSession.ServerOnly(protectedCurrentUser.server)
val intent =
Intent().apply {
@ -216,7 +210,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -224,12 +218,9 @@ class IntentServiceTest {
runTest {
setupPreferences {
signInAutomatically = true
currentServerId = ""
currentUserId = ""
}
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(null)
coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null
coEvery { serverRepository.restoreSession(serverId, userId) } returns null
coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null
val intent =
Intent().apply {
@ -239,7 +230,7 @@ class IntentServiceTest {
val result = intentService.prepare(intent)
assertTrue(result is IntentResult.Error)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) }
}
@Test
@ -296,12 +287,11 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery {
serverRepository.restoreSession(
serverRepository.tryChangeUser(
newServerId,
newUserId,
)
} returns newCurrentUser
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@ -316,9 +306,8 @@ class IntentServiceTest {
assertEquals(0, destinations.size)
assertTrue(result.addHomeToBackStack)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
verify { serverDao.getUser(newServerId, newUserId) }
coVerify { serverRepository.restoreSession(newServerId, newUserId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
coVerify(exactly = 0) { userLibraryApi.getItem(itemId) }
}
@ -333,12 +322,11 @@ class IntentServiceTest {
every { serverRepository.current } returns MutableStateFlow<CurrentUser?>(currentUser)
coEvery {
serverRepository.restoreSession(
serverRepository.tryChangeUser(
newServerId,
newUserId,
)
} returns newCurrentUser
every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user
val intent =
Intent().apply {
@ -358,9 +346,8 @@ class IntentServiceTest {
assertEquals(itemId, first.itemId)
coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) }
verify { serverDao.getUser(newServerId, newUserId) }
coVerify { serverRepository.restoreSession(newServerId, newUserId) }
coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) }
coVerify { serverRepository.tryChangeUser(newServerId, newUserId) }
coVerify { userLibraryApi.getItem(itemId) }
}

View file

@ -1,11 +1,13 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.content.SharedPreferences
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.MostRecentServer
import com.github.damontecres.wholphin.data.MostRecentServerProvider
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinServerUsers
@ -14,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.ServerUserConfig
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.test.nonBlankString
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.WholphinDispatchers
@ -21,6 +24,7 @@ import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -29,7 +33,6 @@ import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
@ -48,6 +51,8 @@ import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.jellyfin.sdk.model.api.UserDto
import org.junit.After
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -80,14 +85,13 @@ class ServerRepositoryTest {
private val mockUserApi = mockk<UserApi>()
private val mockSystemApi = mockk<SystemApi>()
private val mockSharedPreferences = mockk<SharedPreferences>(relaxed = true)
private val mockMostRecentServerProvider = mockk<MostRecentServerProvider>()
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
WholphinDispatchers.configure(testDispatcher)
every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences
every { mockApiClient.userApi } returns mockUserApi
every { mockApiClient.systemApi } returns mockSystemApi
coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto)
@ -102,9 +106,15 @@ class ServerRepositoryTest {
emptyMap(),
)
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
coEvery { mockJellyfinServerDao.getServer(serverId) } returns
JellyfinServerUsers(server, listOf(user))
coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs
every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1")
every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device")
every { mockApiClient.update(any(), any(), any(), any()) } just Runs
coEvery { mockMostRecentServerProvider.save(any()) } just Runs
coEvery { mockMostRecentServerProvider.clear() } just Runs
coEvery { mockMostRecentServerProvider.clearUser() } just Runs
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -119,8 +129,8 @@ class ServerRepositoryTest {
mockJellyfin,
mockJellyfinServerDao,
mockApiClient,
dataStore,
testDispatcher,
mockMostRecentServerProvider,
)
private val serverId = UUID.randomUUID()
@ -194,24 +204,24 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
val appPreferences = dataStore.data.first()
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
verify(exactly = 1) { mockSharedPreferences.edit() }
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) }
}
@Test
fun `Test restoreUser via changeUser`() =
runTest {
val user = user.copy(pin = null)
every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs
every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
val serverRepository = create()
Assert.assertNull(serverRepository.currentUser)
serverRepository.restoreSession(server.id, user.id)
val result = serverRepository.tryChangeUser(server.id, user.id)
assertTrue(result != null)
assertEquals(serverId, result?.server?.id)
assertEquals(user.id, result?.user?.id)
verify(exactly = 1) {
mockApiClient.update(
@ -226,34 +236,6 @@ class ServerRepositoryTest {
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
}
@Test
fun `Test restoreUser via shortcut`() =
runTest {
every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user))
val serverRepository = create()
setUpCurrentUser(serverRepository)
serverRepository.restoreSession(server.id, user.id)
verify(exactly = 1) {
mockApiClient.update(
baseUrl = server.url,
accessToken = user.accessToken,
clientInfo = any(),
deviceInfo = any(),
)
}
verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) }
verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) }
Assert.assertEquals(server, serverRepository.currentServer)
Assert.assertEquals(user, serverRepository.currentUser)
// TODO fix this, need to check userDto too
// Assert.assertEquals(userDto, serverRepository.currentUserDto)
}
@Test
fun `Test remove user`() =
runTest {
@ -308,6 +290,7 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) }
coVerify { mockMostRecentServerProvider.clearUser() }
}
@Test
@ -351,6 +334,7 @@ class ServerRepositoryTest {
)
}
verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) }
coVerify { mockMostRecentServerProvider.clear() }
}
@Test
@ -387,11 +371,7 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
val appPreferences = dataStore.data.first()
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
verify(exactly = 1) { mockSharedPreferences.edit() }
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
}
@Test
@ -430,11 +410,7 @@ class ServerRepositoryTest {
Assert.assertEquals(user, serverRepository.currentUser)
Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto)
val appPreferences = dataStore.data.first()
Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId)
Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId)
verify(exactly = 1) { mockSharedPreferences.edit() }
coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) }
}
@Test
@ -484,4 +460,99 @@ class ServerRepositoryTest {
}
}
}
@Test
fun `Test restoreLastSession, success`() =
runTest {
val user = user.copy(pin = null)
coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user
coEvery { mockJellyfinServerDao.getServer(serverId) } returns
JellyfinServerUsers(server, listOf(user))
every { mockMostRecentServerProvider.get() } returns
MostRecentServer.from(CurrentUser(server, user))
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.Success)
val current = (result as RestoredSession.Success).currentUser
assertEquals(server, current.server)
assertEquals(user, current.user)
verify(exactly = 1) {
mockApiClient.update(
baseUrl = server.url,
accessToken = user.accessToken,
clientInfo = any(),
deviceInfo = any(),
)
}
coVerify { mockMostRecentServerProvider.save(any()) }
}
@Test
fun `Test restoreLastSession, user protected`() =
runTest {
every { mockMostRecentServerProvider.get() } returns
MostRecentServer.ServerAndUser(serverId, server.url, userId, "token")
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.ServerOnly)
val current = (result as RestoredSession.ServerOnly)
assertEquals(server, current.server)
}
@Test
fun `Test restoreLastSession, server only`() =
runTest {
every { mockMostRecentServerProvider.get() } returns
MostRecentServer.Server(server.id, server.url)
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.ServerOnly)
val current = (result as RestoredSession.ServerOnly)
assertEquals(server, current.server)
}
@Test
fun `Test restoreLastSession, nothing to restore`() =
runTest {
every { mockMostRecentServerProvider.get() } returns MostRecentServer.None
val result = create().restoreLastSession()
assertTrue(result is RestoredSession.None)
}
}
class TestMostRecentServerProvider : MostRecentServerProvider {
var mostRecentServer: MostRecentServer = MostRecentServer.None
override fun get(): MostRecentServer = mostRecentServer
override suspend fun save(current: CurrentUser) {
mostRecentServer =
if (current.user.accessToken.isNotNullOrBlank()) {
MostRecentServer.ServerAndUser(
current.server.id,
current.server.url,
current.user.id,
current.user.accessToken,
)
} else {
MostRecentServer.Server(
current.server.id,
current.server.url,
)
}
}
override suspend fun clear() {
mostRecentServer = MostRecentServer.None
}
override suspend fun clearUser() {
when (val s = mostRecentServer) {
MostRecentServer.None -> Unit
is MostRecentServer.Server -> Unit
is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl)
}
}
}

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

@ -5,8 +5,10 @@ import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.RestoredSession
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.test.currentUser
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import io.mockk.coEvery
import io.mockk.coVerify
@ -113,16 +115,16 @@ class SuggestionsWorkerTest {
val mockUser = mockk<CurrentUser>()
var restored = false
every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) }
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
coEvery { mockServerRepository.restoreLastSession() } coAnswers {
restored = true
mockUser
RestoredSession.Success(currentUser())
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
val result = createWorker().doWork()
coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
coVerify { mockServerRepository.restoreLastSession() }
assertEquals(ListenableWorker.Result.success(), result)
}

View file

@ -0,0 +1,85 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.HdrOverrideMode
import com.github.damontecres.wholphin.preferences.PlaybackOverrides
import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType
import org.junit.Assert
import org.junit.Test
class TestHdrFormat {
private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences =
AppPreferences.getDefaultInstance().updatePlaybackOverrides(block)
@Test
fun `Test all auto returns empty sets`() {
val prefs = appPreferences { }
Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true))
Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
}
@Test
fun `Test HDR10 override`() {
val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
Assert.assertEquals(
setOf(WholphinVideoRangeType.HDR10.serialName),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
)
Assert.assertEquals(emptySet<String>(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true))
}
@Test
fun `Test Dolby Vision Profile 5 override`() {
val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
Assert.assertEquals(
setOf(WholphinVideoRangeType.DOVI.serialName),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
)
}
@Test
fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() {
val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE }
Assert.assertEquals(
emptySet<String>(),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false),
)
Assert.assertEquals(
setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
)
}
@Test
fun `Test Dolby Vision Profile 8 override`() {
val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE }
Assert.assertEquals(
setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false),
)
Assert.assertEquals(
setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
)
}
@Test
fun `Test multiple formats set to the same mode are unioned`() {
val prefs =
appPreferences {
hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE
hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE
doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE
}
Assert.assertEquals(
setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true),
)
Assert.assertEquals(
setOf(WholphinVideoRangeType.DOVI.serialName),
prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true),
)
}
}

View file

@ -1,7 +1,9 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.KeyValueEntry
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.services.HomeSettingsService
import com.github.damontecres.wholphin.ui.AspectRatio
@ -13,7 +15,6 @@ import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.Assert
import org.junit.Test
import kotlin.reflect.KClass
@ -61,8 +62,8 @@ class TestHomeRowSamples {
),
HomeRowConfig.GetItems(
name = "Episodes by date created",
getItems =
GetItemsRequest(
filter =
HomeGetItemsFilter(
parentId = UUID.randomUUID(),
recursive = true,
isFavorite = true,
@ -77,6 +78,12 @@ class TestHomeRowSamples {
HomeRowConfig.TvPrograms(),
HomeRowConfig.TvChannels(),
HomeRowConfig.Suggestions(parentId = UUID.randomUUID()),
HomeRowConfig.CustomEndpoint(
endpoint = "/path/to/items",
title = "Items",
query = listOf(KeyValueEntry("key", "value")),
headers = listOf(KeyValueEntry("X-Header", "1234")),
),
)
}
@ -100,6 +107,7 @@ class TestHomeRowSamples {
is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
is HomeRowConfig.TvChannels -> foundTypes.add(it::class)
is HomeRowConfig.Studios -> foundTypes.add(it::class)
is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class)
}
}
Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)
@ -126,12 +134,10 @@ class TestHomeRowSamples {
context = mockk(),
api = mockk(),
serverRepository = mockk(),
userPreferencesService = mockk(),
navDrawerService = mockk(),
latestNextUpService = mockk(),
imageUrlService = mockk(),
suggestionService = mockk(),
displayPreferencesService = mockk(),
serverPluginApi = mockk(),
serverDao = mockk(),
)
val str = """{
@ -149,7 +155,7 @@ class TestHomeRowSamples {
]
}"""
val jsonElement = service.jsonParser.parseToJsonElement(str)
val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str)
val settings = service.decode(jsonElement)
Assert.assertEquals(1, settings.rows.size)
}

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

@ -17,15 +17,18 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.MostRecentServerProvider
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
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.TestMostRecentServerProvider
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
@ -201,6 +204,10 @@ object TestModule {
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
): SeerrApi = mockk()
@Provides
@Singleton
fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider()
}
@Module
@ -260,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

@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.services.DeletedItem
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
@ -34,6 +35,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.After
@ -57,6 +59,7 @@ class CollectionFolderViewModelTest {
private val mockUserLibraryApi = mockk<UserLibraryApi>()
private val artistsRequest = slot<GetArtistsRequest>()
private val albumArtistsRequest = slot<GetAlbumArtistsRequest>()
private val itemsRequest = slot<GetItemsRequest>()
private val libraryId = UUID.randomUUID()
@ -83,11 +86,14 @@ class CollectionFolderViewModelTest {
// against the relaxed ApiClient
mockkObject(GetItemsRequestHandler)
mockkObject(GetArtistsHandler)
mockkObject(GetAlbumArtistsHandler)
mockkObject(GetPersonsHandler)
coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns
successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT)
coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns
successQueryResult(totalRecordCount = ARTIST_COUNT)
coEvery { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns
successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT)
coEvery { GetPersonsHandler.execute(mockApi, any()) } returns
successQueryResult(totalRecordCount = PERSON_COUNT)
}
@ -97,33 +103,36 @@ class CollectionFolderViewModelTest {
WholphinDispatchers.reset()
unmockkObject(GetItemsRequestHandler)
unmockkObject(GetArtistsHandler)
unmockkObject(GetAlbumArtistsHandler)
unmockkObject(GetPersonsHandler)
}
private fun createViewModel(filter: GetItemsFilter) =
CollectionFolderViewModel(
savedStateHandle = SavedStateHandle(),
api = mockApi,
context = mockk(relaxed = true),
serverRepository = mockServerRepository,
libraryDisplayInfoDao = mockk(relaxed = true),
favoriteWatchManager = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
navigationManager = mockk(relaxed = true),
themeSongPlayer = mockk(relaxed = true),
userPreferencesService = mockk(relaxed = true),
mediaManagementService = mockMediaManagementService,
musicService = mockk(relaxed = true),
streamChoiceService = mockk(relaxed = true),
serverReportService = mockk(relaxed = true),
filterOptionCache = mockk(relaxed = true),
itemId = libraryId.toString(),
initialSortAndDirection = null,
recursive = true,
collectionFilter = CollectionFolderFilter(filter = filter),
useSeriesForPrimary = false,
defaultViewOptions = ViewOptionsSquare,
)
private fun createViewModel(
filter: GetItemsFilter,
viewOptions: ViewOptions = ViewOptionsSquare,
) = CollectionFolderViewModel(
savedStateHandle = SavedStateHandle(),
api = mockApi,
context = mockk(relaxed = true),
serverRepository = mockServerRepository,
libraryDisplayInfoDao = mockk(relaxed = true),
favoriteWatchManager = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
navigationManager = mockk(relaxed = true),
themeSongPlayer = mockk(relaxed = true),
userPreferencesService = mockk(relaxed = true),
mediaManagementService = mockMediaManagementService,
musicService = mockk(relaxed = true),
streamChoiceService = mockk(relaxed = true),
serverReportService = mockk(relaxed = true),
filterOptionCache = mockk(relaxed = true),
itemId = libraryId.toString(),
initialSortAndDirection = null,
recursive = true,
collectionFilter = CollectionFolderFilter(filter = filter),
useSeriesForPrimary = false,
defaultViewOptions = viewOptions,
)
/** Counting via /Items would return every song and album, overshooting the artist grid */
@Test
@ -143,6 +152,37 @@ class CollectionFolderViewModelTest {
coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any<GetItemsRequest>()) }
}
@Test
fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() =
runTest(testDispatcher) {
val viewModel =
createViewModel(
GetItemsFilter(override = GetItemsFilterOverride.ARTIST),
ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)),
)
advanceUntilIdle()
val position = viewModel.positionOfLetter('K')
assertEquals(ALBUM_ARTIST_COUNT, position)
assertEquals("K", albumArtistsRequest.captured.nameLessThan)
assertEquals(libraryId, albumArtistsRequest.captured.parentId)
coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any<GetArtistsRequest>()) }
}
@Test
fun `changing the artist source reloads from the album artists endpoint`() =
runTest(testDispatcher) {
val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST))
advanceUntilIdle()
coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) }
viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)))
advanceUntilIdle()
coVerify { GetAlbumArtistsHandler.execute(any(), any<GetAlbumArtistsRequest>()) }
}
/**
* Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists
* like "The Beatles" even though the grid sorts them under "B".
@ -197,6 +237,7 @@ class CollectionFolderViewModelTest {
companion object {
private const val LIBRARY_ITEM_COUNT = 7315
private const val ARTIST_COUNT = 730
private const val ALBUM_ARTIST_COUNT = 215
private const val PERSON_COUNT = 12
}
}

View file

@ -0,0 +1,276 @@
package com.github.damontecres.wholphin.ui.playback
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
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
import com.github.damontecres.wholphin.test.movie
import com.github.damontecres.wholphin.test.playlist
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.ExternalPlayerApp
import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.configure
import com.github.damontecres.wholphin.util.reset
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.subtitleApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.operations.PlayStateApi
import org.jellyfin.sdk.api.operations.SubtitleApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.api.operations.VideosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.minutes
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [30])
class PlayExternalViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val context = mockk<Context>()
private val api = mockk<ApiClient>()
private val userLibraryApi = mockk<UserLibraryApi>()
private val videosApi = mockk<VideosApi>()
private val subtitleApi = mockk<SubtitleApi>()
private val playStateApi = mockk<PlayStateApi>(relaxed = true)
private val serverRepository = mockk<ServerRepository>()
private val itemPlaybackDao = mockk<ItemPlaybackDao>()
private val playlistCreator = mockk<PlaylistCreator>()
private val streamChoiceService = mockk<StreamChoiceService>()
private val navigationManager = mockk<NavigationManager>(relaxed = true)
private val userPreferencesService = mockk<UserPreferencesService>()
private val movie = movie()
private val subtitleUrl = "http://localhost:8096/subtitles/0.srt"
private val videoUrl = "http://localhost:8096/video/stream"
private val mediaSource =
TestTracks
.Builder()
.addExternalSubtitle()
.buildMediaSourceInfo()
.let { source ->
source.copy(
id = "source-id",
runTimeTicks = 90.minutes.inWholeTicks,
mediaStreams =
source.mediaStreams!!.map {
it.copy(codec = "srt", path = "subtitle.srt", displayTitle = "English")
},
)
}
private val subtitle = mediaSource.mediaStreams!!.single()
private val vlc = ComponentName("org.videolan.vlc", "org.videolan.vlc.gui.video.VideoPlayerActivity")
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setUp() {
WholphinDispatchers.configure(testDispatcher)
mockkStatic(::getExternalPlayers)
every { getExternalPlayers(context) } returns
listOf(ExternalPlayerApp("VLC", null, vlc.flattenToString()))
every { api.userLibraryApi } returns userLibraryApi
every { api.videosApi } returns videosApi
every { api.subtitleApi } returns subtitleApi
every { api.playStateApi } returns playStateApi
every { serverRepository.currentUser } returns null
coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie)
coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns
StreamChoiceResult(subtitle, StreamChoiceReason.Unknown)
coEvery { streamChoiceService.getSeriesTrackChoices(any<BaseItemDto>(), SeriesTrackChoiceType.SUBTITLE) } returns emptyList()
every {
videosApi.getVideoStreamUrl(
itemId = movie.id,
mediaSourceId = mediaSource.id,
static = true,
)
} returns videoUrl
every {
subtitleApi.getSubtitleUrl(
routeItemId = any(),
routeMediaSourceId = mediaSource.id!!,
routeIndex = subtitle.index,
routeFormat = "srt",
)
} returns subtitleUrl
setupPlayer("")
}
@OptIn(ExperimentalCoroutinesApi::class)
@After
fun tearDown() {
unmockkStatic(::getExternalPlayers)
WholphinDispatchers.reset()
}
private fun setupPlayer(playerId: String) {
val preferences =
AppPreferences.getDefaultInstance().updatePlaybackPreferences {
externalPlayer = playerId
}
coEvery { userPreferencesService.getCurrent() } returns UserPreferences(preferences, null)
}
private fun createIntent(destination: Destination): Intent {
val viewModel =
PlayExternalViewModel(
SavedStateHandle(),
context,
api,
serverRepository,
itemPlaybackDao,
playlistCreator,
streamChoiceService,
navigationManager,
userPreferencesService,
)
viewModel.init(destination)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(LoadingState.Success, viewModel.state.value.loading)
return viewModel.state.value.intent
}
private fun assertStartFromBeginning(intent: Intent) {
assertEquals(true, intent.extras!!.get("from_start"))
assertEquals(0, intent.extras!!.get("position"))
}
@Test
fun `Explicit VLC starts from beginning at zero position`() =
runTest(testDispatcher) {
setupPlayer(vlc.flattenToString())
val intent = createIntent(Destination.Playback(movie.id, 0L))
assertEquals(vlc, intent.component)
assertStartFromBeginning(intent)
}
@Test
fun `Implicit player starts VLC from beginning at zero position`() =
runTest(testDispatcher) {
val intent = createIntent(Destination.Playback(movie.id, 0L))
assertNull(intent.component)
assertStartFromBeginning(intent)
}
@Test
fun `Unavailable selected player falls back to implicit start from beginning`() =
runTest(testDispatcher) {
setupPlayer("missing.player/missing.player.Activity")
val intent = createIntent(Destination.Playback(movie.id, 0L))
assertNull(intent.component)
assertStartFromBeginning(intent)
}
@Test
fun `Explicit VLC preserves positive resume position`() =
runTest(testDispatcher) {
setupPlayer(vlc.flattenToString())
val intent = createIntent(Destination.Playback(movie.id, 42_000L))
assertEquals(vlc, intent.component)
assertEquals(false, intent.extras!!.get("from_start"))
assertEquals(42_000, intent.extras!!.get("position"))
}
@Test
fun `Implicit player preserves positive resume position`() =
runTest(testDispatcher) {
val intent = createIntent(Destination.Playback(movie.id, 42_000L))
assertNull(intent.component)
assertEquals(false, intent.extras!!.get("from_start"))
assertEquals(42_000, intent.extras!!.get("position"))
}
@Test
fun `Playback list starts from beginning`() =
runTest(testDispatcher) {
val playlist = playlist()
coEvery { userLibraryApi.getItem(playlist.id) } returns successResponse(playlist)
coEvery { playlistCreator.createFrom(any(), any(), any(), any(), any(), any()) } returns
PlaylistCreationResult.Success(Playlist.fromMedia(listOf(BaseItem(movie))))
val intent = createIntent(Destination.PlaybackList(playlist.id))
assertStartFromBeginning(intent)
}
@Test
fun `VLC starts from beginning without subtitles or duration`() =
runTest(testDispatcher) {
coEvery { streamChoiceService.chooseSource(any(), any()) } returns
mediaSource.copy(mediaStreams = emptyList(), runTimeTicks = null)
coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns null
val intent = createIntent(Destination.Playback(movie.id, 0L))
assertStartFromBeginning(intent)
assertFalse(intent.hasExtra("subtitles_location"))
assertFalse(intent.hasExtra("extra_duration"))
assertFalse(intent.hasExtra("subs.enable"))
assertFalse(intent.hasExtra("forcedsrt"))
}
@Test
fun `Existing player extras and playback-start reporting remain unchanged`() =
runTest(testDispatcher) {
val intent = createIntent(Destination.Playback(movie.id, 42_000L))
val title = "${BaseItem(movie).title} ${BaseItem(movie).subtitleLong}"
val subtitleUri = Uri.parse(subtitleUrl)
assertEquals(Intent.ACTION_VIEW, intent.action)
assertEquals(Uri.parse(videoUrl), intent.data)
assertEquals("video/*", intent.type)
assertEquals(title, intent.getStringExtra("title"))
assertEquals(42_000, intent.extras!!.get("position"))
assertTrue(intent.getBooleanExtra("return_result", false))
assertTrue(intent.getBooleanExtra("secure_uri", false))
assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs"))
assertArrayEquals(arrayOf("English"), intent.getStringArrayExtra("subs.name"))
assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs.enable"))
assertEquals(subtitleUrl, intent.getStringExtra("subtitles_location"))
assertEquals(90.minutes.inWholeMilliseconds, intent.extras!!.get("extra_duration"))
assertEquals(42_000, intent.extras!!.get("startfrom"))
assertFalse(intent.getBooleanExtra("forceresume", true))
assertEquals(title, intent.getStringExtra("forcename"))
assertEquals(subtitleUri, intent.getParcelableExtra<Uri>("forcedsrt"))
coVerify(exactly = 1) { playStateApi.reportPlaybackStart(any()) }
coVerify(exactly = 0) { playStateApi.reportPlaybackStopped(any()) }
}
}

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)
@ -275,6 +292,49 @@ class PlaybackViewModelTests {
return viewModel
}
@Test
fun `Direct play includes the opened live stream ID`() =
runTest(testDispatcher) {
setupPreferences { cinemaMode = false }
val liveStreamId = "opened-live-stream"
val liveSource = mediaSource.copy(liveStreamId = liveStreamId)
coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie)
coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns
successResponse(
PlaybackInfoResponse(
mediaSources = listOf(liveSource),
playSessionId = playSessionId,
),
)
every {
mockVideosApi.getVideoStreamUrl(
itemId = movie.id,
mediaSourceId = liveSource.id,
liveStreamId = liveSource.liveStreamId,
static = true,
tag = liveSource.eTag,
playSessionId = playSessionId,
)
} returns videoStreamUrl
createViewModel(Destination.Playback(movie.id, 0L))
val mediaItem = slot<MediaItem>()
verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any<Long>()) }
val playbackUri = mediaItem.captured.localConfiguration?.uri
Assert.assertEquals(videoStreamUrl, playbackUri.toString())
verify(exactly = 1) {
mockVideosApi.getVideoStreamUrl(
itemId = movie.id,
mediaSourceId = liveSource.id,
liveStreamId = liveStreamId,
static = true,
tag = liveSource.eTag,
playSessionId = playSessionId,
)
}
}
@Test
fun `Play intro first`() =
runTest(testDispatcher) {

View file

@ -0,0 +1,86 @@
package com.github.damontecres.wholphin.util
import androidx.media3.common.DrmInitData
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class DolbyVisionProfile7ConversionTest {
@Test
fun recognisesProfile7InBothSampleEntryForms() {
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06")))
assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06")))
}
@Test
fun doesNotMistakeOtherProfilesOrCodecsForProfile7() {
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06")))
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06")))
assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null)))
assertFalse(
DolbyVisionProfile7Conversion.isProfile7(
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs("dvhe.07.06")
.build(),
),
)
}
@Test
fun rewritesOnlyTheProfileOfTheCodecString() {
assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06"))
assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09"))
}
@Test
fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() {
val format = dolbyVision("dvhe.07.06")
val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format)
assertEquals("dvhe.08.06", advertised.codecs)
assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType)
assertEquals(format.width, advertised.width)
assertEquals(format.height, advertised.height)
assertEquals(format.initializationData, advertised.initializationData)
}
@Test
fun leavesTracksWhichAreNotProfile7AsTheSameInstance() {
val profile8 = dolbyVision("dvhe.08.06")
val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build()
assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8))
assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc))
}
@Test
fun leavesAnEncryptedProfile7TrackAlone() {
// Encrypted samples take the secure path, which the bitstream filter does not touch
val encrypted =
dolbyVision("dvhe.07.06")
.buildUpon()
.setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1))))
.build()
assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted))
}
private companion object {
fun dolbyVision(codecs: String?): Format =
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(codecs)
.setWidth(3840)
.setHeight(2160)
.setInitializationData(listOf(byteArrayOf(0x01, 0x02)))
.build()
}
}

View file

@ -0,0 +1,119 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.util
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.TrackOutput
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
/**
* The factory has to reach the video track's format through three layers of media3 forwarding,
* rewrite it there and nowhere else, and let sample data through untouched.
*/
class DolbyVisionProfile7ExtractorsFactoryTest {
private val videoTrack = mockk<TrackOutput>(relaxed = true)
private val audioTrack = mockk<TrackOutput>(relaxed = true)
private val output =
mockk<ExtractorOutput>(relaxed = true) {
every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack
every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack
}
private val extractor = mockk<Extractor>(relaxed = true)
private val delegate =
mockk<ExtractorsFactory> {
every { createExtractors() } returns arrayOf(extractor)
}
@Test
fun advertisesTheVideoTrackAsProfile8() {
val video = initialisedVideoTrack()
video.format(dolbyVision("dvhe.07.06"))
val published = slot<Format>()
verify { videoTrack.format(capture(published)) }
assertEquals("dvhe.08.06", published.captured.codecs)
}
@Test
fun leavesOtherVideoFormatsAsTheyAre() {
val video = initialisedVideoTrack()
val hevc =
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_H265)
.setCodecs("hvc1.2.4.L153")
.build()
video.format(hevc)
verify { videoTrack.format(hevc) }
}
@Test
fun leavesTheAudioTrackUnwrapped() {
val wrapped = wrappedOutput()
assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO))
}
@Test
fun passesSampleDataStraightThrough() {
val video = initialisedVideoTrack()
val data = ParsableByteArray(byteArrayOf(1, 2, 3))
video.sampleData(data, 3)
video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null)
verify { videoTrack.sampleData(data, 3) }
verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) }
}
@Test
fun wrapsEveryExtractorOfTheDelegate() {
val another = mockk<Extractor>(relaxed = true)
every { delegate.createExtractors() } returns arrayOf(extractor, another)
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
assertEquals(2, extractors.size)
extractors[1].init(output)
verify { another.init(any()) }
}
private fun wrappedOutput(): ExtractorOutput {
val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors()
extractors.single().init(output)
val wrapped = slot<ExtractorOutput>()
verify { extractor.init(capture(wrapped)) }
return wrapped.captured
}
private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO)
private companion object {
const val VIDEO_ID = 1
const val AUDIO_ID = 2
fun dolbyVision(codecs: String): Format =
Format
.Builder()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs(codecs)
.build()
}
}

View file

@ -0,0 +1,293 @@
package com.github.damontecres.wholphin.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import java.nio.ByteBuffer
/**
* The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under
* test is everything around it: which access units are touched at all, that the enhancement layer
* goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as
* it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer.
*/
class DolbyVisionProfile7FilterTest {
@Test
fun dropsTheEnhancementLayerAndRewritesTheRpu() {
val filter = filterWith(FakeRewriter(profile = 7))
val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU))
val size = filter.filter(target, 0, target.capacity())
assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
}
@Test
fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() {
// A dual layer remux whose enhancement layer lives in Matroska block additions, which the
// extractor discards: the RPU still needs its rewrite for the profile 8 decoder
val filter = filterWith(FakeRewriter(profile = 7))
val target = directBuffer(annexB(VCL, RPU))
val size = filter.filter(target, 0, target.capacity())
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size))
}
@Test
fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() {
val filter = filterWith(FakeRewriter(profile = 7))
val accessUnit = annexB(VCL, EL, RPU)
val prefix = byteArrayOf(0x7A, 0x7B)
val suffix = byteArrayOf(0x7C)
val target = directBuffer(prefix + accessUnit + suffix)
val size = filter.filter(target, prefix.size, accessUnit.size)
assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size))
assertEquals(prefix.toList(), target.bytes(0, prefix.size))
assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size))
}
@Test
fun doesNotMoveThePositionOrLimitOfTheBuffer() {
val filter = filterWith(FakeRewriter(profile = 7))
val target = directBuffer(annexB(VCL, EL, RPU))
target.position(3)
target.limit(target.capacity() - 1)
filter.filter(target, 0, target.capacity())
assertEquals(3, target.position())
assertEquals(target.capacity() - 1, target.limit())
}
@Test
fun leavesAnAccessUnitWithoutAnRpuAlone() {
val rewriter = FakeRewriter(profile = 7)
val filter = filterWith(rewriter)
val accessUnit = annexB(VPS, VCL, EL)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
assertEquals(0, rewriter.conversions)
}
@Test
fun leavesAnRpuOfAnotherProfileAlone() {
val rewriter = FakeRewriter(profile = 8)
val filter = filterWith(rewriter)
val accessUnit = annexB(VCL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
assertEquals(0, rewriter.conversions)
}
@Test
fun leavesAnAccessUnitAloneWhenTheRewriteThrows() {
val filter = filterWith(FakeRewriter(profile = 7, throws = true))
val accessUnit = annexB(VCL, EL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
}
@Test
fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() {
// No enhancement layer to make room, and an RPU which comes out longer than it went in
val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 }))
val accessUnit = annexB(VCL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
}
@Test
fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() {
val longerRpu = RPU + byteArrayOf(0x55, 0x56)
val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu))
val target = directBuffer(annexB(VCL, EL, RPU))
val size = filter.filter(target, 0, target.capacity())
assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size))
}
@Test
fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() {
val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1))
val accessUnit = annexB(VCL, EL, RPU)
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, size)
assertEquals(accessUnit.toList(), target.bytes(0, size))
}
@Test
fun leavesDataWhichIsNotAnnexBAlone() {
val rewriter = FakeRewriter(profile = 7)
val filter = filterWith(rewriter)
val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C)
val target = directBuffer(data)
val size = filter.filter(target, 0, target.capacity())
assertEquals(data.size, size)
assertEquals(data.toList(), target.bytes(0, size))
assertEquals(0, rewriter.conversions)
}
@Test
fun walksThreeByteStartCodesToo() {
val filter = filterWith(FakeRewriter(profile = 7))
val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU
val target = directBuffer(accessUnit)
val size = filter.filter(target, 0, target.capacity())
assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size))
}
@Test
fun givesUpForGoodWhenTheNativeLibraryIsMissing() {
var attempts = 0
val filter =
DolbyVisionProfile7Filter {
attempts++
throw UnsatisfiedLinkError("no libdovi_android for this ABI")
}
val accessUnit = annexB(VCL, EL, RPU)
val target = directBuffer(accessUnit)
val first = filter.filter(target, 0, target.capacity())
val second = filter.filter(target, 0, target.capacity())
assertEquals(accessUnit.size, first)
assertEquals(accessUnit.size, second)
assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size))
assertEquals("the library should be tried once, not on every access unit", 1, attempts)
}
@Test
fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() {
val rewriter = FakeRewriter(profile = 7)
val filter = filterWith(rewriter)
val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL))
filter.filter(target, 0, target.capacity())
assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame)
}
/**
* Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the
* Annex B start codes, and swaps its payload for [converted], moving whatever follows.
*/
private class FakeRewriter(
private val profile: Int,
private val converted: ByteArray = CONVERTED_RPU,
private val throws: Boolean = false,
private val reportedSize: Int? = null,
) : RpuRewriter {
var conversions = 0
var lastFrame: List<Byte> = emptyList()
override fun profileOf(
frame: ByteBuffer,
size: Int,
): Int {
assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray())
return profile
}
override fun convertToProfile8(
frame: ByteBuffer,
size: Int,
): Int {
conversions++
lastFrame = frame.bytes(0, size)
if (throws) {
throw IllegalArgumentException("Unable to parse Dolby Vision RPU")
}
val bytes = frame.bytes(0, size).toByteArray()
val rpuStart = indexOfNal(bytes, RPU)
val rpuEnd = nextStartCode(bytes, rpuStart + 1)
val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size)
check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" }
rewritten.forEachIndexed { index, byte -> frame.put(index, byte) }
return reportedSize ?: rewritten.size
}
private fun indexOfNal(
bytes: ByteArray,
nal: ByteArray,
): Int {
for (i in 0..bytes.size - nal.size) {
if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) {
return i
}
}
throw AssertionError("RPU not found in the frame handed to the rewriter")
}
private fun nextStartCode(
bytes: ByteArray,
from: Int,
): Int {
for (i in from..bytes.size - 3) {
if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) {
return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i
}
}
return bytes.size
}
}
companion object {
private val START_CODE = byteArrayOf(0, 0, 0, 1)
private val SHORT_START_CODE = byteArrayOf(0, 0, 1)
/** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */
private fun nal(
type: Int,
vararg payload: Byte,
): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload
private val VPS = nal(32, 0x0C, 0x01, 0x7F)
private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44)
private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E)
private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07)
private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08)
private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal }
private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter }
/** Like the decoder input buffer on a device: direct, with no backing array. */
private fun directBuffer(data: ByteArray): ByteBuffer =
ByteBuffer.allocateDirect(data.size).also { buffer ->
data.forEachIndexed { index, byte -> buffer.put(index, byte) }
assertFalse(buffer.hasArray())
}
private fun ByteBuffer.bytes(
from: Int,
count: Int,
): List<Byte> = (from until from + count).map { get(it) }
}
}

View file

@ -0,0 +1,141 @@
package com.github.damontecres.wholphin.util.profile
import io.mockk.every
import io.mockk.mockk
import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.ProfileConditionValue
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Verifies that forceEnabledHdr/forceDisabledHdr correctly override the device's own
* codec-capability detection when building the unsupported video range type list.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [30])
class TestDeviceProfileUtils {
private fun unsupportedRangeTypes(
profile: DeviceProfile,
codec: String,
): Set<String> =
profile.codecProfiles
.filter { it.type == CodecType.VIDEO && it.codec == codec }
.firstNotNullOf { codecProfile ->
codecProfile.applyConditions.firstOrNull { it.property == ProfileConditionValue.VIDEO_RANGE_TYPE }?.value
}.split("|")
.toSet()
private fun buildProfile(
mediaTest: MediaCodecCapabilitiesTest,
jellyfinTenEleven: Boolean = false,
decodeAv1: Boolean = false,
forceEnabledHdr: Set<String> = emptySet(),
forceDisabledHdr: Set<String> = emptySet(),
): DeviceProfile =
createDeviceProfile(
mediaTest = mediaTest,
maxBitrate = 100_000_000,
isAC3Enabled = true,
downMixAudio = false,
assDirectPlay = true,
pgsDirectPlay = true,
decodeAv1 = decodeAv1,
jellyfinTenEleven = jellyfinTenEleven,
preferAc3ForSurround = false,
forceEnabledHdr = forceEnabledHdr,
forceDisabledHdr = forceDisabledHdr,
maxResolution = 0,
)
@Test
fun `Test HEVC Profile 7 unsupported when device does not support it`() {
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
every { mediaTest.supportsHevcDolbyVisionEL() } returns false
every { mediaTest.supportsHevcDolbyVision() } returns true
every { mediaTest.supportsHevcHDR10Plus() } returns true
val profile = buildProfile(mediaTest, jellyfinTenEleven = true)
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName))
}
@Test
fun `Test force enable overrides HEVC Profile 7 device support check`() {
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
every { mediaTest.supportsHevcDolbyVisionEL() } returns false
every { mediaTest.supportsHevcDolbyVision() } returns true
every { mediaTest.supportsHevcHDR10Plus() } returns true
val profile =
buildProfile(
mediaTest,
jellyfinTenEleven = true,
forceEnabledHdr =
setOf(
WholphinVideoRangeType.DOVI_WITH_EL.serialName,
WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName,
),
)
Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName))
}
@Test
fun `Test force disable overrides HEVC Profile 7 device support check`() {
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
every { mediaTest.supportsHevcDolbyVisionEL() } returns true
every { mediaTest.supportsHevcDolbyVision() } returns true
every { mediaTest.supportsHevcHDR10Plus() } returns true
val profile =
buildProfile(
mediaTest,
jellyfinTenEleven = true,
forceDisabledHdr =
setOf(
WholphinVideoRangeType.DOVI_WITH_EL.serialName,
WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName,
),
)
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName))
}
@Test
fun `Test AV1 Profile 5 unsupported when device does not support it`() {
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
every { mediaTest.supportsAV1DolbyVision() } returns false
val profile = buildProfile(mediaTest, decodeAv1 = false)
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName))
}
@Test
fun `Test force enable overrides AV1 Profile 5 device support check`() {
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
every { mediaTest.supportsAV1DolbyVision() } returns false
val profile =
buildProfile(
mediaTest,
decodeAv1 = false,
forceEnabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName),
)
Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName))
}
@Test
fun `Test force disable overrides AV1 Profile 5 device support check`() {
val mediaTest = mockk<MediaCodecCapabilitiesTest>(relaxed = true)
every { mediaTest.supportsAV1DolbyVision() } returns true
val profile =
buildProfile(
mediaTest,
decodeAv1 = false,
forceDisabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName),
)
Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName))
}
}