package com.suno.android.common_data.discover import androidx.paging.PagingSource import androidx.paging.PagingState import arrow.core.getOrElse import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.discover.DiscoverConstants.NETWORK_PAGE_SIZE import com.suno.android.common_data.mappers.clips.xAsSongListDataOrNull import com.suno.android.common_data.mappers.playlists.DiscoverFeedSection import com.suno.android.common_data.mappers.playlists.DiscoverPlaylistItem import com.suno.android.common_data.mappers.playlists.DiscoverStyleItem import com.suno.android.common_networking.extensions.ApiResult import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.remote.discover.DiscoverService import com.suno.android.common_networking.remote.entities.PostDiscoverRequest import com.suno.android.common_networking.remote.entities.PostDiscoverResponse data class DiscoverPagingKey( val currentSource: DiscoverPagingSource = DiscoverPagingSource.StandardFeed(), val history: List = emptyList(), ) sealed interface DiscoverPagingSource { val page: Int data class StandardFeed( override val page: Int = 0, ) : DiscoverPagingSource data class BasicGenreFeed( override val page: Int = 0, ) : DiscoverPagingSource } class DiscoverFeedPagingSource( loggerFactory: SunoLogger.Factory, private val discoverService: DiscoverService, ) : PagingSource() { private val logger = loggerFactory.create(this@DiscoverFeedPagingSource) /** * This method is used to refresh or reload a subset of the results. * * If called on initial load or after an error with the initial load, the results will be empty, * so getRefreshKey wil return null * * If results are populated, let's say paging key A is used to fetch the first page, * paging key B is used for the second, and paging key C is used for the third, like this: * | A | B | C | * * On pull to refresh, the first paging key, which is A, is invalidated so it becomes like this: * | x | B | C | * It will then use that anchor position to retrieve the closet key to regenerate the paging key, * which would be key B as page.nextKey, and decrement from it * * For another example, let's reset and say that we can refresh while anywhere on the screen. * The current is position is in the middle near paging key B. We refresh and invalidate key B. * | A | x | C | * It will then use that anchor position to retrieve the closet key to regenerate the paging key, * which we can first choose use page.prevKey which is A or page.nextKey which is C, both of * are available in this case. * * Returning null for now as we only supported PTR from the top of the screen, which works fine. * See [PagingSource.getRefreshKey] for more details */ override fun getRefreshKey( state: PagingState, ): DiscoverPagingKey? = null override suspend fun load( params: LoadParams, ): LoadResult { val key = params.key ?: DiscoverPagingKey() val response = fetchFeed(key).getOrElse { error -> val throwable = error.toThrowable() logger.e(throwable) return LoadResult.Error(throwable) } return try { val data = convertNetworkData(response) val nextKey = createNextKey( currentKey = key, response = response, ) LoadResult.Page( data = data, prevKey = params.key, nextKey = nextKey, ) } catch (exception: Exception) { logger.e(exception) LoadResult.Error(exception) } } private suspend fun fetchFeed( key: DiscoverPagingKey, ): ApiResult { val request = when (key.currentSource) { // Without section name, the endpoint doesn't support page, but passing it just to be safe is DiscoverPagingSource.StandardFeed -> PostDiscoverRequest( startIndex = key.currentSource.page * NETWORK_PAGE_SIZE, pageSize = NETWORK_PAGE_SIZE, page = key.currentSource.page, ) // With section name, the endpoint does support page with start_index as offset within page // and it ignores page size is DiscoverPagingSource.BasicGenreFeed -> PostDiscoverRequest( startIndex = 0, pageSize = NETWORK_PAGE_SIZE, page = key.currentSource.page, sectionName = SECTION_NAME_BASIC_GENRE_SONGS, ) } return discoverService.getFeed(request) } private fun convertNetworkData( response: PostDiscoverResponse, ): List { val supportedSections = mutableListOf() val unsupportedSections = mutableSetOf>() // TODO add field validation logic for when required fields are null or empty response.sections?.forEach { section -> when (section.sectionType) { SECTION_TYPE_PLAYLIST -> supportedSections.add( DiscoverFeedSection.PlaylistSection( id = section.id.orEmpty(), name = section.title.orEmpty(), link = section.link, items = section.mediaItemRemoteEntities?.mapNotNull { it.xAsSongListDataOrNull() } ?: emptyList(), ), ) SECTION_TYPE_PLAYLIST_LIST -> supportedSections.add( DiscoverFeedSection.PlaylistListSection( id = section.id.orEmpty(), name = section.title.orEmpty(), link = section.link, items = section.mediaItemRemoteEntities?.mapNotNull { item -> DiscoverPlaylistItem( id = item.id.orEmpty(), name = item.name.orEmpty(), description = item.description.orEmpty(), imageUrl = item.imageUrl, playlistClips = item.playlistClips?.mapNotNull { clip -> clip.clip?.xAsSongListDataOrNull() } ?: emptyList(), userDisplayName = item.userDisplayName.orEmpty(), userHandle = item.userHandle?.let(::UserHandle) ?: return@mapNotNull null, userAvatarImageUrl = item.userAvatarImageUrl?.let(::Url), upvoteCount = item.upvoteCount ?: 0, dislikeCount = item.dislikeCount ?: 0, flagCount = item.flagCount ?: 0, skipCount = item.skipCount ?: 0, playCount = item.playCount ?: 0, songCount = item.songCount ?: 0, ) } ?: emptyList(), styleType = section.styleType, ), ) SECTION_TYPE_STYLE_LIST -> { val mappedSection = DiscoverFeedSection.StyleListSection( id = section.id.orEmpty(), name = section.title.orEmpty(), link = section.link, items = section.mediaItemRemoteEntities?.mapNotNull { item -> // TODO add support for genre lists, which don't have redirect urls item.redirectUrl?.let { redirectUrl -> DiscoverStyleItem( id = item.id.orEmpty(), name = item.name.orEmpty(), imageUrl = item.imageUrl.orEmpty(), redirectUrl = redirectUrl, ) } } ?: emptyList(), ) if (mappedSection.items.isNotEmpty()) { supportedSections.add(mappedSection) } else { unsupportedSections.add( Pair( section.sectionType + "_FOR_GENRES", section.title ?: "NULL_TITLE", ), ) } } else -> unsupportedSections.add( Pair( section.sectionType ?: "NULL_TYPE", section.title ?: "NULL_TITLE", ), ) } } logger.println { "${this::class.simpleName} load feed unsupported sections: $unsupportedSections" } return supportedSections } /** * Determines next key to be fetched, goes in order of standard feed, then basic genre songs */ private fun createNextKey( currentKey: DiscoverPagingKey, response: PostDiscoverResponse, ): DiscoverPagingKey? { val sections = response.sections // Checking for empty results to see if there are more results to be fetched // Note that the response does have a field total_sections but it can be inaccurate // Note that also partial results can be returned due to a timeout issue on the server val hasReachedFeedEnd = sections.isNullOrEmpty() return when (val currentSource = currentKey.currentSource) { is DiscoverPagingSource.StandardFeed -> { if (hasReachedFeedEnd) { currentKey.copy( history = currentKey.history + currentSource, currentSource = DiscoverPagingSource.BasicGenreFeed(), ) } else { currentKey.copy( currentSource = currentSource.copy(currentSource.page + 1), ) } } is DiscoverPagingSource.BasicGenreFeed -> { if (hasReachedFeedEnd) { null // no more sources to use } else { currentKey.copy( currentSource = currentSource.copy(currentSource.page + 1), ) } } } } companion object { private const val SECTION_TYPE_PLAYLIST = "playlist" private const val SECTION_TYPE_PLAYLIST_LIST = "playlist_list" private const val SECTION_TYPE_STYLE_LIST = "style_list" private const val SECTION_NAME_BASIC_GENRE_SONGS = "basic_genre_songs" } }