diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 1d54f256..134e4903 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -1,29 +1,40 @@ package com.github.damontecres.wholphin.services.tvprovider +import android.content.ContentUris import android.content.Context import android.content.Intent import android.database.Cursor +import android.net.Uri +import androidx.core.content.edit import androidx.core.net.toUri import androidx.datastore.core.DataStore 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.Channels import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms import androidx.tvprovider.media.tv.WatchNextProgram import androidx.work.CoroutineWorker import androidx.work.WorkerParameters 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.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.ui.SlimItemFields import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.api.client.ApiClient 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.ImageType +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -77,6 +88,21 @@ class TvProviderWorker val currentItems = getCurrentTvChannelNextUp() 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 = currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } @@ -84,7 +110,7 @@ class TvProviderWorker val userRemovedIds = userRemoved.map { it.internalProviderId } Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) 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 }) // 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) + addOtherChannels() + Timber.d("Completed successfully") } catch (_: ApiClientException) { return Result.retry() @@ -226,6 +254,142 @@ class TvProviderWorker ) }.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 { const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" const val PARAM_USER_ID = "userId"