proper management of cloud items in cw section

This commit is contained in:
tapframe 2026-05-22 15:03:38 +05:30
parent 996fd7f949
commit 3d6d0fcfb4
9 changed files with 340 additions and 63 deletions

View file

@ -106,10 +106,13 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.playbackVideoId
import com.nuvio.app.features.cloud.providerPosterUrl
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
@ -842,6 +845,52 @@ private fun MainAppContent(
}
}
suspend fun launchCloudLibraryFile(
item: CloudLibraryItem,
file: CloudLibraryFile,
resumePositionMs: Long? = null,
resumeProgressFraction: Float? = null,
startFromBeginning: Boolean = false,
): Boolean {
return when (
val resolved = CloudLibraryRepository.resolvePlayback(
item = item,
file = file,
)
) {
is CloudLibraryPlaybackResult.Success -> {
val playbackTitle = resolved.filename
?.takeIf { it.isNotBlank() }
?: file.name.ifBlank { item.name }
val playerLaunch = PlayerLaunch(
title = playbackTitle,
sourceUrl = resolved.url,
streamTitle = playbackTitle,
streamSubtitle = item.name.takeIf { it != playbackTitle },
providerName = item.providerName,
providerAddonId = "cloud:${item.providerId}",
poster = item.providerPosterUrl(),
contentType = CloudLibraryContentType,
videoId = item.playbackVideoId(file),
parentMetaId = item.stableKey,
parentMetaType = CloudLibraryContentType,
initialPositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L),
initialProgressFraction = if (startFromBeginning) null else resumeProgressFraction,
)
if (playerSettingsUiState.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
true
} else {
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId))
true
}
}
else -> false
}
}
fun launchPlaybackWithDownloadPreference(
type: String,
videoId: String,
@ -1012,25 +1061,46 @@ private fun MainAppContent(
}
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
launchPlaybackWithDownloadPreference(
type = item.parentMetaType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,
parentMetaType = item.parentMetaType,
title = item.title,
logo = item.logo,
poster = item.poster,
background = item.background,
seasonNumber = item.seasonNumber,
episodeNumber = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
resumePositionMs = item.resumePositionMs,
resumeProgressFraction = item.resumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
)
if (item.isCloudLibraryContinueWatchingItem()) {
coroutineScope.launch {
val target = CloudLibraryRepository.findPlaybackTargetForProgress(
contentId = item.parentMetaId,
videoId = item.videoId,
)
val launched = target?.let { playbackTarget ->
launchCloudLibraryFile(
item = playbackTarget.item,
file = playbackTarget.file,
resumePositionMs = item.resumePositionMs,
resumeProgressFraction = item.resumeProgressFraction,
startFromBeginning = startFromBeginning,
)
} == true
if (!launched) {
NuvioToastController.show(cloudLibraryPlayFailedText)
}
}
} else {
launchPlaybackWithDownloadPreference(
type = item.parentMetaType,
videoId = item.videoId,
parentMetaId = item.parentMetaId,
parentMetaType = item.parentMetaType,
title = item.title,
logo = item.logo,
poster = item.poster,
background = item.background,
seasonNumber = item.seasonNumber,
episodeNumber = item.episodeNumber,
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
resumePositionMs = item.resumePositionMs,
resumeProgressFraction = item.resumeProgressFraction,
manualSelection = manualSelection,
startFromBeginning = startFromBeginning,
)
}
}
val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item ->
@ -1165,36 +1235,8 @@ private fun MainAppContent(
onLibrarySectionViewAllClick = onLibrarySectionViewAllClick,
onCloudFilePlay = { item, file ->
coroutineScope.launch {
when (
val resolved = CloudLibraryRepository.resolvePlayback(
item = item,
file = file,
)
) {
is CloudLibraryPlaybackResult.Success -> {
val playbackTitle = file.name.ifBlank { item.name }
val playerLaunch = PlayerLaunch(
title = playbackTitle,
sourceUrl = resolved.url,
streamTitle = playbackTitle,
streamSubtitle = item.name.takeIf { it != playbackTitle },
providerName = item.providerName,
providerAddonId = "cloud:${item.providerId}",
contentType = "cloud",
videoId = "${item.stableKey}:${file.stableKey}",
parentMetaId = item.stableKey,
parentMetaType = "cloud",
)
if (playerSettingsUiState.externalPlayerEnabled) {
openExternalPlayback(playerLaunch)
return@launch
}
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId))
}
else -> {
NuvioToastController.show(cloudLibraryPlayFailedText)
}
if (!launchCloudLibraryFile(item = item, file = file)) {
NuvioToastController.show(cloudLibraryPlayFailedText)
}
}
},
@ -2161,6 +2203,7 @@ private fun MainAppContent(
NuvioContinueWatchingActionSheet(
item = selectedContinueWatchingForActions,
showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState),
showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true,
onDismiss = { selectedContinueWatchingForActions = null },
onOpenDetails = {
selectedContinueWatchingForActions?.let { item ->
@ -2540,6 +2583,9 @@ private fun TabletFloatingTopBar(
}
}
private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean =
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
@Composable
private fun TabletTopPillItem(
label: String,

View file

@ -1,6 +1,7 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@ -18,6 +19,8 @@ fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String {
stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber)
item.isNextUp ->
stringResource(Res.string.continue_watching_up_next)
item.parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) ->
stringResource(Res.string.library_source_cloud)
else ->
stringResource(Res.string.media_movie)
}

View file

@ -28,6 +28,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
@ -42,6 +44,7 @@ import org.jetbrains.compose.resources.stringResource
fun NuvioContinueWatchingActionSheet(
item: ContinueWatchingItem?,
showManualPlayOption: Boolean,
showDetailsOption: Boolean = true,
onDismiss: () -> Unit,
onOpenDetails: () -> Unit,
onStartFromBeginning: (() -> Unit)? = null,
@ -73,12 +76,14 @@ fun NuvioContinueWatchingActionSheet(
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
) {
ContinueWatchingSheetHeader(item = item)
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.Info,
title = stringResource(Res.string.cw_action_go_to_details),
onClick = { dismissAfter(onOpenDetails) },
)
if (showDetailsOption) {
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.Info,
title = stringResource(Res.string.cw_action_go_to_details),
onClick = { dismissAfter(onOpenDetails) },
)
}
if (showManualPlayOption && onPlayManually != null) {
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
@ -128,10 +133,10 @@ private fun ContinueWatchingSheetHeader(
val artwork = item.poster ?: item.imageUrl
if (artwork != null) {
AsyncImage(
model = artwork,
model = cloudLibraryDisplayArtworkUrl(artwork),
contentDescription = item.title,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
)
} else {
Text(
@ -167,3 +172,6 @@ private fun ContinueWatchingSheetHeader(
}
}
}
private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean =
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)

View file

@ -39,6 +39,43 @@ data class CloudLibraryItem(
get() = files.filter { it.playable }
}
data class CloudLibraryPlaybackTarget(
val item: CloudLibraryItem,
val file: CloudLibraryFile,
)
const val CloudLibraryContentType = "cloud"
const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg"
const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg"
private const val TorboxCloudLibraryPosterDataUrl =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjM2NyAzMDggNzY2IDg4NCI+PHBvbHlnb24gZmlsbD0iIzAwNDQ0RCIgcG9pbnRzPSI3NDkuOTksNzQ5Ljk5IDc0OS45OSwxMTkxLjk2IDM2Ny4yNSw5NzAuOTcgMzY3LjI1LDUyOS4wMSIvPjxwb2x5Z29uIGZpbGw9IiMzNEJBOTAiIHBvaW50cz0iMTEzMi43NSw1MjkuMDEgMTEzMi43NSw5NzAuOTcgNzQ5Ljk5LDExOTEuOTYgNzQ5Ljk5LDc0OS45OSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYiLz48cG9seWdvbiBmaWxsPSIjNTJBMTUzIiBwb2ludHM9IjExMzIuNzUsNTI5LjAxIDc0OS45OSw3NDkuOTkgMzY3LjI1LDUyOS4wMSA3NDkuOTksMzA4LjA0Ii8+PHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSIxMDQzLjA0LDczOS4zNiA5NTguNjYsMTA1Ny4wOCA5NTIuNCw4NTEuODQgODM5LjcxLDkxNS4zOSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYgOTMxLjgxLDc5OS4yMSIvPjwvc3ZnPg=="
fun CloudLibraryItem.playbackVideoId(file: CloudLibraryFile): String =
"$stableKey:${file.stableKey}"
fun CloudLibraryItem.providerPosterUrl(): String? =
cloudLibraryProviderPosterUrl(providerId)
fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? =
when (providerIdOrContentId.normalizedCloudLibraryProviderId()) {
"torbox" -> TorboxCloudLibraryPosterUrl
"premiumize" -> PremiumizeCloudLibraryPosterUrl
else -> null
}
fun cloudLibraryDisplayArtworkUrl(url: String?): String? =
when (url?.trim()) {
TorboxCloudLibraryPosterUrl -> TorboxCloudLibraryPosterDataUrl
else -> url?.trim()
}
private fun String?.normalizedCloudLibraryProviderId(): String =
orEmpty()
.trim()
.removePrefix("$CloudLibraryContentType:")
.substringBefore(':')
.lowercase()
data class CloudLibraryProviderState(
val provider: DebridProvider,
val isLoading: Boolean = false,

View file

@ -121,6 +121,29 @@ object CloudLibraryRepository {
}
}
suspend fun findPlaybackTargetForProgress(
contentId: String,
videoId: String,
): CloudLibraryPlaybackTarget? {
DebridSettingsRepository.ensureLoaded()
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
loadedConnectionKeys = emptyList()
_uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false)
return null
}
_uiState.value.findPlaybackTargetForProgress(
contentId = contentId,
videoId = videoId,
)?.let { target -> return target }
val refreshed = refreshNow()
return refreshed.findPlaybackTargetForProgress(
contentId = contentId,
videoId = videoId,
)
}
suspend fun resolvePlayback(
item: CloudLibraryItem,
file: CloudLibraryFile,
@ -147,8 +170,47 @@ object CloudLibraryRepository {
)
}.sortedBy { it.providerId }
private suspend fun refreshNow(): CloudLibraryUiState {
_uiState.update { current ->
current.copy(
isEnabled = true,
isRefreshing = true,
providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) },
)
}
val refreshed = store.refresh()
loadedConnectionKeys = connectedCloudConnectionKeys()
_uiState.value = refreshed
return refreshed
}
private data class CloudConnectionKey(
val providerId: String,
val apiKeyHash: Int,
)
}
internal fun CloudLibraryUiState.findPlaybackTargetForProgress(
contentId: String,
videoId: String,
): CloudLibraryPlaybackTarget? {
val normalizedContentId = contentId.trim()
val normalizedVideoId = videoId.trim()
if (normalizedContentId.isBlank()) return null
val matchingItems = items.filter { item -> item.stableKey == normalizedContentId }
if (matchingItems.isEmpty()) return null
for (item in matchingItems) {
val exactFile = item.playableFiles.firstOrNull { file ->
item.playbackVideoId(file) == normalizedVideoId
}
if (exactFile != null) {
return CloudLibraryPlaybackTarget(item = item, file = exactFile)
}
}
val singleItem = matchingItems.singleOrNull() ?: return null
val singleFile = singleItem.playableFiles.singleOrNull() ?: return null
return CloudLibraryPlaybackTarget(item = singleItem, file = singleFile)
}

