feat: add embedded streams support in video details and player repositories

This commit is contained in:
tapframe 2026-03-30 13:36:04 +05:30
parent 375f13f5f6
commit f01427fea3
7 changed files with 150 additions and 20 deletions

View file

@ -1,5 +1,7 @@
package com.nuvio.app.features.details package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamItem
data class MetaDetails( data class MetaDetails(
val id: String, val id: String,
val type: String, val type: String,
@ -44,6 +46,7 @@ data class MetaVideo(
val season: Int? = null, val season: Int? = null,
val episode: Int? = null, val episode: Int? = null,
val overview: String? = null, val overview: String? = null,
val streams: List<StreamItem> = emptyList(),
) )
data class MetaDetailsUiState( data class MetaDetailsUiState(

View file

@ -1,5 +1,7 @@
package com.nuvio.app.features.details package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -8,6 +10,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
internal object MetaDetailsParser { internal object MetaDetailsParser {
@ -203,8 +206,43 @@ internal object MetaDetailsParser {
season = video.int("season"), season = video.int("season"),
episode = video.int("episode"), episode = video.int("episode"),
overview = video.string("overview") ?: video.string("description"), overview = video.string("overview") ?: video.string("description"),
streams = video.embeddedStreams(),
) )
} }
private fun JsonObject.embeddedStreams(): List<StreamItem> {
val arr = this["streams"] as? JsonArray ?: return emptyList()
return arr.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val url = obj.string("url")
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val streamData = obj["streamData"] as? JsonObject
val addonName = streamData?.string("addon") ?: obj.string("name") ?: "Embedded"
StreamItem(
name = obj.string("name"),
description = obj.string("description") ?: obj.string("title"),
url = url,
infoHash = infoHash,
fileIdx = obj.int("fileIdx"),
externalUrl = externalUrl,
addonName = addonName,
addonId = "embedded",
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = hintsObj?.boolean("notWebReady") ?: false,
videoSize = hintsObj?.long("videoSize"),
filename = hintsObj?.string("filename"),
),
)
}
}
private fun JsonObject.long(name: String): Long? =
this[name]?.jsonPrimitive?.longOrNull
} }
private fun JsonElement?.asJsonObjectOrNull(): JsonObject? = this as? JsonObject private fun JsonElement?.asJsonObjectOrNull(): JsonObject? = this as? JsonObject

View file

@ -130,7 +130,7 @@ object MetaDetailsRepository {
log.d { "Parsed meta: type=${result.type}, name=${result.name}, videos=${result.videos.size}" } log.d { "Parsed meta: type=${result.type}, name=${result.name}, videos=${result.videos.size}" }
if (result.videos.isNotEmpty()) { if (result.videos.isNotEmpty()) {
val first = result.videos.first() val first = result.videos.first()
log.d { "First video: id=${first.id} title=${first.title} s=${first.season} e=${first.episode}" } log.d { "First video: id=${first.id} title=${first.title} s=${first.season} e=${first.episode} embeddedStreams=${first.streams.size}" }
} }
result result
} catch (e: Throwable) { } catch (e: Throwable) {
@ -138,4 +138,37 @@ object MetaDetailsRepository {
null null
} }
} }
fun findEmbeddedStreams(videoId: String): List<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList()
val videosWithStreams = meta.videos.filter { it.streams.isNotEmpty() }
if (videosWithStreams.isEmpty()) return emptyList()
val directMatch = videosWithStreams.firstOrNull { it.id == videoId }
if (directMatch != null) return directMatch.streams
val parts = videoId.split(":")
if (parts.size >= 3) {
val season = parts[parts.size - 2].toIntOrNull()
val episode = parts[parts.size - 1].toIntOrNull()
if (season != null && episode != null) {
val episodeMatch = videosWithStreams.firstOrNull { it.season == season && it.episode == episode }
if (episodeMatch != null) return episodeMatch.streams
}
}
val prefixMatch = videosWithStreams.firstOrNull { it.id.startsWith("$videoId:") }
if (prefixMatch != null) return prefixMatch.streams
if (videoId == meta.id && videosWithStreams.size == 1) {
return videosWithStreams.first().streams
}
if (videoId == meta.id && videosWithStreams.isNotEmpty()) {
return videosWithStreams.flatMap { it.streams }
}
return emptyList()
}
} }

View file

