Skip to content

Commit

Permalink
Merge pull request #681 from Automattic/update/automotive-podcast-search
Browse files Browse the repository at this point in the history
Improving Automotive search
  • Loading branch information
ashiagr authored Jan 9, 2023
2 parents eeeebe0 + a277b32 commit 05cfdc7
Show file tree
Hide file tree
Showing 7 changed files with 70 additions and 83 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
([#583](https://github.com/Automattic/pocket-casts-android/pull/583)).
* Add categories to recommendations screen
([#675](https://github.com/Automattic/pocket-casts-android/pull/675)).
* Improved the Android Automotive search
([#681](https://github.com/Automattic/pocket-casts-android/pull/681)).

7.29
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,6 @@ abstract class EpisodeDao {
@Query("SELECT * FROM episodes WHERE podcast_id = :podcastUuid ORDER BY published_date DESC, added_date DESC LIMIT 1")
abstract fun findLatestRx(podcastUuid: String): Maybe<Episode>

@Query("SELECT * FROM episodes WHERE podcast_id = :podcastUuid AND playing_status != 2 ORDER BY published_date DESC LIMIT 10")
abstract fun findPodcastEpisodesForMediaBrowserSearch(podcastUuid: String): List<Episode>

@Query("SELECT * FROM episodes WHERE (download_task_id IS NOT NULL OR episode_status == :downloadEpisodeStatusEnum OR (episode_status == :failedEpisodeStatusEnum AND last_download_attempt_date > :failedDownloadCutoff AND archived == 0)) ORDER BY last_download_attempt_date DESC")
abstract fun observeDownloadingEpisodesIncludingFailed(failedDownloadCutoff: Long, failedEpisodeStatusEnum: EpisodeStatusEnum = EpisodeStatusEnum.DOWNLOAD_FAILED, downloadEpisodeStatusEnum: EpisodeStatusEnum = EpisodeStatusEnum.DOWNLOADED): Flowable<List<Episode>>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ class MediaSessionManager(
* ‘Listen to [filter name] in Pocket Casts’
* ‘Listen to Up Next in Pocket Casts’
* ‘Play Up Next in Pocket Casts’
* ‘Play New Releasees Next in Pocket Casts’
* ‘Play New Releases Next in Pocket Casts’
*/
private fun performPlayFromSearch(searchTerm: String?) {
Timber.d("performSearch $searchTerm")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import au.com.shiftyjelly.pocketcasts.models.entity.Episode
import au.com.shiftyjelly.pocketcasts.models.entity.Podcast
import au.com.shiftyjelly.pocketcasts.models.to.FolderItem
import au.com.shiftyjelly.pocketcasts.models.to.SubscriptionStatus
import au.com.shiftyjelly.pocketcasts.models.type.PodcastsSortType
import au.com.shiftyjelly.pocketcasts.preferences.Settings
import au.com.shiftyjelly.pocketcasts.repositories.extensions.id
import au.com.shiftyjelly.pocketcasts.repositories.notification.NotificationDrawer
Expand Down Expand Up @@ -66,7 +67,6 @@ const val RECENT_ROOT = "__RECENT__"
const val SUGGESTED_ROOT = "__SUGGESTED__"
const val FOLDER_ROOT_PREFIX = "__FOLDER__"

private const val MAX_SEARCH_RESULT_COUNT = 10
private const val MEDIA_SEARCH_SUPPORTED = "android.media.browse.SEARCH_SUPPORTED"
private const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"

Expand Down Expand Up @@ -569,86 +569,40 @@ open class PlaybackService : MediaBrowserServiceCompat(), CoroutineScope {
override fun onSearch(query: String, extras: Bundle?, result: Result<List<MediaBrowserCompat.MediaItem>>) {
result.detach()
launch {
result.sendResult(performSearch(query))
result.sendResult(podcastSearch(query))
}
}

/**
* Voice search.
* "Play Up Next"
* "Play Material"
* "Play Material in Pocket Casts"
* Search for local and remote podcasts.
* Returning an empty list displays "No media available for browsing here"
* Returning null displays "Something went wrong". There is no way to display our own error message.
*/
private fun performSearch(searchTerm: String): List<MediaBrowserCompat.MediaItem> {
val searchResults = mutableListOf<MediaBrowserCompat.MediaItem>()

val query = searchTerm.trim { it <= ' ' }.lowercase()
if (query.startsWith("up next") || query.startsWith("next episode") || query.startsWith("next podcast")) {
val upNextEpisodes = playbackManager.upNextQueue.queueEpisodes.take(10)
for (episode in upNextEpisodes) {
if (episode is Episode) {
podcastManager.findPodcastByUuid(episode.podcastUuid)?.let { parentPodcast ->
addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = episode, parentPodcast = parentPodcast), searchResults)
}
} else {
// TODO: UserEpisode
}
}

return searchResults
}

val options = MediaSessionManager.calculateSearchQueryOptions(query)
for (option in options) {
val matchingPodcast = podcastManager.searchPodcastByTitle(option) ?: continue
val latestEpisodeUuid = matchingPodcast.latestEpisodeUuid
val latestEpisode = if (latestEpisodeUuid == null) null else episodeManager.findByUuid(latestEpisodeUuid)

if (latestEpisode != null) {
addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = latestEpisode, parentPodcast = matchingPodcast), searchResults)
if (searchResults.size >= MAX_SEARCH_RESULT_COUNT) {
return searchResults
}
}

// add a few of the latest episodes from this podcast
episodeManager.findPodcastEpisodesForMediaBrowserSearch(matchingPodcast.uuid).forEach { episode ->
addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = episode, parentPodcast = matchingPodcast), searchResults)
}
if (searchResults.size >= MAX_SEARCH_RESULT_COUNT) {
return searchResults
}
}

for (option in options) {
episodeManager.findFirstBySearchQuery(option)?.let { firstMatch ->
podcastManager.findPodcastByUuid(firstMatch.podcastUuid)?.let { parentPodcast ->
addToResultsIfNotExists(AutoConverter.convertEpisodeToMediaItem(context = this, episode = firstMatch, parentPodcast = parentPodcast), searchResults)
}

if (searchResults.size >= MAX_SEARCH_RESULT_COUNT) {
return searchResults
}
private suspend fun podcastSearch(term: String): List<MediaBrowserCompat.MediaItem>? {
val termCleaned = term.trim()
// search for local podcasts
val localPodcasts = podcastManager.findSubscribedNoOrder()
.filter { it.title.contains(termCleaned, ignoreCase = true) || it.author.contains(termCleaned, ignoreCase = true) }
.sortedBy { PodcastsSortType.cleanStringForSort(it.title) }
// search for podcasts on the server
val serverPodcasts = try {
// only search the server if the term is over one character long
if (termCleaned.length <= 1) {
emptyList()
} else {
serverManager.searchForPodcastsSuspend(searchTerm = term, resources = resources).searchResults
}
}

if (searchResults.isEmpty()) { // Try the server
serverManager.searchForPodcastsRx(query).blockingGet().searchResults.forEach { podcast ->
val mediaItem = convertPodcastToMediaItem(podcast = podcast, context = this)
addToResultsIfNotExists(mediaItem, searchResults)
} catch (ex: Exception) {
Timber.e(ex)
// display the error message when the server call fails only if there is no local podcasts to display
if (localPodcasts.isEmpty()) {
return null
}
emptyList()
}

return searchResults
}

private fun addToResultsIfNotExists(item: MediaBrowserCompat.MediaItem?, searchResults: MutableList<MediaBrowserCompat.MediaItem>) {
item ?: return

for (existingItem in searchResults) {
if (existingItem.mediaId == item.mediaId) return
}

searchResults.add(item)
// merge the local and remote podcasts
val podcasts = (localPodcasts + serverPodcasts).distinctBy { it.uuid }
// convert podcasts to the media browser format
return podcasts.mapNotNull { podcast -> convertPodcastToMediaItem(context = this, podcast = podcast) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ interface EpisodeManager {
fun findLatestUnfinishedEpisodeByPodcast(podcast: Podcast): Episode?
fun findLatestEpisodeToPlay(): Episode?
fun observeEpisodesByPodcastOrderedRx(podcast: Podcast): Flowable<List<Episode>>
fun findPodcastEpisodesForMediaBrowserSearch(podcastUuid: String): List<Episode>
fun observeEpisodesWhere(queryAfterWhere: String): Flowable<List<Episode>>
fun observeDownloadingEpisodes(): LiveData<List<Episode>>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,6 @@ class EpisodeManagerImpl @Inject constructor(
}
}

override fun findPodcastEpisodesForMediaBrowserSearch(podcastUuid: String): List<Episode> {
return episodeDao.findPodcastEpisodesForMediaBrowserSearch(podcastUuid)
}

override fun findEpisodesWhere(queryAfterWhere: String): List<Episode> {
return episodeDao.findEpisodes(SimpleSQLiteQuery("SELECT episodes.* FROM episodes JOIN podcasts ON episodes.podcast_id = podcasts.uuid WHERE podcasts.subscribed = 1 AND $queryAfterWhere"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package au.com.shiftyjelly.pocketcasts.servers

import android.content.res.Resources
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import au.com.shiftyjelly.pocketcasts.localization.R
import au.com.shiftyjelly.pocketcasts.localization.helper.LocaliseHelper
import au.com.shiftyjelly.pocketcasts.models.entity.Podcast
import au.com.shiftyjelly.pocketcasts.models.to.Share
import au.com.shiftyjelly.pocketcasts.preferences.Settings
Expand All @@ -13,6 +16,7 @@ import au.com.shiftyjelly.pocketcasts.servers.model.AuthResultModel
import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer
import io.reactivex.Single
import io.reactivex.SingleEmitter
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.FormBody
Expand All @@ -28,6 +32,8 @@ import java.io.IOException
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

@Singleton
open class ServerManager @Inject constructor(
Expand Down Expand Up @@ -119,6 +125,39 @@ open class ServerManager @Inject constructor(
)
}

suspend fun searchForPodcastsSuspend(searchTerm: String, resources: Resources): PodcastSearch {
if (searchTerm.isEmpty()) {
return PodcastSearch()
}
return suspendCancellableCoroutine { continuation ->
searchForPodcasts(
searchTerm = searchTerm,
callback = object : ServerCallback<PodcastSearch> {
override fun dataReturned(result: PodcastSearch?) {
if (result == null) {
continuation.resumeWithException(Exception(resources.getString(R.string.error_search_failed)))
} else {
continuation.resume(result)
}
}

override fun onFailed(
errorCode: Int,
userMessage: String?,
serverMessageId: String?,
serverMessage: String?,
throwable: Throwable?,
) {
val message = LocaliseHelper.serverMessageIdToMessage(serverMessageId, resources::getString)
?: userMessage
?: resources.getString(R.string.error_search_failed)
continuation.resumeWithException(throwable ?: Exception(message))
}
}
)
}
}

open fun searchForPodcastsRx(searchTerm: String): Single<PodcastSearch> {
return convertCallToRx { emitter ->
searchForPodcasts(searchTerm, getRxServerCallback(emitter))
Expand Down

0 comments on commit 05cfdc7

Please sign in to comment.