mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-07-27 04:12:10 +00:00
Add Nuvio remote collection sources
This commit is contained in:
parent
2e0f1baee6
commit
b330eb475c
11 changed files with 246 additions and 13 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue