crunchyroll support #49

Merged
Seil0 merged 42 commits from feature/crunchyroll into develop 2022-03-19 20:42:54 +01:00
6 changed files with 125 additions and 78 deletions
Showing only changes of commit 4fed3ddb91 - Show all commits

View File

@ -14,7 +14,7 @@ android {
minSdkVersion 23 minSdkVersion 23
targetSdkVersion 30 targetSdkVersion 30
versionCode 4200 //00.04.200 versionCode 4200 //00.04.200
versionName "1.0.0-alpha2" versionName "1.0.0-alpha3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime() resValue "string", "build_time", buildTime()

View File

@ -295,6 +295,23 @@ object Crunchyroll {
} ?: NoneSeries } ?: NoneSeries
} }
/**
* TODO
*/
suspend fun upNextSeries(seriesId: String): UpNextSeriesItem {
val upNextSeriesEndpoint = "/content/v1/up_next_series"
val parameters = listOf(
"series_id" to seriesId,
"locale" to locale
)
val result = request(upNextSeriesEndpoint, parameters)
return result.component1()?.obj()?.let {
json.decodeFromString(it.toString())
} ?: NoneUpNextSeriesItem
}
suspend fun seasons(seriesId: String): Seasons { suspend fun seasons(seriesId: String): Seasons {
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons" val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
val parameters = listOf( val parameters = listOf(
@ -404,6 +421,18 @@ object Crunchyroll {
} ?: emptyMap() } ?: emptyMap()
} }
suspend fun postPlayheads(episodeId: String, playhead: Int) {
val playheadsEndpoint = "/content/v1/playheads/$accountID"
val parameters = listOf("locale" to locale)
val json = buildJsonObject {
put("content_id", episodeId)
put("playhead", playhead)
}
requestPost(playheadsEndpoint, parameters, json.toString())
}
/** /**
* Listing functions: watchlist (list), up_next_account * Listing functions: watchlist (list), up_next_account
*/ */

View File

@ -31,6 +31,14 @@ typealias DiscSeasonList = Collection<SeasonListItem>
typealias Watchlist = Collection<Item> typealias Watchlist = Collection<Item>
typealias ContinueWatchingList = Collection<ContinueWatchingItem> typealias ContinueWatchingList = Collection<ContinueWatchingItem>
@Serializable
data class UpNextSeriesItem(
val playhead: Int,
val fully_watched: Boolean,
val never_watched: Boolean,
val panel: EpisodePanel,
)
/** /**
* panel data classes * panel data classes
*/ */
@ -73,17 +81,18 @@ data class SeasonListLocalization(
/** /**
* continue_watching_item data classes * continue_watching_item data classes
*/ */
@Serializable @Serializable
data class ContinueWatchingItem( data class ContinueWatchingItem(
@SerialName("panel") val panel: EpisodePanel, @SerialName("panel") val panel: EpisodePanel,
@SerialName("new") val new: Boolean, @SerialName("new") val new: Boolean,
@SerialName("new_content") val newContent: Boolean, @SerialName("new_content") val newContent: Boolean,
// not present in up_next_account's continue_watching_item // not present in up_next_account -> continue_watching_item
// @SerialName("is_favorite") val isFavorite: Boolean, // @SerialName("is_favorite") val isFavorite: Boolean,
// @SerialName("never_watched") val neverWatched: Boolean, // @SerialName("never_watched") val neverWatched: Boolean,
// @SerialName("completion_status") val completionStatus: Boolean, // @SerialName("completion_status") val completionStatus: Boolean,
@SerialName("playhead") val playhead: Int, @SerialName("playhead") val playhead: Int,
// not present in watchlist -> continue_watching_item
// @SerialName("fully_watched") val fullyWatched: Boolean,
) )
// EpisodePanel is used in ContinueWatchingItem // EpisodePanel is used in ContinueWatchingItem
@ -94,24 +103,31 @@ data class EpisodePanel(
@SerialName("type") val type: String, @SerialName("type") val type: String,
@SerialName("channel_id") val channelId: String, @SerialName("channel_id") val channelId: String,
@SerialName("description") val description: String, @SerialName("description") val description: String,
@SerialName("images") val images: Thumbnail,
@SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata, @SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata,
@SerialName("images") val images: Thumbnail,
@SerialName("playback") val playback: String,
) )
@Serializable @Serializable
data class EpisodeMetadata( data class EpisodeMetadata(
@SerialName("duration_ms") val durationMs: Int,
@SerialName("season_id") val seasonId: String,
@SerialName("series_id") val seriesId: String, @SerialName("series_id") val seriesId: String,
@SerialName("series_title") val seriesTitle: String, @SerialName("series_title") val seriesTitle: String,
) )
val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList())) val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList()))
val NoneEpisodeMetadata = EpisodeMetadata(0, "", "", "")
val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "")
val NoneCollection = Collection<Item>(0, emptyList()) val NoneCollection = Collection<Item>(0, emptyList())
val NoneSearchResult = SearchResult(0, emptyList()) val NoneSearchResult = SearchResult(0, emptyList())
val NoneBrowseResult = BrowseResult(0, emptyList()) val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList()) val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneWatchlist = Watchlist(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList()) val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneUpNextSeriesItem =UpNextSeriesItem(0, false, false, NoneEpisodePanel)
/** /**
* Series data type * Series data type
*/ */
@ -163,7 +179,7 @@ data class Season(
@SerialName("is_dubbed") val isDubbed: Boolean, @SerialName("is_dubbed") val isDubbed: Boolean,
) )
val NoneSeasons = Seasons(0, listOf()) val NoneSeasons = Seasons(0, emptyList())
val NoneSeason = Season("", "", "", 0, isSubbed = false, isDubbed = false) val NoneSeason = Season("", "", "", 0, isSubbed = false, isDubbed = false)

