Fix Home navigation ownership and merge watch lanes
This commit is contained in:
parent
c2f7ccc438
commit
12c201dc55
41 changed files with 671 additions and 307 deletions
|
|
@ -29,7 +29,7 @@ sealed interface HomeRowConfig {
|
|||
fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig
|
||||
|
||||
/**
|
||||
* Continue watching media that the user has started but not finished
|
||||
* Legacy serialized resume row, migrated to ContinueWatchingCombined when settings are read.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ContinueWatching")
|
||||
|
|
@ -40,7 +40,7 @@ sealed interface HomeRowConfig {
|
|||
}
|
||||
|
||||
/**
|
||||
* Next up row for next episodes in a series the user has started
|
||||
* Legacy serialized next-up row, migrated to ContinueWatchingCombined when settings are read.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("NextUp")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
/** Saved split rows become one watch row at their first position, retaining its presentation choices. */
|
||||
fun mergeHomeWatchRows(rows: List<HomeRowConfig>): List<HomeRowConfig> = buildList {
|
||||
var watchRowSeen = false
|
||||
for (row in rows) {
|
||||
when (row) {
|
||||
is HomeRowConfig.ContinueWatching, is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> {
|
||||
if (!watchRowSeen) add(HomeRowConfig.ContinueWatchingCombined(row.viewOptions))
|
||||
watchRowSeen = true
|
||||
}
|
||||
else -> add(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
|
|
@ -21,13 +20,15 @@ import org.jellyfin.sdk.api.client.ApiClient
|
|||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
|
||||
import timber.log.Timber
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
|
||||
|
|
@ -46,50 +47,21 @@ class DatePlayedService
|
|||
.expireAfterWrite(2, TimeUnit.HOURS)
|
||||
.build<SeriesItemId, Deferred<LocalDateTime>>()
|
||||
|
||||
private fun getLastPlayed(
|
||||
seriesId: UUID,
|
||||
item: BaseItem,
|
||||
): Deferred<LocalDateTime> {
|
||||
val request =
|
||||
GetEpisodesRequest(
|
||||
seriesId = seriesId,
|
||||
adjacentTo = item.id,
|
||||
limit = 1,
|
||||
)
|
||||
return scope.async(WholphinDispatchers.IO) {
|
||||
val premiereDate = item.data.premiereDate
|
||||
private fun readLastPlayed(seriesId: UUID): Deferred<LocalDateTime> =
|
||||
scope.async(WholphinDispatchers.IO) {
|
||||
try {
|
||||
val result =
|
||||
GetEpisodesRequestHandler
|
||||
.execute(
|
||||
api,
|
||||
request,
|
||||
).content.items
|
||||
.firstOrNull()
|
||||
?.userData
|
||||
?.lastPlayedDate ?: LocalDateTime.MIN
|
||||
if (premiereDate != null && result.isBefore(premiereDate)) {
|
||||
premiereDate
|
||||
} else {
|
||||
result
|
||||
}
|
||||
api.itemsApi.getItems(parentId = seriesId, recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
|
||||
sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
|
||||
.content.items.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN
|
||||
} catch (ex: InvalidStatusException) {
|
||||
Timber.w(
|
||||
"Error fetching series=%s, item=%s: %s",
|
||||
seriesId,
|
||||
item.id,
|
||||
ex.localizedMessage,
|
||||
)
|
||||
LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN)
|
||||
Timber.w(ex, "Error fetching series=%s", seriesId)
|
||||
LocalDateTime.MIN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logical timestamp when the item was last played.
|
||||
*
|
||||
* This is calculated using lastest of the actual last played timestamp of the item,
|
||||
* the previous episode's last played timestamp, and the episode's premiere date
|
||||
* Get the series' latest actual watch activity, never an upcoming episode's premiere date.
|
||||
*/
|
||||
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
|
|
@ -97,7 +69,7 @@ class DatePlayedService
|
|||
return@withContext if (seriesId != null) {
|
||||
datePlayedCache
|
||||
.get(SeriesItemId(seriesId, item.id)) {
|
||||
getLastPlayed(seriesId, item)
|
||||
readLastPlayed(seriesId)
|
||||
}.await()
|
||||
} else {
|
||||
null
|
||||
|
|
|
|||
|
|
@ -92,44 +92,8 @@ class HomeDataService
|
|||
usePaging: Boolean = false,
|
||||
): HomeRowLoadingState =
|
||||
when (row) {
|
||||
is HomeRowConfig.ContinueWatching -> {
|
||||
val resume =
|
||||
latestNextUpService.getResume(
|
||||
userDto.id,
|
||||
limit,
|
||||
true,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
items = resume,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = resume.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.NextUp -> {
|
||||
val nextUp =
|
||||
latestNextUpService.getNextUp(
|
||||
userDto.id,
|
||||
limit,
|
||||
prefs.enableRewatchingNextUp,
|
||||
false,
|
||||
prefs.maxDaysNextUp,
|
||||
row.viewOptions.useSeries,
|
||||
)
|
||||
|
||||
Success(
|
||||
title = ResStringProvider(R.string.next_up),
|
||||
items = nextUp,
|
||||
viewOptions = row.viewOptions,
|
||||
rowType = row,
|
||||
showViewMore = nextUp.size >= limit,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.ContinueWatching,
|
||||
is HomeRowConfig.NextUp,
|
||||
is HomeRowConfig.ContinueWatchingCombined -> {
|
||||
val resume =
|
||||
latestNextUpService.getResume(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
|||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
|
|
@ -159,7 +160,7 @@ class HomeSettingsService
|
|||
null
|
||||
}
|
||||
}.orEmpty()
|
||||
return HomePageSettings(rows, version)
|
||||
return HomePageSettings(mergeHomeWatchRows(rows), version)
|
||||
}
|
||||
|
||||
private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? =
|
||||
|
|
@ -191,7 +192,7 @@ class HomeSettingsService
|
|||
)
|
||||
return settings?.let {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
HomePageResolvedSettings(userId, resolvedSource, resolvedRows)
|
||||
|
|
@ -261,7 +262,7 @@ class HomeSettingsService
|
|||
settings: HomePageSettings,
|
||||
) {
|
||||
val resolvedRows =
|
||||
settings.rows.mapIndexed { index, config ->
|
||||
mergeHomeWatchRows(settings.rows).mapIndexed { index, config ->
|
||||
resolve(index, config)
|
||||
}
|
||||
currentSettings.update { it.copy(rows = resolvedRows) }
|
||||
|
|
@ -313,7 +314,7 @@ class HomeSettingsService
|
|||
listOf(
|
||||
HomeRowConfigDisplay(
|
||||
id = includedIds.size + 1,
|
||||
title = ResStringProvider(R.string.combine_continue_next),
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
config = HomeRowConfig.ContinueWatchingCombined(),
|
||||
),
|
||||
)
|
||||
|
|
@ -434,7 +435,8 @@ class HomeSettingsService
|
|||
null
|
||||
}
|
||||
}.flatten()
|
||||
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs)
|
||||
val merged = mergeHomeWatchRows(rowConfigs.map { it.config }).mapIndexed { index, config -> resolve(index, config) }
|
||||
HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, merged)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -457,19 +459,13 @@ class HomeSettingsService
|
|||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.ContinueWatching -> {
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
ResStringProvider(R.string.continue_watching),
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.ContinueWatching,
|
||||
is HomeRowConfig.NextUp,
|
||||
is HomeRowConfig.ContinueWatchingCombined -> {
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
ResStringProvider(R.string.combine_continue_next),
|
||||
config,
|
||||
ResStringProvider(R.string.continue_watching),
|
||||
HomeRowConfig.ContinueWatchingCombined(config.viewOptions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -495,14 +491,6 @@ class HomeSettingsService
|
|||
HomeRowConfigDisplay(id, StringStringProvider(config.name), config)
|
||||
}
|
||||
|
||||
is HomeRowConfig.NextUp -> {
|
||||
HomeRowConfigDisplay(
|
||||
id,
|
||||
ResStringProvider(R.string.next_up),
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
is HomeRowConfig.RecentlyAdded -> {
|
||||
val title = getItemName(R.string.recently_added_in, config.parentId)
|
||||
HomeRowConfigDisplay(
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ class LatestNextUpService
|
|||
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
|
||||
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
|
||||
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
|
||||
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
|
||||
val result = (resume + nextUp).distinctBy { it.data.seriesId ?: it.id }.sortedByDescending { timestamps[it.id] }
|
||||
val duration = (System.currentTimeMillis() - start).milliseconds
|
||||
Timber.v("buildCombined took %s", duration)
|
||||
return@withContext result
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.main.settings
|
||||
|
||||
sealed interface HomeRowConfigAction {
|
||||
data object Combine : HomeRowConfigAction
|
||||
|
||||
data object Split : HomeRowConfigAction
|
||||
}
|
||||
|
|
@ -60,7 +60,6 @@ fun HomeRowSettings(
|
|||
viewOptions: HomeRowViewOptions,
|
||||
onViewOptionsChange: (HomeRowViewOptions) -> Unit,
|
||||
onConfigChange: (HomeRowConfig) -> Unit,
|
||||
onConfigAction: (HomeRowConfigAction) -> Unit,
|
||||
onApplyApplyAll: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(),
|
||||
|
|
@ -126,7 +125,6 @@ fun HomeRowSettings(
|
|||
addAdditionalConfig(
|
||||
config = config,
|
||||
onConfigChange = onConfigChange,
|
||||
onConfigAction = onConfigAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -135,29 +133,8 @@ fun HomeRowSettings(
|
|||
private fun LazyListScope.addAdditionalConfig(
|
||||
config: HomeRowConfig,
|
||||
onConfigChange: (HomeRowConfig) -> Unit,
|
||||
onConfigAction: (HomeRowConfigAction) -> Unit,
|
||||
) {
|
||||
when (config) {
|
||||
is HomeRowConfig.ContinueWatchingCombined -> {
|
||||
item {
|
||||
ClickPreference(
|
||||
title = stringResource(R.string.split_into_separate_rows),
|
||||
onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.ContinueWatching,
|
||||
is HomeRowConfig.NextUp,
|
||||
-> {
|
||||
item {
|
||||
ClickPreference(
|
||||
title = stringResource(R.string.combine_continue_next),
|
||||
onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is HomeRowConfig.ByParent -> {
|
||||
item {
|
||||
val sortAndDirection =
|
||||
|
|
|
|||
|
|
@ -35,8 +35,6 @@ fun HomeSettingsAddRow(
|
|||
) {
|
||||
itemsIndexed(
|
||||
listOf(
|
||||
MetaRowType.CONTINUE_WATCHING,
|
||||
MetaRowType.NEXT_UP,
|
||||
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
||||
),
|
||||
) { index, type ->
|
||||
|
|
@ -94,9 +92,7 @@ fun HomeSettingsAddRow(
|
|||
enum class MetaRowType(
|
||||
@param:StringRes val stringId: Int,
|
||||
) {
|
||||
CONTINUE_WATCHING(R.string.continue_watching),
|
||||
NEXT_UP(R.string.next_up),
|
||||
COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next),
|
||||
COMBINED_CONTINUE_WATCHING(R.string.continue_watching),
|
||||
FAVORITES(R.string.favorites),
|
||||
DISCOVER(R.string.discover),
|
||||
COLLECTION(R.string.collection),
|
||||
|
|
|
|||
|
|
@ -157,8 +157,6 @@ fun HomeSettingsPage(
|
|||
onClick = { backStack.add(ChooseRowType(it)) },
|
||||
onClickMeta = {
|
||||
when (it) {
|
||||
MetaRowType.CONTINUE_WATCHING,
|
||||
MetaRowType.NEXT_UP,
|
||||
MetaRowType.COMBINED_CONTINUE_WATCHING,
|
||||
-> {
|
||||
addRow { viewModel.addRow(it) }
|
||||
|
|
@ -246,18 +244,6 @@ fun HomeSettingsPage(
|
|||
onConfigChange = {
|
||||
viewModel.onConfigChange(row, it)
|
||||
},
|
||||
onConfigAction = {
|
||||
viewModel.onConfigAction(row, it)
|
||||
when (it) {
|
||||
HomeRowConfigAction.Combine,
|
||||
HomeRowConfigAction.Split,
|
||||
-> {
|
||||
backStack.removeAt(
|
||||
backStack.lastIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels
|
|||
import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.HomeDataService
|
||||
|
|
@ -249,26 +250,10 @@ class HomeSettingsViewModel
|
|||
val id = idCounter++
|
||||
val newRow =
|
||||
when (type) {
|
||||
MetaRowType.CONTINUE_WATCHING -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
config = ContinueWatching(),
|
||||
)
|
||||
}
|
||||
|
||||
MetaRowType.NEXT_UP -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = ResStringProvider(R.string.next_up),
|
||||
config = NextUp(),
|
||||
)
|
||||
}
|
||||
|
||||
MetaRowType.COMBINED_CONTINUE_WATCHING -> {
|
||||
HomeRowConfigDisplay(
|
||||
id = id,
|
||||
title = ResStringProvider(R.string.combine_continue_next),
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
config = ContinueWatchingCombined(),
|
||||
)
|
||||
}
|
||||
|
|
@ -813,7 +798,7 @@ class HomeSettingsViewModel
|
|||
if (result != null) {
|
||||
Timber.v("Got remote settings")
|
||||
val newRows =
|
||||
result.rows.mapIndexed { index, config ->
|
||||
mergeHomeWatchRows(result.rows).mapIndexed { index, config ->
|
||||
homeSettingsService.resolve(index, config)
|
||||
}
|
||||
idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0
|
||||
|
|
@ -839,76 +824,6 @@ class HomeSettingsViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun onConfigAction(
|
||||
row: HomeRowConfigDisplay,
|
||||
action: HomeRowConfigAction,
|
||||
) {
|
||||
viewModelScope.launchDefault {
|
||||
when (action) {
|
||||
HomeRowConfigAction.Combine -> {
|
||||
val index =
|
||||
state.value.rows
|
||||
.indexOf(row)
|
||||
.coerceAtLeast(0)
|
||||
val rowsToRemove =
|
||||
state.value.rows
|
||||
.filter { it.config is ContinueWatching || it.config is NextUp }
|
||||
|
||||
updateState {
|
||||
it.copy(
|
||||
loading = LoadingState.Loading,
|
||||
rows =
|
||||
it.rows.toMutableList().apply {
|
||||
set(
|
||||
index,
|
||||
HomeRowConfigDisplay(
|
||||
id = it.rows[index].id,
|
||||
title = ResStringProvider(R.string.combine_continue_next),
|
||||
config = ContinueWatchingCombined(row.config.viewOptions),
|
||||
),
|
||||
)
|
||||
removeAll(rowsToRemove)
|
||||
},
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
|
||||
HomeRowConfigAction.Split -> {
|
||||
val index =
|
||||
state.value.rows
|
||||
.indexOf(row)
|
||||
.coerceAtLeast(0)
|
||||
updateState {
|
||||
it.copy(
|
||||
loading = LoadingState.Loading,
|
||||
rows =
|
||||
it.rows.toMutableList().apply {
|
||||
set(
|
||||
index,
|
||||
HomeRowConfigDisplay(
|
||||
id = it.rows[index].id,
|
||||
title = ResStringProvider(R.string.continue_watching),
|
||||
config = ContinueWatching(row.config.viewOptions),
|
||||
),
|
||||
)
|
||||
add(
|
||||
index + 1,
|
||||
HomeRowConfigDisplay(
|
||||
id = idCounter++,
|
||||
title = ResStringProvider(R.string.next_up),
|
||||
config = NextUp(row.config.viewOptions),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
fetchRowData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onConfigChange(
|
||||
row: HomeRowConfigDisplay,
|
||||
config: HomeRowConfig,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Legacy saved watch lanes retain the first lane's order and display options after merging. */
|
||||
class HomeWatchRowsTest {
|
||||
@Test fun splitAndAlreadyCombinedRowsBecomeOneWithoutChangingOtherPresets() {
|
||||
val options = HomeRowViewOptions(heightDp = 148)
|
||||
val other = HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter())
|
||||
for (watchRows in listOf(
|
||||
listOf(HomeRowConfig.ContinueWatching(options), other, HomeRowConfig.NextUp()),
|
||||
listOf(HomeRowConfig.NextUp(options), other, HomeRowConfig.ContinueWatching()),
|
||||
listOf(HomeRowConfig.ContinueWatchingCombined(options), other, HomeRowConfig.NextUp()),
|
||||
)) {
|
||||
val result = mergeHomeWatchRows(watchRows)
|
||||
assertEquals(listOf(HomeRowConfig.ContinueWatchingCombined(options), other), result)
|
||||
assertEquals(result, mergeHomeWatchRows(result))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun settingsWithoutWatchRowsStayWithoutWatchRows() {
|
||||
val rows = listOf(HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter()))
|
||||
assertEquals(rows, mergeHomeWatchRows(rows))
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
|
|||
class HomeLibraryCacheTest {
|
||||
@Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42)
|
||||
val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42, seriesId = "series-id", lastPlayedAt = 123456L)
|
||||
val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43)
|
||||
val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84))
|
||||
HomeLibraryCache(context).write("server:person", library, 12345L)
|
||||
|
|
@ -26,4 +26,19 @@ class HomeLibraryCacheTest {
|
|||
assertEquals(12345L, saved.second)
|
||||
assertNull(HomeLibraryCache(context).read("server:someone-else"))
|
||||
}
|
||||
|
||||
@Test fun preMergeCacheWithoutWatchIdentityOrTimestampStillReopens() {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
val cache = HomeLibraryCache(context)
|
||||
val title = HomeTitle("legacy", "An episode", "episode", seriesTitle = "Series", resumeSeconds = 24.0, held = true)
|
||||
cache.write("legacy-scope", HomeLibrary(resume = listOf(title)), 12L)
|
||||
val store = org.justinvisser.wolfskin.conductarr.FileCacheStore(java.io.File(context.filesDir, "wolfskin-home"))
|
||||
val entry = store.read("legacy-scope", "home:jellyfin")!!
|
||||
val body = org.json.JSONObject(entry.body)
|
||||
body.getJSONArray("resume").getJSONObject(0).apply { remove("seriesId"); remove("lastPlayedAt") }
|
||||
store.write("legacy-scope", "home:jellyfin", entry.copy(body = body.toString()))
|
||||
val saved = cache.read("legacy-scope")!!
|
||||
assertEquals(title, saved.first.resume.single())
|
||||
assertEquals(12L, saved.second)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class WatchReadTest {
|
|||
runCurrent()
|
||||
assertFalse(loading.isCompleted)
|
||||
assertTrue(model.snapshot.rows.first().watchReady)
|
||||
assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001)
|
||||
assertEquals(1136.274, model.snapshot.rows.single { it.id == "cw" }.cards.single().resumeSeconds, .0001)
|
||||
releaseLibrary.complete(Unit)
|
||||
loading.join()
|
||||
assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001)
|
||||
|
|
@ -132,4 +132,28 @@ class WatchReadTest {
|
|||
WholphinDispatchers.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun mergedHomeUsesSeriesWatchActivityNotAnUnwatchedNextEpisodeDate() = runTest {
|
||||
val otherSeries = UUID.fromString("00000000-0000-0000-0000-000000000004")
|
||||
val checkpoint = decode(resumeWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
|
||||
userData = it.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse("2026-11-05T12:00:00"))) }
|
||||
val hero = checkpoint.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000005"), type = BaseItemKind.MOVIE, seriesId = null, seriesName = null)
|
||||
val next = decode(nextWire).let { it.copy(seriesId = seriesId, seriesName = "Series A",
|
||||
userData = it.userData!!.copy(playbackPositionTicks = 0, lastPlayedDate = null)) }
|
||||
val otherNext = next.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000006"), seriesId = otherSeries, seriesName = "Series B")
|
||||
coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(hero, checkpoint))
|
||||
coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next, otherNext))
|
||||
for ((id, date) in listOf(seriesId to "2026-11-04T12:00:00", otherSeries to "2026-11-07T12:00:00")) {
|
||||
coEvery { library.getItem(userId = userId, itemId = id) } returns successResponse(BaseItemDto(id = id, type = BaseItemKind.SERIES))
|
||||
val watched = checkpoint.copy(userData = checkpoint.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse(date)))
|
||||
coEvery { items.getItems(userId = userId, parentId = id, recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
|
||||
sortBy = listOf(org.jellyfin.sdk.model.api.ItemSortBy.DATE_PLAYED),
|
||||
sortOrder = listOf(org.jellyfin.sdk.model.api.SortOrder.DESCENDING), limit = 1) } returns successQueryResult(listOf(watched))
|
||||
}
|
||||
val rows = buildHomeRows(HomeReadFacade(api, images, mockk()).readWatch(userId), emptyList())
|
||||
val watching = rows.single { it.id == "cw" }.cards
|
||||
assertEquals(listOf(otherNext.id.toString(), checkpoint.id.toString()), watching.map { it.id })
|
||||
assertEquals(96.078, watching.last().resumeSeconds, .0001)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,6 +317,7 @@ fun WolfskinApp(
|
|||
LaunchedEffect(destination, state, isCurrent) {
|
||||
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
|
||||
}
|
||||
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
|
||||
if (destination.fullScreen) {
|
||||
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
|
||||
} else {
|
||||
|
|
@ -342,6 +343,7 @@ fun WolfskinApp(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co
|
|||
put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode)
|
||||
put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt)
|
||||
put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId)
|
||||
put("seriesId", title.seriesId); put("lastPlayedAt", title.lastPlayedAt)
|
||||
}) }
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +60,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co
|
|||
item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"),
|
||||
string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"),
|
||||
List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"),
|
||||
seriesId = string("seriesId"), lastPlayedAt = if (item.isNull("lastPlayedAt")) null else item.getLong("lastPlayedAt"),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ package org.justinvisser.wolfskin.tv.read
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import java.util.UUID
|
||||
import java.time.ZoneOffset
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||
|
|
@ -27,18 +32,39 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
|||
suspend fun readWatch(userId: UUID): HomeLibrary {
|
||||
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||
val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id ->
|
||||
try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
|
||||
catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
null
|
||||
}
|
||||
val seriesIds = buildSet {
|
||||
resumeItems.forEach { it.seriesId?.let(::add) }
|
||||
nextItems.forEach { it.seriesId?.let(::add) }
|
||||
}
|
||||
val series = coroutineScope {
|
||||
val limiter = Semaphore(WATCH_READ_CONCURRENCY)
|
||||
seriesIds.associateWith { id -> async { limiter.withPermit { readSeriesWatch(userId, id) } } }
|
||||
.mapValues { it.value.await() }
|
||||
}
|
||||
val resume = resumeItems.map { title(it, series) }
|
||||
val next = nextItems.map { title(it, series) }
|
||||
return HomeLibrary(resume = resume, nextUp = next)
|
||||
}
|
||||
|
||||
private suspend fun readSeriesWatch(userId: UUID, id: UUID): SeriesWatch {
|
||||
val tmdb = try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() }
|
||||
catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
null
|
||||
}
|
||||
// Next-up episodes are unwatched; date the series by its latest actually watched episode.
|
||||
val playedAt = try {
|
||||
api.itemsApi.getItems(userId = userId, parentId = id, recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true,
|
||||
sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1)
|
||||
.content.items.firstOrNull()?.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
|
||||
} catch (exception: Exception) {
|
||||
if (exception is kotlinx.coroutines.CancellationException) throw exception
|
||||
null
|
||||
}
|
||||
return SeriesWatch(tmdb, playedAt)
|
||||
}
|
||||
|
||||
/** Library failures do not discard an already successful watch read. */
|
||||
suspend fun readLibrary(userId: UUID): HomeLibrary {
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items
|
||||
|
|
@ -66,7 +92,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
|||
return readPlayback(api, userId, id)
|
||||
}
|
||||
|
||||
private fun title(item: BaseItemDto, series: Map<UUID, Int?> = emptyMap()): HomeTitle {
|
||||
private fun title(item: BaseItemDto, series: Map<UUID, SeriesWatch> = emptyMap()): HomeTitle {
|
||||
val wrapped = BaseItem(item)
|
||||
return HomeTitle(
|
||||
id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" },
|
||||
|
|
@ -76,12 +102,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val
|
|||
seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber,
|
||||
resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true,
|
||||
addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true,
|
||||
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
||||
tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId]?.tmdbId else item.providerIds?.get("Tmdb")?.toIntOrNull(),
|
||||
seriesId = item.seriesId?.toString(),
|
||||
lastPlayedAt = maxOf(item.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli() ?: Long.MIN_VALUE,
|
||||
series[item.seriesId]?.lastPlayedAt ?: Long.MIN_VALUE).takeUnless { it == Long.MIN_VALUE },
|
||||
)
|
||||
}
|
||||
|
||||
private data class SeriesWatch(val tmdbId: Int?, val lastPlayedAt: Long?)
|
||||
|
||||
companion object {
|
||||
private const val TICKS_PER_SECOND = 10_000_000L
|
||||
private const val WATCH_READ_CONCURRENCY = 3
|
||||
private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue