Fix duplicates being added to Android TV watch next channel (#588)

## Description
Fixes the logic for determining which items need to be added to the
watch next channel row

Also adds some code to clean up existing duplicates. This code can be
removed around the next release.

Adds a channel to the home screen for recently added media. This is
needed to ensure full integration with the OS to allow toggling off any
of the Wholphin channels including watch next.

### Related issues
#372 introduced the bug
Fixes #587
This commit is contained in:
Ray 2025-12-28 17:14:14 -05:00 committed by GitHub
parent ad64f9a136
commit aa194f5a2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,29 +1,40 @@
package com.github.damontecres.wholphin.services.tvprovider package com.github.damontecres.wholphin.services.tvprovider
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
import android.net.Uri
import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.tvprovider.media.tv.Channel
import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.TvContractCompat
import androidx.tvprovider.media.tv.TvContractCompat.Channels
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
import androidx.tvprovider.media.tv.WatchNextProgram import androidx.tvprovider.media.tv.WatchNextProgram
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.MainActivity
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.ui.SlimItemFields
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.exception.ApiClientException
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
@ -77,6 +88,21 @@ class TvProviderWorker
val currentItems = getCurrentTvChannelNextUp() val currentItems = getCurrentTvChannelNextUp()
val currentItemIds = currentItems.map { it.internalProviderId } val currentItemIds = currentItems.map { it.internalProviderId }
// TODO Remove after v0.3.10 release
// This cleans up duplicates added to the watch next due a bug in https://github.com/damontecres/Wholphin/pull/372
currentItems.groupBy { it.internalProviderId }.forEach { (id, items) ->
if (items.size > 1) {
Timber.v("Duplicate ID %s", id)
items
.subList(1, items.size)
.map { TvContractCompat.buildWatchNextProgramUri(it.id) }
.forEach {
context.contentResolver.delete(it, null, null)
}
}
}
// End temporary clean up
val toRemove = val toRemove =
currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds }
@ -84,7 +110,7 @@ class TvProviderWorker
val userRemovedIds = userRemoved.map { it.internalProviderId } val userRemovedIds = userRemoved.map { it.internalProviderId }
Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId })
val toAdd = val toAdd =
potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds } potentialItemsToAdd.filter { it.id.toString() !in currentItemIds && it.id.toString() !in userRemovedIds }
Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id }) Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id })
// Remove existing items if they are no longer in the next up from server // Remove existing items if they are no longer in the next up from server
@ -104,6 +130,8 @@ class TvProviderWorker
) )
Timber.v("Added %s", addedCount) Timber.v("Added %s", addedCount)
addOtherChannels()
Timber.d("Completed successfully") Timber.d("Completed successfully")
} catch (_: ApiClientException) { } catch (_: ApiClientException) {
return Result.retry() return Result.retry()
@ -226,6 +254,142 @@ class TvProviderWorker
) )
}.build() }.build()
private suspend fun addOtherChannels() {
val preferences = preferences.data.firstOrNull()
val channelsPrefs = context.getSharedPreferences("channels", Context.MODE_PRIVATE)
val latest =
api.userLibraryApi
.getLatestMedia(
GetLatestMediaRequest(
fields = SlimItemFields,
imageTypeLimit = 1,
parentId = null,
groupItems = true,
limit =
preferences?.homePagePreferences?.maxItemsPerRow
?: AppPreference.HomePageItems.defaultValue.toInt(),
isPlayed = null, // Server will handle user's preference
),
).content
.map { BaseItem(it, true) }
var channelId = channelsPrefs.getString("latest", null)?.toUri()
if (channelId == null) {
Timber.d("channelId for latest is null")
val channel =
Channel
.Builder()
.apply {
setDisplayName(context.getString(R.string.recently_added))
setType(Channels.TYPE_PREVIEW)
setAppLinkIntent(Intent(context, MainActivity::class.java))
}.build()
channelId =
context.contentResolver.insert(
Channels.CONTENT_URI,
channel.toContentValues(),
)
if (channelId != null) {
channelsPrefs.edit(true) {
putString("latest", channelId.toString())
}
TvContractCompat.requestChannelBrowsable(
context,
ContentUris.parseId(channelId),
)
} else {
Timber.w("channelId was null")
throw IllegalStateException("channelId was null")
}
}
val programs = latest.map { convert(channelId, it).toContentValues() }.toTypedArray()
// Delete & replace
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
val count =
context.contentResolver.bulkInsert(
TvContractCompat.PreviewPrograms.CONTENT_URI,
programs,
)
Timber.v("Inserted $count records")
}
private fun convert(
channelId: Uri,
item: BaseItem,
): PreviewProgram =
PreviewProgram
.Builder()
.apply {
setChannelId(ContentUris.parseId(channelId))
val dto = item.data
setInternalProviderId(item.id.toString())
val type =
when (item.type) {
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
BaseItemKind.SEASON -> WatchNextPrograms.TYPE_TV_SEASON
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
else -> WatchNextPrograms.TYPE_CLIP
}
setType(type)
val resumePosition = dto.userData?.playbackPositionTicks?.ticks
if (resumePosition != null && resumePosition >= 2.minutes) {
// https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content
setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt())
}
dto.runTimeTicks
?.ticks
?.inWholeMilliseconds
?.toInt()
?.let(::setDurationMillis)
setTitle(item.title)
setDescription(dto.overview)
if (item.type == BaseItemKind.EPISODE) {
setEpisodeTitle(item.name)
dto.indexNumber?.let(::setEpisodeNumber)
dto.parentIndexNumber?.let(::setSeasonNumber)
}
setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9)
setPosterArtUri(imageUrlService.getItemImageUrl(item, ImageType.THUMB)!!.toUri())
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
MainActivity.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
MainActivity.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
MainActivity.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
MainActivity.INTENT_EPISODE_NUMBER,
it,
)
}
}
},
)
}.build()
companion object { companion object {
const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker"
const val PARAM_USER_ID = "userId" const val PARAM_USER_ID = "userId"