View File

@ -17,10 +17,10 @@ import com.bumptech.glide.request.RequestOptions
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import jp.wasabeef.glide.transformations.BlurTransformation import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.mosad.teapod.R import org.mosad.teapod.R
import org.mosad.teapod.databinding.FragmentMediaBinding import org.mosad.teapod.databinding.FragmentMediaBinding
import org.mosad.teapod.parser.crunchyroll.NoneUpNextSeriesItem
import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.ui.activity.main.MainActivity
import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel
import org.mosad.teapod.util.tmdb.TMDBApiController import org.mosad.teapod.util.tmdb.TMDBApiController
@ -37,19 +37,23 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private lateinit var binding: FragmentMediaBinding private lateinit var binding: FragmentMediaBinding
private lateinit var pagerAdapter: FragmentStateAdapter private lateinit var pagerAdapter: FragmentStateAdapter
private val model: MediaFragmentViewModel by activityViewModels()
private val fragments = arrayListOf<Fragment>() private val fragments = arrayListOf<Fragment>()
private var watchlistJobRunning = false private var watchlistJobRunning = false
private var runOnResume = false
private val model: MediaFragmentViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentMediaBinding.inflate(inflater, container, false) binding = FragmentMediaBinding.inflate(inflater, container, false)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
println("onViewCreated")
binding.frameLoading.visibility = View.VISIBLE binding.frameLoading.visibility = View.VISIBLE
// tab layout and pager // tab layout and pager
@ -77,11 +81,21 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// update the next ep text if there is one, since it may have changed if (runOnResume) {
// TODO reimplement lifecycleScope.launch {
// if (model.media.getEpisodeById(model.nextEpisodeId).title.isNotEmpty()) { model.updateOnResume()
// binding.textTitle.text = model.media.getEpisodeById(model.nextEpisodeId).title
// } if (model.upNextSeries != NoneUpNextSeriesItem) {
binding.textTitle.text = model.upNextSeries.panel.title
}
if (fragments.elementAtOrNull(0) is MediaFragmentEpisodes) {
(fragments[0] as MediaFragmentEpisodes).updateWatchedState()
}
}
} else {
runOnResume = true
}
} }
/** /**
@ -102,15 +116,17 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
.apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3))) .apply(RequestOptions.bitmapTransform(BlurTransformation(20, 3)))
.into(binding.imageBackdrop) .into(binding.imageBackdrop)
binding.textTitle.text = seriesCrunchy.title
binding.textOverview.text = seriesCrunchy.description
binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull()
binding.textYear.text = when(tmdbResult) { binding.textYear.text = when(tmdbResult) {
is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate.substring(0, 4) is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate.substring(0, 4)
is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4) is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4)
else -> "" else -> ""
} }
binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull()
binding.textTitle.text = if (upNextSeries != NoneUpNextSeriesItem) {
upNextSeries.panel.title
} else seriesCrunchy.title
binding.textOverview.text = seriesCrunchy.description
// set "watchlist" indicator // set "watchlist" indicator
val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24
@ -127,8 +143,7 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
pagerAdapter.notifyItemInserted(fragments.indexOf(it)) pagerAdapter.notifyItemInserted(fragments.indexOf(it))
} }
// TODO reimplement via tmdb/metaDB // specific gui (via tmdb)
// specific gui
when (tmdbResult) { when (tmdbResult) {
is TMDBTVShow -> { is TMDBTVShow -> {
// episodes count // episodes count
@ -156,40 +171,6 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
} }
} }
// if (mediaCrunchy.type == MediaType.TVSHOW.str) {
// // TODO get next episode
//// nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId
//// ?: media.playlist.first().mediaId
//
// // TODO title is the next episodes title
//// binding.textTitle.text = media.getEpisodeById(nextEpisodeId).title
//
// // episodes count
// binding.textEpisodesOrRuntime.text = resources.getQuantityString(
// R.plurals.text_episodes_count,
// episodesCrunchy.total,
// episodesCrunchy.total
// )
//
// // episodes
// MediaFragmentEpisodes().also {
// fragments.add(it)
// pagerAdapter.notifyItemInserted(fragments.indexOf(it))
// }
// } else if (media.type == MediaType.MOVIE) {
// val tmdbMovie = (tmdbResult as TMDBMovie?)
//
// if (tmdbMovie?.runtime != null) {
// binding.textEpisodesOrRuntime.text = resources.getQuantityString(
// R.plurals.text_runtime,
// tmdbMovie.runtime,
// tmdbMovie.runtime
// )
// } else {
// binding.textEpisodesOrRuntime.visibility = View.GONE
// }
// }
// if has similar titles // if has similar titles
// TODO reimplement // TODO reimplement
// if (media.similar.isNotEmpty()) { // if (media.similar.isNotEmpty()) {
@ -210,12 +191,9 @@ class MediaFragment(private val mediaIdStr: String) : Fragment() {
private fun initActions() = with(model) { private fun initActions() = with(model) {
binding.buttonPlay.setOnClickListener { binding.buttonPlay.setOnClickListener {
// TODO reimplement if (upNextSeries != NoneUpNextSeriesItem) {
// when (media.type) { playEpisode(upNextSeries.panel.episodeMetadata.seasonId, upNextSeries.panel.id)
// MediaType.MOVIE -> playEpisode(media.playlist.first().mediaId) }
// MediaType.TVSHOW -> playEpisode(nextEpisodeId)
// else -> Log.e(javaClass.name, "Wrong Type: ${media.type}")
// }
} }
// add or remove media from myList // add or remove media from myList

View File

@ -60,6 +60,7 @@ class MediaFragmentEpisodes : Fragment() {
// if adapterRecEpisodes is initialized, update the watched state for the episodes // if adapterRecEpisodes is initialized, update the watched state for the episodes
if (this::adapterRecEpisodes.isInitialized) { if (this::adapterRecEpisodes.isInitialized) {
// TODO reimplement, if needed // TODO reimplement, if needed
// update via playheads?
// model.media.playlist.forEachIndexed { index, episodeInfo -> // model.media.playlist.forEachIndexed { index, episodeInfo ->
// adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index) // adapterRecEpisodes.updateWatchedState(episodeInfo.watched, index)
// } // }
@ -67,6 +68,11 @@ class MediaFragmentEpisodes : Fragment() {
} }
} }
fun updateWatchedState() {
// TODO update watched state of all episodes
// use a mutable list for playheads and notify dataset changed
}
private fun showSeasonSelection(v: View) { private fun showSeasonSelection(v: View) {
// TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus // TODO replace with Exposed dropdown menu: https://material.io/components/menus/android#exposed-dropdown-menus
val popup = PopupMenu(requireContext(), v) val popup = PopupMenu(requireContext(), v)

View File

@ -3,9 +3,9 @@ package org.mosad.teapod.ui.activity.main.viewmodel
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import org.mosad.teapod.parser.crunchyroll.* import org.mosad.teapod.parser.crunchyroll.*
import org.mosad.teapod.preferences.Preferences import org.mosad.teapod.preferences.Preferences
import org.mosad.teapod.util.DataTypes.MediaType import org.mosad.teapod.util.DataTypes.MediaType
@ -29,9 +29,12 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
var episodesCrunchy = NoneEpisodes var episodesCrunchy = NoneEpisodes
internal set internal set
val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates) val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates)
// additional media info
var currentPlayheads: PlayheadsMap = emptyMap() var currentPlayheads: PlayheadsMap = emptyMap()
var isWatchlist = false var isWatchlist = false
internal set internal set
var upNextSeries = NoneUpNextSeriesItem
// TMDB stuff // TMDB stuff
var mediaType = MediaType.OTHER var mediaType = MediaType.OTHER
@ -52,35 +55,40 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
listOf( listOf(
viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) },
viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) }, viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) },
viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) } viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) },
viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(crunchyId) }
).joinAll() ).joinAll()
// println("series: $seriesCrunchy")
println("series: $seriesCrunchy") // println("seasons: $seasonsCrunchy")
println("seasons: $seasonsCrunchy") println(upNextSeries)
// TODO load episodes, metaDB and tmdb in parallel
// load the preferred season (preferred language, language per season, not per stream) // load the preferred season (preferred language, language per season, not per stream)
currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal) currentSeasonCrunchy = seasonsCrunchy.getPreferredSeason(Preferences.preferredLocal)
episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id)
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
listOf(
viewModelScope.launch { episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) },
viewModelScope.launch { mediaMeta = null }, // TODO metaDB
).joinAll()
// println("episodes: $episodesCrunchy")
currentEpisodesCrunchy.clear() currentEpisodesCrunchy.clear()
currentEpisodesCrunchy.addAll(episodesCrunchy.items) currentEpisodesCrunchy.addAll(episodesCrunchy.items)
println("episodes: $episodesCrunchy")
// get playheads (including fully watched state)
val episodeIDs = episodesCrunchy.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
// set media type // set media type
mediaType = episodesCrunchy.items.firstOrNull()?.let { mediaType = episodesCrunchy.items.firstOrNull()?.let {
if (it.episodeNumber != null) MediaType.TVSHOW else MediaType.MOVIE if (it.episodeNumber != null) MediaType.TVSHOW else MediaType.MOVIE
} ?: MediaType.OTHER } ?: MediaType.OTHER
// TODO check if metaDB knows the title // load playheads and tmdb in parallel
mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media listOf(
viewModelScope.launch {
// use tmdb search to get media info // get playheads (including fully watched state)
loadTmdbInfo() val episodeIDs = episodesCrunchy.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
},
viewModelScope.launch { loadTmdbInfo() } // use tmdb search to get media info
).joinAll()
} }
/** /**
@ -143,6 +151,16 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
} }
} }
suspend fun updateOnResume(): List<Job> {
return listOf(
viewModelScope.launch {
val episodeIDs = episodesCrunchy.items.map { it.id }
currentPlayheads = Crunchyroll.playheads(episodeIDs)
},
viewModelScope.launch { upNextSeries = Crunchyroll.upNextSeries(seriesCrunchy.id) }
)
}
/** /**
* get the next episode based on episodeId * get the next episode based on episodeId
* if no matching is found, use first episode * if no matching is found, use first episode