diff --git a/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt b/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt index c59ef45..7dd2078 100644 --- a/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt +++ b/app/src/main/java/com/fluxa/app/core/rust/FluxaAndroidHeadlessEnvironment.kt @@ -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, + skip: Int, + profile: UserProfile? + ): List = 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 { + 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 { + 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() + 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.profile(): UserProfile? = parseProfile(gson) + private fun Map.remoteSources(): List { + 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 = effect.payload.list("items").mapNotNull { raw -> runCatching { gson.fromJson(gson.toJsonTree(raw), CalendarUpcomingItem::class.java) }.getOrNull() diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeCatalogFeedCoordinator.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeCatalogFeedCoordinator.kt index 3e4d42c..942e10d 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeCatalogFeedCoordinator.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeCatalogFeedCoordinator.kt @@ -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 diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeModels.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeModels.kt index 43d2ce9..4ae8d2b 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeModels.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeModels.kt @@ -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? = emptyList(), + val remoteSources: List? = emptyList(), val addonIconUrl: String? = null ) diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt index dfe1f4f..b62a31c 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeViewModel.kt @@ -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 diff --git a/data/src/commonMain/kotlin/com/fluxa/app/data/local/LibraryCollectionModels.kt b/data/src/commonMain/kotlin/com/fluxa/app/data/local/LibraryCollectionModels.kt index aad8f50..1d9164c 100644 --- a/data/src/commonMain/kotlin/com/fluxa/app/data/local/LibraryCollectionModels.kt +++ b/data/src/commonMain/kotlin/com/fluxa/app/data/local/LibraryCollectionModels.kt @@ -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? = 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? = null, + val sources: List? = null, val coverEmoji: String? = null, val coverImageUrl: String? = null, val focusGifUrl: String? = null, diff --git a/data/src/main/java/com/fluxa/app/data/remote/AuxiliaryServices.kt b/data/src/main/java/com/fluxa/app/data/remote/AuxiliaryServices.kt index 51bf07e..b5a1ae1 100644 --- a/data/src/main/java/com/fluxa/app/data/remote/AuxiliaryServices.kt +++ b/data/src/main/java/com/fluxa/app/data/remote/AuxiliaryServices.kt @@ -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) diff --git a/data/src/main/java/com/fluxa/app/data/remote/NuvioModels.kt b/data/src/main/java/com/fluxa/app/data/remote/NuvioModels.kt index a659836..6153768 100644 --- a/data/src/main/java/com/fluxa/app/data/remote/NuvioModels.kt +++ b/data/src/main/java/com/fluxa/app/data/remote/NuvioModels.kt @@ -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? = null ) data class NuvioCollectionFolder( diff --git a/data/src/main/java/com/fluxa/app/data/remote/TraktApi.kt b/data/src/main/java/com/fluxa/app/data/remote/TraktApi.kt index 8bb2cd9..b048681 100644 --- a/data/src/main/java/com/fluxa/app/data/remote/TraktApi.kt +++ b/data/src/main/java/com/fluxa/app/data/remote/TraktApi.kt @@ -25,6 +25,18 @@ interface TraktApi { @Header("trakt-api-key") apiKey: String ): List + @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 + @Headers("Content-Type: application/json", "trakt-api-version: 2") @GET("shows/{id}/seasons/{season}") suspend fun getSeasonEpisodes( diff --git a/data/src/main/java/com/fluxa/app/data/remote/TraktModels.kt b/data/src/main/java/com/fluxa/app/data/remote/TraktModels.kt index 8c1842f..c4410c7 100644 --- a/data/src/main/java/com/fluxa/app/data/remote/TraktModels.kt +++ b/data/src/main/java/com/fluxa/app/data/remote/TraktModels.kt @@ -40,6 +40,11 @@ data class TraktSyncItem( val seasons: List? = null ) +data class TraktListItem( + val movie: TraktSummary? = null, + val show: TraktSummary? = null +) + data class TraktWatchedSeason( val number: Int? = null, val episodes: List? = null diff --git a/data/src/main/java/com/fluxa/app/data/repository/NuvioAccountImportCoordinator.kt b/data/src/main/java/com/fluxa/app/data/repository/NuvioAccountImportCoordinator.kt index c152e71..6ecc2e8 100644 --- a/data/src/main/java/com/fluxa/app/data/repository/NuvioAccountImportCoordinator.kt +++ b/data/src/main/java/com/fluxa/app/data/repository/NuvioAccountImportCoordinator.kt @@ -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 + ) + } ) } ) diff --git a/data/src/main/java/com/fluxa/app/data/repository/NuvioSyncCoordinator.kt b/data/src/main/java/com/fluxa/app/data/repository/NuvioSyncCoordinator.kt index f402283..d42bf23 100644 --- a/data/src/main/java/com/fluxa/app/data/repository/NuvioSyncCoordinator.kt +++ b/data/src/main/java/com/fluxa/app/data/repository/NuvioSyncCoordinator.kt @@ -142,8 +142,12 @@ private fun LibraryUserCollection.toNuvioCollection(): Map = 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) } ) }