feat: support for multiple addon with same id

This commit is contained in:
tapframe 2026-04-11 17:19:16 +05:30
parent 19b51c38d3
commit a33558cbae
4 changed files with 66 additions and 34 deletions

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -535,7 +536,10 @@ private fun EpisodeStreamsSubView(
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
) {
items(streams, key = { "${it.addonId}::${it.url ?: it.name}" }) { stream ->
itemsIndexed(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
) { _, stream ->
EpisodeSourceStreamRow(
stream = stream,
onClick = { onStreamSelected(stream, episode) },

View file

@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -195,7 +196,10 @@ fun PlayerSourcesPanel(
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
) {
items(streams, key = { "${it.addonId}::${it.url ?: it.name}" }) { stream ->
itemsIndexed(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
) { _, stream ->
val isCurrent = isCurrentStream(
stream = stream,
currentUrl = currentStreamUrl,

View file

@ -165,19 +165,22 @@ object PlayerStreamsRepository {
return
}
val addonDisplayNames = installedAddons.associate {
(it.manifest?.id ?: it.manifestUrl) to it.displayTitle
}
val streamAddons = installedAddons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
val supportsRequestedStream = manifest.resources.any { resource ->
resource.name == "stream" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() ||
resource.idPrefixes.any { videoId.startsWith(it) })
}
if (!supportsRequestedStream) return@mapNotNull null
PlayerInstalledStreamAddonTarget(
addonName = addon.displayTitle.ifBlank { manifest.name },
addonId = addon.streamAddonInstanceId(manifest.id),
manifest = manifest,
)
}
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
@ -188,10 +191,10 @@ object PlayerStreamsRepository {
return
}
val initialGroups = streamAddons.map { manifest ->
val initialGroups = streamAddons.map { addon ->
AddonStreamGroup(
addonName = addonDisplayNames[manifest.id] ?: manifest.name,
addonId = manifest.id,
addonName = addon.addonName,
addonId = addon.addonId,
streams = emptyList(),
isLoading = true,
)
@ -210,25 +213,25 @@ object PlayerStreamsRepository {
)
val job = scope.launch {
val addonJobs = streamAddons.map { manifest ->
val addonJobs = streamAddons.map { addon ->
async {
val encodedId = videoId.replace("%", "%25").replace(" ", "%20")
val baseUrl = manifest.transportUrl
val baseUrl = addon.manifest.transportUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
val url = "$baseUrl/stream/$type/$encodedId.json"
val displayName = addonDisplayNames[manifest.id] ?: manifest.name
val displayName = addon.addonName
runCatching {
val payload = httpGetText(url)
StreamParser.parse(payload, displayName, manifest.id)
StreamParser.parse(payload, displayName, addon.addonId)
}.fold(
onSuccess = { streams ->
AddonStreamGroup(displayName, manifest.id, streams, isLoading = false)
AddonStreamGroup(displayName, addon.addonId, streams, isLoading = false)
},
onFailure = { err ->
log.w(err) { "Failed: ${displayName}" }
AddonStreamGroup(displayName, manifest.id, emptyList(), isLoading = false, error = err.message)
AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message)
},
)
}
@ -289,6 +292,15 @@ object PlayerStreamsRepository {
}
}
private data class PlayerInstalledStreamAddonTarget(
val addonName: String,
val addonId: String,
val manifest: com.nuvio.app.features.addons.AddonManifest,
)
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
"addon:$manifestId:$manifestUrl"
private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem {
val subtitleParts = listOfNotNull(
quality?.takeIf { it.isNotBlank() },

View file

@ -127,19 +127,22 @@ object StreamsRepository {
return
}
val addonDisplayNames = installedAddons.associate {
(it.manifest?.id ?: it.manifestUrl) to it.displayTitle
}
val streamAddons = installedAddons
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->
.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
val supportsRequestedStream = manifest.resources.any { resource ->
resource.name == "stream" &&
resource.types.contains(type) &&
(resource.idPrefixes.isEmpty() ||
resource.idPrefixes.any { videoId.startsWith(it) })
}
if (!supportsRequestedStream) return@mapNotNull null
InstalledStreamAddonTarget(
addonName = addon.displayTitle.ifBlank { manifest.name },
addonId = addon.streamAddonInstanceId(manifest.id),
manifest = manifest,
)
}
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
@ -153,10 +156,10 @@ object StreamsRepository {
}
// Initialise loading placeholders
val initialGroups = streamAddons.map { manifest ->
val initialGroups = streamAddons.map { addon ->
AddonStreamGroup(
addonName = addonDisplayNames[manifest.id] ?: manifest.name,
addonId = manifest.id,
addonName = addon.addonName,
addonId = addon.addonId,
streams = emptyList(),
isLoading = true,
)
@ -232,29 +235,29 @@ object StreamsRepository {
null
}
streamAddons.forEach { manifest ->
streamAddons.forEach { addon ->
launch {
val encodedId = videoId.encodeForPath()
val baseUrl = manifest.transportUrl
val baseUrl = addon.manifest.transportUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
val url = "$baseUrl/stream/$type/$encodedId.json"
log.d { "Fetching streams from: $url" }
val displayName = addonDisplayNames[manifest.id] ?: manifest.name
val displayName = addon.addonName
val group = runCatching {
val payload = httpGetText(url)
StreamParser.parse(
payload = payload,
addonName = displayName,
addonId = manifest.id,
addonId = addon.addonId,
)
}.fold(
onSuccess = { streams ->
log.d { "Got ${streams.size} streams from ${displayName}" }
AddonStreamGroup(
addonName = displayName,
addonId = manifest.id,
addonId = addon.addonId,
streams = streams,
isLoading = false,
)
@ -263,7 +266,7 @@ object StreamsRepository {
log.w(err) { "Failed to fetch streams from ${displayName}" }
AddonStreamGroup(
addonName = displayName,
addonId = manifest.id,
addonId = addon.addonId,
streams = emptyList(),
isLoading = false,
error = err.message,
@ -423,6 +426,15 @@ object StreamsRepository {
replace("%", "%25").replace(" ", "%20")
}
private data class InstalledStreamAddonTarget(
val addonName: String,
val addonId: String,
val manifest: com.nuvio.app.features.addons.AddonManifest,
)
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
"addon:$manifestId:$manifestUrl"
private data class PluginProviderGroup(
val addonId: String,
val addonName: String,