@ -147,11 +147,12 @@ fun MetaDetailsScreen(
) == action.videoId ) == action.videoId
}?.overview }?.overview
} }
val playButtonLabel = remember(movieProgress, seriesAction, meta.type) { val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) {
when { when {
meta.type == "series" && seriesAction != null -> (meta.type == "series" || hasEpisodes) && seriesAction != null ->
seriesAction.label seriesAction.label
meta.type != "series" && movieProgress != null -> meta.type != "series" && !hasEpisodes && movieProgress != null ->
"Resume" "Resume"
else -> "Play" else -> "Play"
} }
@ -200,7 +201,7 @@ fun MetaDetailsScreen(
isSaved = isSaved, isSaved = isSaved,
onPlayClick = { onPlayClick = {
when { when {
meta.type == "series" && seriesAction != null -> { (meta.type == "series" || hasEpisodes) && seriesAction != null -> {
onPlay?.invoke( onPlay?.invoke(
meta.type, meta.type,
seriesAction.videoId, seriesAction.videoId,

View file

@ -57,7 +57,8 @@ fun DetailSeriesContent(
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(), progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeClick: ((MetaVideo) -> Unit)? = null,
) { ) {
if (meta.type != "series") return val hasVideos = meta.videos.isNotEmpty()
if (meta.type != "series" && !hasVideos) return
if (meta.videos.isEmpty()) { if (meta.videos.isEmpty()) {
DetailSection( DetailSection(
@ -85,21 +86,30 @@ fun DetailSeriesContent(
if (meta.videos.isNotEmpty() && withSeasonOrEp.isEmpty()) { if (meta.videos.isNotEmpty() && withSeasonOrEp.isEmpty()) {
log.w { "All videos lack season/episode fields! First: ${meta.videos.first()}" } log.w { "All videos lack season/episode fields! First: ${meta.videos.first()}" }
} }
withSeasonOrEp if (withSeasonOrEp.isNotEmpty()) {
.sortedWith(metaVideoSeasonEpisodeComparator) withSeasonOrEp
.groupBy { normalizeSeasonNumber(it.season) } .sortedWith(metaVideoSeasonEpisodeComparator)
.groupBy { normalizeSeasonNumber(it.season) }
} else if (meta.type != "series" && meta.videos.isNotEmpty()) {
// For non-series types (e.g. "other"), show videos without season/episode as a flat list
mapOf(normalizeSeasonNumber(null) to meta.videos)
} else {
emptyMap()
}
} }
if (groupedEpisodes.isEmpty()) { if (groupedEpisodes.isEmpty()) {
DetailSection( if (meta.type == "series") {
title = "Episodes", DetailSection(
modifier = modifier, title = "Episodes",
) { modifier = modifier,
Text( ) {
text = "This addon returned videos for the series, but none included season or episode numbers.", Text(
style = MaterialTheme.typography.bodyMedium, text = "This addon returned videos for the series, but none included season or episode numbers.",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
return return
} }
@ -172,8 +182,13 @@ fun DetailSeriesContent(
} }
} }
val sectionTitle = if (meta.type != "series" && seasons.size == 1 && currentSeason <= 0) {
"Videos"
} else {
currentSeason.label()
}
DetailSectionTitle( DetailSectionTitle(
title = currentSeason.label(), title = sectionTitle,
) )
Column( Column(
@ -496,7 +511,11 @@ private fun Int.label(): String =
} }
private fun MetaVideo.episodeBadge(): String = private fun MetaVideo.episodeBadge(): String =
episode?.let { "E${it.toString().padStart(2, '0')}" } ?: "EP" when {
episode != null -> "E${episode.toString().padStart(2, '0')}"
season != null -> "S${season.toString().padStart(2, '0')}"
else -> "FILE"
}
private fun String.formattedDate(): String { private fun String.formattedDate(): String {
val isoDate = substringBefore('T') val isoDate = substringBefore('T')

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamParser import com.nuvio.app.features.streams.StreamParser
import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.StreamsUiState
@ -108,6 +109,23 @@ object PlayerStreamsRepository {
jobHolder()?.cancel() jobHolder()?.cancel()
stateFlow.value = StreamsUiState() stateFlow.value = StreamsUiState()
val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId)
if (embeddedStreams.isNotEmpty()) {
log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" }
val group = AddonStreamGroup(
addonName = embeddedStreams.first().addonName,
addonId = "embedded",
streams = embeddedStreams,
isLoading = false,
)
stateFlow.value = StreamsUiState(
groups = listOf(group),
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
)
return
}
val installedAddons = AddonRepository.uiState.value.addons val installedAddons = AddonRepository.uiState.value.addons
if (installedAddons.isEmpty()) { if (installedAddons.isEmpty()) {
stateFlow.value = StreamsUiState( stateFlow.value = StreamsUiState(

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.streams
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -48,6 +49,23 @@ object StreamsRepository {
activeJob?.cancel() activeJob?.cancel()
_uiState.value = StreamsUiState() _uiState.value = StreamsUiState()
val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId)
if (embeddedStreams.isNotEmpty()) {
log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" }
val group = AddonStreamGroup(
addonName = embeddedStreams.first().addonName,
addonId = "embedded",
streams = embeddedStreams,
isLoading = false,
)
_uiState.value = StreamsUiState(
groups = listOf(group),
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
)
return
}
val installedAddons = AddonRepository.uiState.value.addons val installedAddons = AddonRepository.uiState.value.addons
if (installedAddons.isEmpty()) { if (installedAddons.isEmpty()) {
_uiState.value = StreamsUiState( _uiState.value = StreamsUiState(