From bdaa6d5b92b9e23e7c82a062d74ea245ca83ed32 Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Mon, 1 Jun 2026 18:41:28 +0200 Subject: [PATCH 1/5] Add CustomEndpoint home row Adds a new HomeRowConfig variant `CustomEndpoint` that resolves rows by GET-ing an arbitrary Jellyfin endpoint returning QueryResult. Lets third-party plugins (e.g. jellyfin-plugin-home-sections) feed rows into Wholphin without Wholphin needing specific knowledge of them. - Resolves both relative and absolute endpoint URLs via HttpUrl.resolve() - Auto-injects current userId as query param (overridable per-config) - Headers/Query are List to match the server-side plugin's XML-serializable shape - Auth token added automatically via AuthOkHttpClient --- .../wholphin/data/model/HomeRowConfig.kt | 26 ++++++++ .../wholphin/services/HomeSettingsService.kt | 59 +++++++++++++++++++ .../ui/main/settings/HomeSettingsViewModel.kt | 4 ++ 3 files changed, 89 insertions(+) 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..93159e486 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? = null, + 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..9f31730d6 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,16 @@ 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.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.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -92,6 +97,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 +548,14 @@ class HomeSettingsService config, ) } + + is HomeRowConfig.CustomEndpoint -> { + HomeRowConfigDisplay( + id = id, + title = config.title ?: config.endpoint, + config, + ) + } } private suspend fun getItemName( @@ -1125,8 +1139,53 @@ class HomeSettingsService ) } } + + is HomeRowConfig.CustomEndpoint -> { + val title = row.title ?: row.endpoint + 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) + Error(title, exception = ex) + } + } } + private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List { + val base = + api.baseUrl + ?: throw IllegalStateException("Jellyfin baseUrl not set") + val resolved = + base + .toHttpUrl() + .resolve(row.endpoint) + ?: throw IllegalStateException("Could not resolve endpoint ${row.endpoint} against $base") + // Build params as a map first so caller-supplied values can override our auto-injects. + val params = mutableMapOf() + serverRepository.currentUser.value + ?.id + ?.toString() + ?.let { params["userId"] = it } + row.query?.forEach { params[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 IllegalStateException("HTTP ${it.code} from ${row.endpoint}") + } + 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) } From d968c84c9ea9b0d5f88ea3cc03e05f9aa6848151 Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Mon, 1 Jun 2026 18:41:28 +0200 Subject: [PATCH 2/5] Address custom endpoint review comments --- .../wholphin/services/HomeSettingsService.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 9f31730d6..4d52a372a 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 @@ -54,11 +54,13 @@ 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 @@ -1154,7 +1156,7 @@ class HomeSettingsService } } - private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List { + private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List { val base = api.baseUrl ?: throw IllegalStateException("Jellyfin baseUrl not set") @@ -1163,13 +1165,13 @@ class HomeSettingsService .toHttpUrl() .resolve(row.endpoint) ?: throw IllegalStateException("Could not resolve endpoint ${row.endpoint} against $base") - // Build params as a map first so caller-supplied values can override our auto-injects. - val params = mutableMapOf() - serverRepository.currentUser.value - ?.id - ?.toString() - ?.let { params["userId"] = it } - row.query?.forEach { params[it.key] = it.value } + val params = buildMap { + serverRepository.currentUser.value + ?.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() @@ -1180,7 +1182,7 @@ class HomeSettingsService .execute() return response.use { if (!it.isSuccessful) { - throw IllegalStateException("HTTP ${it.code} from ${row.endpoint}") + throw InvalidStatusException(it.code, null) } jsonParser.decodeFromString(it.body.string()).items } From 23b65707118e787901041e87337a4cdc98038d5f Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Mon, 1 Jun 2026 18:41:28 +0200 Subject: [PATCH 3/5] Fix custom endpoint compile errors --- .../damontecres/wholphin/services/HomeSettingsService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 4d52a372a..1f61166f6 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 @@ -554,7 +554,7 @@ class HomeSettingsService is HomeRowConfig.CustomEndpoint -> { HomeRowConfigDisplay( id = id, - title = config.title ?: config.endpoint, + title = StringStringProvider(config.title ?: config.endpoint), config, ) } @@ -1143,7 +1143,7 @@ class HomeSettingsService } is HomeRowConfig.CustomEndpoint -> { - val title = row.title ?: row.endpoint + val title = StringStringProvider(row.title ?: row.endpoint) try { val items = fetchCustomEndpointItems(row) @@ -1151,7 +1151,7 @@ class HomeSettingsService Success(title, items, row.viewOptions, rowType = row) } catch (ex: Exception) { Timber.w(ex, "Custom endpoint %s failed", row.endpoint) - Error(title, exception = ex) + HomeRowLoadingState.Error(title, exception = ex) } } } @@ -1166,7 +1166,7 @@ class HomeSettingsService .resolve(row.endpoint) ?: throw IllegalStateException("Could not resolve endpoint ${row.endpoint} against $base") val params = buildMap { - serverRepository.currentUser.value + serverRepository.currentUser ?.id ?.toString() ?.let { put("userId", it) } From a50b07af974c00c059df406ce9c91fe4791bb7b7 Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Mon, 1 Jun 2026 20:14:54 +0200 Subject: [PATCH 4/5] Require relative custom endpoints --- .../damontecres/wholphin/data/model/HomeRowConfig.kt | 2 +- .../damontecres/wholphin/services/HomeSettingsService.kt | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 93159e486..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 @@ -206,7 +206,7 @@ sealed interface HomeRowConfig { @SerialName("CustomEndpoint") data class CustomEndpoint( val endpoint: String, - val title: String? = null, + val title: String, val headers: List? = null, val query: List? = null, override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), 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 1f61166f6..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 @@ -554,7 +554,7 @@ class HomeSettingsService is HomeRowConfig.CustomEndpoint -> { HomeRowConfigDisplay( id = id, - title = StringStringProvider(config.title ?: config.endpoint), + title = StringStringProvider(config.title), config, ) } @@ -1143,7 +1143,7 @@ class HomeSettingsService } is HomeRowConfig.CustomEndpoint -> { - val title = StringStringProvider(row.title ?: row.endpoint) + val title = StringStringProvider(row.title) try { val items = fetchCustomEndpointItems(row) @@ -1160,6 +1160,9 @@ class HomeSettingsService 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() From f379224733d33125957c32a1c7af210932c366e3 Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Wed, 10 Jun 2026 19:01:57 +0200 Subject: [PATCH 5/5] Fix ktlint formatting --- .../wholphin/services/HomeSettingsService.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 da4e32218..4c77eb362 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 @@ -1168,13 +1168,14 @@ class HomeSettingsService .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 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()