Fix Home navigation ownership and merge watch lanes

This commit is contained in:
justinzeus 2026-11-07 15:23:45 +01:00
parent c2f7ccc438
commit 12c201dc55
41 changed files with 671 additions and 307 deletions

View file

@ -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")

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -1,7 +0,0 @@
package com.github.damontecres.wholphin.ui.main.settings
sealed interface HomeRowConfigAction {
data object Combine : HomeRowConfigAction
data object Split : HomeRowConfigAction
}

View file

@ -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 =

View file

@ -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),

View file

@ -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,
)
}
}
},
)
}

View file

@ -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,

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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(
}
}
}
}
}
},
)

View file

@ -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"),
)
}

View file

@ -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)
}
}