diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 74a6c6d1a..50b373de4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -193,8 +193,34 @@ sealed interface HomeRowConfig { ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) } + + /** + * Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult. + * Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing + * specific knowledge of them. + * + * Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin + * config is persisted as XML, which doesn't support IDictionary. + */ + @Serializable + @SerialName("CustomEndpoint") + data class CustomEndpoint( + val endpoint: String, + val title: String, + val headers: List? = null, + val query: List? = null, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions) + } } +@Serializable +data class KeyValueEntry( + val key: String, + val value: String, +) + /** * Root class for home page settings * diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 39386cf83..da4e32218 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap @@ -49,12 +50,18 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -92,6 +99,7 @@ class HomeSettingsService private val imageUrlService: ImageUrlService, private val suggestionService: SuggestionService, private val displayPreferencesService: DisplayPreferencesService, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, ) { @OptIn(ExperimentalSerializationApi::class) val jsonParser = @@ -542,6 +550,14 @@ class HomeSettingsService config, ) } + + is HomeRowConfig.CustomEndpoint -> { + HomeRowConfigDisplay( + id = id, + title = StringStringProvider(config.title), + config, + ) + } } private suspend fun getItemName( @@ -1125,7 +1141,55 @@ class HomeSettingsService ) } } + + is HomeRowConfig.CustomEndpoint -> { + val title = StringStringProvider(row.title) + try { + val items = + fetchCustomEndpointItems(row) + .map { BaseItem(it, row.viewOptions.useSeries) } + Success(title, items, row.viewOptions, rowType = row) + } catch (ex: Exception) { + Timber.w(ex, "Custom endpoint %s failed", row.endpoint) + HomeRowLoadingState.Error(title, exception = ex) + } + } + } + + private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List { + val base = + api.baseUrl + ?: throw IllegalStateException("Jellyfin baseUrl not set") + if (!row.endpoint.startsWith("/") || row.endpoint.startsWith("//")) { + throw IllegalArgumentException("Custom endpoint must be an absolute path relative to Jellyfin baseUrl: ${row.endpoint}") + } + val resolved = + base + .toHttpUrl() + .resolve(row.endpoint) + ?: throw IllegalStateException("Could not resolve endpoint ${row.endpoint} against $base") + val params = buildMap { + serverRepository.currentUser + ?.id + ?.toString() + ?.let { put("userId", it) } + row.query?.forEach { put(it.key, it.value) } + } + val urlBuilder = resolved.newBuilder() + params.forEach { (k, v) -> urlBuilder.addQueryParameter(k, v) } + val requestBuilder = Request.Builder().url(urlBuilder.build()).get() + row.headers?.forEach { requestBuilder.header(it.key, it.value) } + val response = + authOkHttpClient + .newCall(requestBuilder.build()) + .execute() + return response.use { + if (!it.isSuccessful) { + throw InvalidStatusException(it.code, null) + } + jsonParser.decodeFromString(it.body.string()).items } + } companion object { const val CUSTOM_PREF_ID = "home_settings" diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 18cb89a03..e84557997 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -767,6 +767,10 @@ class HomeSettingsViewModel is HomeRowConfig.TvChannels -> { it.config.updateViewOptions(preset.liveTv) } + + is HomeRowConfig.CustomEndpoint -> { + it.config + } } it.copy(config = newConfig) }