Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseItemDto>.
* 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<KeyValueEntry>? = null,
val query: List<KeyValueEntry>? = 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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -542,6 +550,14 @@ class HomeSettingsService
config,
)
}

is HomeRowConfig.CustomEndpoint -> {
HomeRowConfigDisplay(
id = id,
title = StringStringProvider(config.title),
config,
)
}
}

private suspend fun getItemName(
Expand Down Expand Up @@ -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<BaseItemDto> {
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<BaseItemDtoQueryResult>(it.body.string()).items
}
}

companion object {
const val CUSTOM_PREF_ID = "home_settings"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,10 @@ class HomeSettingsViewModel
is HomeRowConfig.TvChannels -> {
it.config.updateViewOptions(preset.liveTv)
}

is HomeRowConfig.CustomEndpoint -> {
it.config
}
}
it.copy(config = newConfig)
}
Expand Down