Add Nuvio remote collection sources

This commit is contained in:
KhooLy 2026-07-14 22:24:25 +03:00
parent 2e0f1baee6
commit b330eb475c
11 changed files with 246 additions and 13 deletions

View file

@ -3,6 +3,8 @@ package com.fluxa.app.core.rust
import android.content.Context
import android.util.Log
import com.fluxa.app.core.StremioId
import com.fluxa.app.BuildConfig
import com.fluxa.app.data.local.LibraryRemoteSource
import com.fluxa.app.data.local.OfflineDownloadManager
import com.fluxa.app.data.local.OfflineSubtitleOption
import com.fluxa.app.data.local.ProfileManager
@ -21,6 +23,9 @@ import com.fluxa.app.data.remote.MetaRating
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.remote.SubtitleData
import com.fluxa.app.data.remote.Video
import com.fluxa.app.data.remote.TmdbMeta
import com.fluxa.app.data.remote.TmdbService
import com.fluxa.app.data.remote.TraktApi
import com.fluxa.app.data.repository.AddonRepository
import com.fluxa.app.data.repository.StremioRepository
import com.fluxa.app.data.repository.TraktRepository
@ -58,6 +63,8 @@ import com.fluxa.app.ui.catalog.DirectPlaybackTarget
import com.fluxa.app.ui.catalog.TraktScrobbleWorker
import com.fluxa.app.ui.catalog.SimklScrobbleWorker
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@ -877,6 +884,19 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
private suspend fun fetchCatalogPage(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val remoteSources = payload.remoteSources()
if (remoteSources.isNotEmpty()) {
return ok(
effect,
mapOf(
"items" to fetchRemoteCollectionSources(
sources = remoteSources,
skip = payload.number("skip")?.toInt() ?: 0,
profile = payload.profile()
)
)
)
}
return ok(
effect,
mapOf(
@ -892,6 +912,125 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
)
}
private suspend fun fetchRemoteCollectionSources(
sources: List<LibraryRemoteSource>,
skip: Int,
profile: UserProfile?
): List<Meta> = coroutineScope {
sources.map { source ->
async {
when (source.provider.trim().lowercase()) {
"trakt" -> fetchTraktCollectionSource(source, skip)
"tmdb" -> fetchTmdbCollectionSource(source, skip, profile)
else -> emptyList()
}
}
}.awaitAll().flatten().distinctBy { "${it.type}:${it.id}" }
}
private suspend fun fetchTraktCollectionSource(source: LibraryRemoteSource, skip: Int): List<Meta> {
if (!TraktIntegration.hasClient(BuildConfig.TRAKT_CLIENT_ID)) return emptyList()
val listId = source.traktListId ?: return emptyList()
val isSeries = source.mediaType.equals("series", ignoreCase = true) || source.mediaType.equals("show", ignoreCase = true) || source.mediaType.equals("tv", ignoreCase = true)
return TraktApi.create().getListItems(
listId = listId,
type = if (isSeries) "show" else "movie",
apiKey = BuildConfig.TRAKT_CLIENT_ID,
page = (skip / 50) + 1,
sortBy = source.sortBy,
sortHow = source.sortHow
).mapNotNull { item ->
val summary = (if (isSeries) item.show else item.movie) ?: return@mapNotNull null
val id = summary.ids.imdb ?: summary.ids.tmdb?.let { "tmdb:$it" } ?: return@mapNotNull null
Meta(
id = id,
name = summary.title ?: return@mapNotNull null,
type = if (isSeries) "series" else "movie",
poster = null,
releaseInfo = summary.year?.toString(),
runtime = summary.runtime?.toString()
)
}
}
private suspend fun fetchTmdbCollectionSource(source: LibraryRemoteSource, skip: Int, profile: UserProfile?): List<Meta> {
val apiKey = profile?.safeTmdbApiKey.orEmpty()
val sourceId = source.tmdbId ?: return emptyList()
if (apiKey.isBlank()) return emptyList()
val mediaType = if (source.mediaType.equals("series", true) || source.mediaType.equals("tv", true) || source.mediaType.equals("show", true)) "tv" else "movie"
val sourceType = source.tmdbSourceType.orEmpty().uppercase()
val path = when (sourceType) {
"LIST" -> "list/$sourceId"
"COLLECTION" -> "collection/$sourceId"
"PERSON", "DIRECTOR" -> "person/$sourceId/combined_credits"
"COMPANY" -> "discover/$mediaType"
"NETWORK" -> "discover/tv"
else -> "discover/$mediaType"
}
val url = Uri.parse("https://api.themoviedb.org/3/$path").buildUpon()
.appendQueryParameter("api_key", apiKey)
.appendQueryParameter("language", profile?.safeLanguage ?: "en")
.apply {
if (sourceType !in setOf("COLLECTION", "PERSON", "DIRECTOR")) {
appendQueryParameter("page", (skip / 20 + 1).toString())
}
when (sourceType) {
"COMPANY" -> appendQueryParameter("with_companies", sourceId.toString())
"NETWORK" -> appendQueryParameter("with_networks", sourceId.toString())
}
if (sourceType !in setOf("LIST", "COLLECTION", "PERSON", "DIRECTOR")) {
appendQueryParameter("sort_by", source.sortBy ?: "popularity.desc")
}
val filters = source.filters.orEmpty()
mapOf(
"year" to if (mediaType == "tv") "first_air_date_year" else "year",
"withGenres" to "with_genres",
"watchRegion" to "watch_region",
"voteCountGte" to "vote_count.gte",
"withKeywords" to "with_keywords",
"withNetworks" to "with_networks",
"withCompanies" to "with_companies",
"releaseDateGte" to if (mediaType == "tv") "first_air_date.gte" else "primary_release_date.gte",
"releaseDateLte" to if (mediaType == "tv") "first_air_date.lte" else "primary_release_date.lte",
"voteAverageGte" to "vote_average.gte",
"voteAverageLte" to "vote_average.lte",
"withOriginCountry" to "with_origin_country",
"withWatchProviders" to "with_watch_providers",
"withOriginalLanguage" to "with_original_language"
).forEach { (input, output) -> filters[input]?.let { appendQueryParameter(output, it) } }
}
.build()
.toString()
val root = TmdbService.create().getCollectionSource(url)
val items = root.asJsonObjectOrNull()?.let { objectNode ->
when {
sourceType == "DIRECTOR" -> objectNode.getAsJsonArrayOrNull("crew")?.filter { it.asJsonObjectOrNull()?.get("job")?.asString == "Director" && it.asJsonObjectOrNull()?.get("media_type")?.asString == mediaType }
sourceType == "PERSON" -> objectNode.getAsJsonArrayOrNull("cast")?.filter { it.asJsonObjectOrNull()?.get("media_type")?.asString == mediaType }
else -> listOf("results", "parts", "items", "cast", "crew").firstNotNullOfOrNull { key -> objectNode.getAsJsonArrayOrNull(key) }
}
} ?: emptyList<JsonElement>()
return items.mapNotNull { item ->
runCatching { gson.fromJson(item, TmdbMeta::class.java) }.getOrNull()?.toCollectionMeta(mediaType)
}
}
private fun TmdbMeta.toCollectionMeta(defaultMediaType: String): Meta? {
val type = if (media_type == "tv" || defaultMediaType == "tv") "series" else "movie"
val title = if (type == "series") name else title
return title?.let {
Meta(
id = "tmdb:$id",
name = it,
type = type,
poster = posterPath?.let { path -> "https://image.tmdb.org/t/p/w500$path" },
background = backdropPath?.let { path -> "https://image.tmdb.org/t/p/w1280$path" },
description = overview,
releaseInfo = (if (type == "series") first_air_date else release_date)?.take(4),
originalName = original_name
)
}
}
private suspend fun fetchSeasonEpisodes(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
val payload = effect.payload
val profile = payload.profile()
@ -1210,6 +1349,19 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
internal fun Map<String, Any?>.profile(): UserProfile? = parseProfile(gson)
private fun Map<String, Any?>.remoteSources(): List<LibraryRemoteSource> {
val raw = this["remoteSource"] ?: return emptyList()
val values = raw as? List<*> ?: listOf(raw)
return values.mapNotNull { value ->
runCatching { gson.fromJson(gson.toJsonTree(value), LibraryRemoteSource::class.java) }.getOrNull()
}
}
private fun JsonElement.asJsonObjectOrNull() = takeIf { it.isJsonObject }?.asJsonObject
private fun com.google.gson.JsonObject.getAsJsonArrayOrNull(key: String): JsonArray? =
get(key)?.takeIf { it.isJsonArray }?.asJsonArray
internal fun calendarItems(effect: NativeHeadlessEffect): List<CalendarUpcomingItem> =
effect.payload.list("items").mapNotNull { raw ->
runCatching { gson.fromJson(gson.toJsonTree(raw), CalendarUpcomingItem::class.java) }.getOrNull()

View file

@ -83,30 +83,34 @@ internal class HomeCatalogFeedCoordinator(
}
val folderResultCategories = collection.folders.orEmpty().mapNotNull { folder ->
val sources = folderSources[folder].orEmpty()
if (sources.isEmpty()) return@mapNotNull null
val remoteSources = folder.sources.orEmpty()
if (sources.isEmpty() && remoteSources.isEmpty()) return@mapNotNull null
HomeCategory(
name = folder.title,
items = emptyList(),
id = folder.id,
type = "collection_folder",
catalogId = sources.first().catalogId,
addonTransportUrl = sources.first().transportUrl,
catalogId = sources.firstOrNull()?.catalogId ?: folder.id,
addonTransportUrl = sources.firstOrNull()?.transportUrl,
addonGenre = folder.genre,
catalogSources = sources
catalogSources = sources,
remoteSources = remoteSources
)
}
val allSources = collection.folders.orEmpty().flatMap { folderSources[it].orEmpty() }
val allRemoteSources = collection.folders.orEmpty().flatMap { it.sources.orEmpty() }
val allCategoryId = "${collection.id}.all"
val allResultCategory = if (collection.showAllTab == true && allSources.isNotEmpty()) {
val allResultCategory = if (collection.showAllTab == true && (allSources.isNotEmpty() || allRemoteSources.isNotEmpty())) {
HomeCategory(
name = AppStrings.t(lang, "auto.all"),
items = emptyList(),
id = allCategoryId,
type = "collection_folder",
catalogId = allSources.first().catalogId,
addonTransportUrl = allSources.first().transportUrl,
catalogId = allSources.firstOrNull()?.catalogId ?: allCategoryId,
addonTransportUrl = allSources.firstOrNull()?.transportUrl,
addonGenre = null,
catalogSources = allSources
catalogSources = allSources,
remoteSources = allRemoteSources
)
} else {
null

View file

@ -3,6 +3,7 @@ package com.fluxa.app.ui.catalog
import androidx.compose.runtime.Immutable
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.local.LibraryRemoteSource
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
@Immutable
@ -28,6 +29,7 @@ data class HomeCategory(
val addonTransportUrl: String? = null,
val addonGenre: String? = null,
val catalogSources: List<HomeCatalogSource>? = emptyList(),
val remoteSources: List<LibraryRemoteSource>? = emptyList(),
val addonIconUrl: String? = null
)

View file

@ -925,6 +925,7 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
try {
val catalogSources = category.catalogSources.orEmpty()
val remoteSources = category.remoteSources.orEmpty()
if (catalogSources.isNotEmpty()) {
val nextSkip = if (category.items.isEmpty()) 0 else category.skip + 20
val lang = currentActiveProfile?.safeLanguage ?: "en"
@ -967,7 +968,9 @@ class HomeViewModel @Inject constructor(
"catalogId" to category.catalogId,
"skip" to (category.skip + category.items.size),
"genre" to category.addonGenre,
"search" to null
"search" to null,
"remoteSource" to remoteSources,
"profile" to currentActiveProfile
)
)
val home = result.state["home"] as? Map<*, *> ?: return@launch

View file

@ -19,6 +19,22 @@ data class LibraryCatalogSource(
val type: String
)
data class LibraryRemoteSource(
val provider: String,
val title: String? = null,
val mediaType: String? = null,
val traktListId: Long? = null,
val tmdbSourceType: String? = null,
val tmdbId: Long? = null,
val sortBy: String? = null,
val sortHow: String? = null,
val filters: Map<String, String>? = null,
val addonId: String? = null,
val catalogId: String? = null,
val type: String? = null,
val genre: String? = null
)
data class LibraryUserCollectionFolder(
val id: String,
val title: String,
@ -30,6 +46,7 @@ data class LibraryUserCollectionFolder(
val hideTitle: Boolean? = false,
val focusGifEnabled: Boolean? = true,
val catalogSources: List<LibraryCatalogSource>? = null,
val sources: List<LibraryRemoteSource>? = null,
val coverEmoji: String? = null,
val coverImageUrl: String? = null,
val focusGifUrl: String? = null,

View file

@ -11,6 +11,7 @@ import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
interface TmdbService {
@GET("3/{type}/{id}/recommendations")
@ -46,6 +47,9 @@ interface TmdbService {
@GET("3/collection/{id}")
suspend fun getCollection(@Path("id") id: Int, @Query("language") lang: String = "en", @Query("api_key") apiKey: String = ""): TmdbCollectionResponse
@GET
suspend fun getCollectionSource(@Url url: String): JsonElement
companion object {
private var instance: TmdbService? = null
fun create(): TmdbService {
@ -103,7 +107,10 @@ data class TmdbMeta(
val original_name: String? = null,
val release_date: String?,
val first_air_date: String?,
val media_type: String?
val media_type: String?,
@SerializedName("poster_path") val posterPath: String? = null,
@SerializedName("backdrop_path") val backdropPath: String? = null,
val overview: String? = null
)
// Full detail response (movie or TV)

View file

@ -69,7 +69,15 @@ data class NuvioCollectionFolderSource(
@SerializedName("addonId") val addonId: String?,
@SerializedName("catalogId") val catalogId: String?,
val type: String?,
val genre: String? = null
val genre: String? = null,
val title: String? = null,
@SerializedName("mediaType") val mediaType: String? = null,
@SerializedName("traktListId") val traktListId: Long? = null,
@SerializedName("tmdbSourceType") val tmdbSourceType: String? = null,
@SerializedName("tmdbId") val tmdbId: Long? = null,
@SerializedName("sortBy") val sortBy: String? = null,
@SerializedName("sortHow") val sortHow: String? = null,
val filters: Map<String, String>? = null
)
data class NuvioCollectionFolder(

View file

@ -25,6 +25,18 @@ interface TraktApi {
@Header("trakt-api-key") apiKey: String
): List<TraktSearchResult>
@Headers("Content-Type: application/json", "trakt-api-version: 2")
@GET("lists/{listId}/items/{type}")
suspend fun getListItems(
@Path("listId") listId: Long,
@Path("type") type: String,
@Header("trakt-api-key") apiKey: String,
@Query("page") page: Int,
@Query("limit") limit: Int = 50,
@Query("sort_by") sortBy: String? = null,
@Query("sort_how") sortHow: String? = null
): List<TraktListItem>
@Headers("Content-Type: application/json", "trakt-api-version: 2")
@GET("shows/{id}/seasons/{season}")
suspend fun getSeasonEpisodes(

View file

@ -40,6 +40,11 @@ data class TraktSyncItem(
val seasons: List<TraktWatchedSeason>? = null
)
data class TraktListItem(
val movie: TraktSummary? = null,
val show: TraktSummary? = null
)
data class TraktWatchedSeason(
val number: Int? = null,
val episodes: List<TraktWatchedEpisode>? = null

View file

@ -4,6 +4,7 @@ import android.util.Log
import com.fluxa.app.data.local.LibraryUserCollection
import com.fluxa.app.data.local.LibraryUserCollectionFolder
import com.fluxa.app.data.local.LibraryCatalogSource
import com.fluxa.app.data.local.LibraryRemoteSource
import com.fluxa.app.data.local.ProfileManager
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.WatchlistManager
@ -258,6 +259,24 @@ class NuvioAccountImportCoordinator(
type = source.type ?: "movie"
)
}?.filter { it.catalogId.isNotBlank() }
,
sources = folder.catalogSources?.map { source ->
LibraryRemoteSource(
provider = source.provider ?: "addon",
title = source.title,
mediaType = source.mediaType,
traktListId = source.traktListId,
tmdbSourceType = source.tmdbSourceType,
tmdbId = source.tmdbId,
sortBy = source.sortBy,
sortHow = source.sortHow,
filters = source.filters,
addonId = source.addonId,
catalogId = source.catalogId,
type = source.type,
genre = source.genre
)
}
)
}
)

View file

@ -142,8 +142,12 @@ private fun LibraryUserCollection.toNuvioCollection(): Map<String, Any?> = mapOf
"heroBackdropUrl" to folder.heroBackdropUrl,
"tileShape" to folder.shape,
"hideTitle" to folder.hideTitle,
"catalogSources" to folder.catalogSources.orEmpty().map { source ->
mapOf("provider" to "addon", "addonId" to source.addonId, "catalogId" to source.catalogId, "type" to source.type)
"sources" to (folder.sources.orEmpty().ifEmpty {
folder.catalogSources.orEmpty().map { source ->
com.fluxa.app.data.local.LibraryRemoteSource(provider = "addon", addonId = source.addonId, catalogId = source.catalogId, type = source.type)
}
}).map { source ->
mapOf("provider" to source.provider, "title" to source.title, "mediaType" to source.mediaType, "traktListId" to source.traktListId, "tmdbSourceType" to source.tmdbSourceType, "tmdbId" to source.tmdbId, "sortBy" to source.sortBy, "sortHow" to source.sortHow, "filters" to source.filters, "addonId" to source.addonId, "catalogId" to source.catalogId, "type" to source.type, "genre" to source.genre)
}
)
}