View file

@ -45,6 +45,8 @@ import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
@ -64,10 +66,15 @@ private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): Strin
stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber)
item.isNextUp ->
stringResource(Res.string.continue_watching_up_next)
item.isCloudLibraryItem() ->
stringResource(Res.string.library_source_cloud)
else ->
stringResource(Res.string.media_movie)
}
private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean =
parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
useEpisodeThumbnails: Boolean,
): String? = when {
@ -392,6 +399,7 @@ private fun ContinueWatchingWideCard(
imageUrl = artworkUrl,
width = layout.widePosterStripWidth,
blurred = shouldBlurArtwork,
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
modifier = Modifier.fillMaxHeight(),
)
Column(
@ -504,12 +512,12 @@ private fun ContinueWatchingPosterCard(
val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
model = cloudLibraryDisplayArtworkUrl(imageUrl),
contentDescription = item.title,
modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
)
}
if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) {
@ -589,6 +597,7 @@ private fun ArtworkPanel(
imageUrl: String?,
width: Dp,
blurred: Boolean = false,
contentScale: ContentScale = ContentScale.Crop,
modifier: Modifier = Modifier,
) {
Box(
@ -598,12 +607,12 @@ private fun ArtworkPanel(
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
model = cloudLibraryDisplayArtworkUrl(imageUrl),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
contentScale = contentScale,
)
}
}

View file

@ -1,5 +1,7 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.watching.domain.WatchingContentRef
import kotlinx.serialization.Serializable
@ -199,6 +201,7 @@ internal fun nextUpDismissKey(
internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
val normalizedEntry = normalizedCompletion()
val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl()
val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent
?.takeIf { durationMs <= 0L && it > 0f }
?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) }
@ -213,9 +216,9 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
episodeNumber = normalizedEntry.episodeNumber,
episodeTitle = normalizedEntry.episodeTitle,
),
imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster,
imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl,
logo = normalizedEntry.logo,
poster = normalizedEntry.poster,
poster = normalizedEntry.poster ?: cloudPosterUrl,
background = normalizedEntry.background,
seasonNumber = normalizedEntry.seasonNumber,
episodeNumber = normalizedEntry.episodeNumber,
@ -233,6 +236,16 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
)
}
private fun WatchProgressEntry.cloudLibraryPosterFallbackUrl(): String? {
if (!contentType.equals(CloudLibraryContentType, ignoreCase = true) &&
!parentMetaType.equals(CloudLibraryContentType, ignoreCase = true)
) {
return null
}
return cloudLibraryProviderPosterUrl(parentMetaId)
?: cloudLibraryProviderPosterUrl(providerAddonId)
}
internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
nextEpisode: MetaVideo,
): ContinueWatchingItem {

View file

@ -6,6 +6,7 @@ import com.nuvio.app.features.debrid.DebridServiceCredential
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class CloudLibraryStoreTest {
@ -68,6 +69,86 @@ class CloudLibraryStoreTest {
assertEquals(listOf("cloud"), state.providers.map { it.providerId })
assertEquals(listOf("cloud-item"), state.items.map { it.id })
}
@Test
fun `playback target lookup matches cloud watch progress video id`() {
val provider = cloudProvider(id = "torbox", name = "TorBox")
val item = CloudLibraryItem(
providerId = provider.id,
providerName = provider.displayName,
id = "29773238",
type = CloudLibraryItemType.Torrent,
name = "Torrent",
files = listOf(
CloudLibraryFile(id = "7", name = "sample.mkv", playable = true),
CloudLibraryFile(id = "8", name = "movie.mkv", playable = true),
),
)
val state = CloudLibraryUiState(
isLoaded = true,
providers = listOf(
CloudLibraryProviderState(
provider = provider,
items = listOf(item),
),
),
)
val target = assertNotNull(
state.findPlaybackTargetForProgress(
contentId = "torbox:Torrent:29773238",
videoId = "torbox:Torrent:29773238:8",
),
)
assertEquals(item, target.item)
assertEquals("8", target.file.id)
}
@Test
fun `playback target lookup falls back to single playable file`() {
val provider = cloudProvider(id = "torbox", name = "TorBox")
val item = cloudItem(provider, "29773238")
val state = CloudLibraryUiState(
isLoaded = true,
providers = listOf(
CloudLibraryProviderState(
provider = provider,
items = listOf(item),
),
),
)
val target = assertNotNull(
state.findPlaybackTargetForProgress(
contentId = item.stableKey,
videoId = item.stableKey,
),
)
assertEquals(item, target.item)
assertEquals(item.playableFiles.single(), target.file)
}
@Test
fun `provider poster urls are mapped for cloud services`() {
assertEquals(
TorboxCloudLibraryPosterUrl,
cloudLibraryProviderPosterUrl("torbox:Torrent:29773238"),
)
assertEquals(
PremiumizeCloudLibraryPosterUrl,
cloudLibraryProviderPosterUrl("cloud:premiumize"),
)
assertTrue(
cloudLibraryDisplayArtworkUrl(TorboxCloudLibraryPosterUrl)
?.startsWith("data:image/svg+xml;base64,") == true,
)
assertEquals(
PremiumizeCloudLibraryPosterUrl,
cloudLibraryDisplayArtworkUrl(PremiumizeCloudLibraryPosterUrl),
)
}
}
private class FakeCloudProviderApi(

View file

@ -1,5 +1,6 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl
import com.nuvio.app.features.details.MetaVideo
import kotlin.test.Test
import kotlin.test.assertEquals
@ -95,6 +96,23 @@ class WatchProgressRulesTest {
assertEquals(2, result.size)
}
@Test
fun `cloud continue watching uses provider poster fallback`() {
val item = WatchProgressEntry(
contentType = "cloud",
parentMetaId = "torbox:Torrent:29773238",
parentMetaType = "cloud",
videoId = "torbox:Torrent:29773238:8",
title = "Cloud file",
lastPositionMs = 120_000L,
durationMs = 1_000_000L,
lastUpdatedEpochMs = 1L,
).toContinueWatchingItem()
assertEquals(TorboxCloudLibraryPosterUrl, item.poster)
assertEquals(TorboxCloudLibraryPosterUrl, item.imageUrl)
}
@Test
fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() {
val completedByPercent = entry(