Implementation

Searching media#

This guide walks you through implementing in-app search for your provider using the capability-first SDK.

Overview#

To support search, expose a SearchProviderApi from your ProviderPlugin:

class TestProviderPlugin : ProviderPlugin() {
    private val client by lazy { OkHttpClient() }
 
    private val searchApi by lazy {
        TestSearchApi(
            client = client,
            providerId = id,
            tmdbApiKey = settings.getString("tmdb_api_key", null) ?: "",
        )
    }
 
    override fun getSearchApi(context: Context): SearchProviderApi = searchApi
}

Then implement SearchProviderApi.search(...), which returns a PaginatedResponse<FilmSearchItem>.

PaginatedResponse#

The host uses PaginatedResponse<T> to paginate results.

FieldMeaning
pageThe current page number (1-based).
resultsItems for the current page.
hasNextPageWhether another page exists.
totalPagesTotal pages (set to 0 if unknown).

Example using TMDB Search API#

In this section, TMDB’s search endpoints are used as an example.

Define filters (optional)#

Filters let users control what they’re searching for. Here’s a simple “Media type” filter group:

private const val FILTER_ALL = 0
private const val FILTER_MOVIE = 1
private const val FILTER_TV_SHOW = 2
 
class TmdbMediaTypeFilters(
    name: String = "Media type",
) : FilterGroup(
    name = name,
    Filter.Select(
        name = "",
        options = listOf("All", "Movies", "TV Shows"),
        state = FILTER_ALL,
    )
) {
    private val select: Filter.Select<String>
        get() = first() as Filter.Select<String>
 
    val selectedIndex: Int
        get() = select.state
}

Then register it in your API implementation:

override val filters: FilterList = FilterList(
    TmdbMediaTypeFilters(),
)

Define DTO classes#

Core Stubs uses Kotlinx Serialization for JSON parsing. Define DTOs using @Serializable + @SerialName.

@Serializable
data class TmdbSearchResponseDto(
    val page: Int,
    @SerialName("total_pages") val totalPages: Int = 0,
    val results: List<TmdbSearchItemDto> = emptyList(),
)
 
@Serializable
data class TmdbSearchItemDto(
    val id: Int,
    @SerialName("media_type") val mediaType: String? = null,
    val title: String? = null,
    val name: String? = null,
    @SerialName("poster_path") val posterPath: String? = null,
    @SerialName("backdrop_path") val backdropPath: String? = null,
    val overview: String? = null,
    @SerialName("vote_average") val voteAverage: Double? = null,
    @SerialName("original_language") val originalLanguage: String? = null,
    val adult: Boolean? = null,
    @SerialName("release_date") val releaseDate: String? = null,
    @SerialName("first_air_date") val firstAirDate: String? = null,
)
 
private fun TmdbSearchItemDto.toFilmSearchItemOrNull(
    providerId: String,
    forcedType: FilmType? = null,
): FilmSearchItem? {
    val resolvedType = forcedType ?: when (mediaType) {
        "movie" -> FilmType.MOVIE
        "tv" -> FilmType.TV_SHOW
        else -> null
    }
 
    if (resolvedType == null) {
        return null
    }
 
    val resolvedTitle = title ?: name.orEmpty()
    val resolvedReleaseDate = releaseDate ?: firstAirDate
 
    val poster = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }
    val backdrop = backdropPath?.let { "https://image.tmdb.org/t/p/w780$it" }
 
    return FilmSearchItem(
        id = id.toString(),
        providerId = providerId,
        filmType = resolvedType,
        homePage = "https://www.themoviedb.org/${resolvedType.type}/$id",
        title = resolvedTitle,
        posterImage = poster,
        adult = adult == true,
        backdropImage = backdrop,
        externalIds = mapOf(FilmIdSource.TMDB to id.toString()),
        releaseDate = parseDate(resolvedReleaseDate),
        rating = voteAverage,
        language = originalLanguage,
        overview = overview,
    )
}

Pick an endpoint based on the selected filter, then parse JSON with Response.fromJson<T>().

private fun buildTmdbRequestUrl(
    endpoint: String,
    query: String,
    page: Int,
    apiKey: String,
): String {
    val encodedQuery = URLEncoder.encode(query, Charsets.UTF_8.name())
    return "$endpoint?api_key=$apiKey&query=$encodedQuery&page=$page"
}
 
override suspend fun search(
    title: String,
    page: Int,
    filters: FilterList,
): PaginatedResponse<FilmSearchItem> {
    val mediaTypeFilter = filters
        .filterIsInstance<TmdbMediaTypeFilters>()
        .firstOrNull()
    val mediaTypeIndex = mediaTypeFilter?.selectedIndex ?: FILTER_ALL
 
    val endpoint = when (mediaTypeIndex) {
        FILTER_ALL -> "https://api.themoviedb.org/3/search/multi"
        FILTER_TV_SHOW -> "https://api.themoviedb.org/3/search/tv"
        FILTER_MOVIE -> "https://api.themoviedb.org/3/search/movie"
        else -> "https://api.themoviedb.org/3/search/multi"
    }
 
    val dto = client
        .request(url = buildTmdbRequestUrl(endpoint, title, page, tmdbApiKey))
        .execute()
        .fromJson<TmdbSearchResponseDto>(
            errorMessage = "Couldn't parse response data!",
        )
 
    val forcedType = when (mediaTypeIndex) {
        FILTER_MOVIE -> FilmType.MOVIE
        FILTER_TV_SHOW -> FilmType.TV_SHOW
        else -> null
    }
 
    val results = dto.results
        .asSequence()
        .mapNotNull { it.toFilmSearchItemOrNull(providerId = providerId, forcedType = forcedType) }
        .toList()
 
    return PaginatedResponse(
        page = dto.page,
        results = results,
        hasNextPage = dto.page < dto.totalPages,
        totalPages = dto.totalPages,
    )
}

fromJson(...) and OkHttpClient.request(...) are provided by Core Stubs utilities:

Final output#

class TestSearchApi(
    private val client: OkHttpClient,
    private val providerId: String,
    private val tmdbApiKey: String,
) : SearchProviderApi {
    override val filters: FilterList = FilterList(
        TmdbMediaTypeFilters(),
    )
 
    override suspend fun search(
        title: String,
        page: Int,
        filters: FilterList,
    ): PaginatedResponse<FilmSearchItem> {
        val mediaTypeFilter = filters
            .filterIsInstance<TmdbMediaTypeFilters>()
            .firstOrNull()
        val mediaTypeIndex = mediaTypeFilter?.selectedIndex ?: FILTER_ALL
 
        val endpoint = when (mediaTypeIndex) {
            FILTER_ALL -> "https://api.themoviedb.org/3/search/multi"
            FILTER_TV_SHOW -> "https://api.themoviedb.org/3/search/tv"
            FILTER_MOVIE -> "https://api.themoviedb.org/3/search/movie"
            else -> "https://api.themoviedb.org/3/search/multi"
        }
 
        val dto = client
            .request(url = buildTmdbRequestUrl(endpoint, title, page, tmdbApiKey))
            .execute()
            .fromJson<TmdbSearchResponseDto>()
 
        val forcedType = when (mediaTypeIndex) {
            FILTER_MOVIE -> FilmType.MOVIE
            FILTER_TV_SHOW -> FilmType.TV_SHOW
            else -> null
        }
 
        val results = dto.results
            .asSequence()
            .mapNotNull { it.toFilmSearchItemOrNull(providerId = providerId, forcedType = forcedType) }
            .toList()
 
        return PaginatedResponse(
            page = dto.page,
            results = results,
            hasNextPage = dto.page < dto.totalPages,
            totalPages = dto.totalPages,
        )
    }
}