diff --git a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt index 47ec698..c1171d0 100644 --- a/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt +++ b/app/src/main/java/org/mosad/teapod/parser/crunchyroll/Crunchyroll.kt @@ -4,12 +4,15 @@ import android.util.Log import com.github.kittinunf.fuel.Fuel import com.github.kittinunf.fuel.core.FuelError import com.github.kittinunf.fuel.core.Parameters +import com.github.kittinunf.fuel.core.extensions.jsonBody import com.github.kittinunf.fuel.json.FuelJson import com.github.kittinunf.fuel.json.responseJson import com.github.kittinunf.result.Result import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.mosad.teapod.preferences.Preferences import java.util.* @@ -22,6 +25,8 @@ object Crunchyroll { private var accessToken = "" private var tokenType = "" + private var accountID = "" + private var policy = "" private var signature = "" private var keyPairID = "" @@ -91,6 +96,42 @@ object Crunchyroll { } } + private suspend fun requestPost( + endpoint: String, + params: Parameters = listOf(), + body: String + ) = coroutineScope { + val path = "$baseUrl$endpoint" + + // TODO before sending a request, make sure the accessToken is not expired + withContext(Dispatchers.IO) { + Fuel.post(path, params) + .header("Authorization", "$tokenType $accessToken") + .jsonBody(body) + .response() // without a response, crunchy doesn't accept the request + } + } + + private suspend fun requestDelete( + endpoint: String, + params: Parameters = listOf(), + url: String = "" + ) = coroutineScope { + val path = if (url.isEmpty()) "$baseUrl$endpoint" else url + + // TODO before sending a request, make sure the accessToken is not expired + withContext(Dispatchers.IO) { + Fuel.delete(path, params) + .header("Authorization", "$tokenType $accessToken") + .response() // without a response, crunchy doesn't accept the request + } + } + + /** + * Basic functions: index, account + * Needed for other functions to work properly! + */ + /** * Retrieve the identifiers necessary for streaming. If the identifiers are * retrieved, set the corresponding global var. The identifiers are valid for 24h. @@ -110,6 +151,24 @@ object Crunchyroll { println("keyPairID: $keyPairID") } + /** + * Retrieve the account id and set the corresponding global var. + * The account id is needed for other calls. + * + * This must be execute on every start for teapod to work properly! + */ + suspend fun account() { + val indexEndpoint = "/accounts/v1/me" + val result = request(indexEndpoint) + + result.component1()?.obj()?.let { + accountID = it.get("account_id").toString() + } + } + + /** + * Main media functions: browse, search, series, season, episodes, playback + */ // TODO locale de-DE, categories /** @@ -213,4 +272,59 @@ object Crunchyroll { } ?: NonePlayback } + /** + * Additional media functions: watchlist, playhead + */ + + /** + * Check if a media is in the user's watchlist. + * + * @param seriesId The crunchyroll series id of the media to check + * @return Boolean: ture if it was found, else false + */ + suspend fun isWatchlist(seriesId: String): Boolean { + val watchlistEndpoint = "/content/v1/watchlist/$accountID/$seriesId" + val parameters = listOf("locale" to locale) + + val result = request(watchlistEndpoint, parameters) + // if needed implement parsing + + return result.component1()?.obj()?.has(seriesId) ?: false + } + + /** + * Add a media to the user's watchlist. + * + * @param seriesId The crunchyroll series id of the media to check + */ + suspend fun postWatchlist(seriesId: String) { + val watchlistEndpoint = "/content/v1/watchlist/$accountID" + val parameters = listOf("locale" to locale) + + val json = buildJsonObject { + put("content_id", seriesId) + } + + requestPost(watchlistEndpoint, parameters, json.toString()) + } + + /** + * Remove a media from the user's watchlist. + * + * @param seriesId The crunchyroll series id of the media to check + */ + suspend fun deleteWatchlist(seriesId: String) { + val watchlistEndpoint = "/content/v1/watchlist/$accountID/$seriesId" + val parameters = listOf("locale" to locale) + + requestDelete(watchlistEndpoint, parameters) + } + + /** + * TODO + */ + suspend fun playhead() { + // implement + } + } diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt index c0e7636..af62459 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/MainActivity.kt @@ -135,10 +135,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen private fun load() { val time = measureTimeMillis { // start the initial loading -// val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) -// .async { -// launch { MetaDBController.list() } -// } // load all saved stuff here Preferences.load(this) @@ -150,7 +146,7 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen showOnboarding() } else { Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password) - runBlocking { Crunchyroll.index() } + runBlocking { initCrunchyroll().joinAll() } } // if (EncryptedPreferences.password.isEmpty()) { @@ -179,6 +175,14 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen wasInitialized = true } + private fun initCrunchyroll(): List { + val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope")) + return listOf( + scope.launch { Crunchyroll.index() }, + scope.launch { Crunchyroll.account() } + ) + } + private fun showLoginDialog() { LoginDialog(this, false).positiveButton { EncryptedPreferences.saveCredentials(login, password, context) diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt index fdfb693..d3c9be7 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/fragments/MediaFragment.kt @@ -17,11 +17,10 @@ import com.bumptech.glide.request.RequestOptions import com.google.android.material.appbar.AppBarLayout import com.google.android.material.tabs.TabLayoutMediator import jp.wasabeef.glide.transformations.BlurTransformation +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.mosad.teapod.R import org.mosad.teapod.databinding.FragmentMediaBinding -import org.mosad.teapod.parser.crunchyroll.Item -import org.mosad.teapod.parser.crunchyroll.NoneItem import org.mosad.teapod.ui.activity.main.MainActivity import org.mosad.teapod.ui.activity.main.viewmodel.MediaFragmentViewModel import org.mosad.teapod.util.tmdb.TMDBApiController @@ -33,12 +32,13 @@ import org.mosad.teapod.util.tmdb.TMDBTVShow * Note: the fragment is created only once, when selecting a similar title etc. * therefore fragments may be not empty and model may be the old one */ -class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : Fragment() { +class MediaFragment(private val mediaIdStr: String) : Fragment() { private lateinit var binding: FragmentMediaBinding private lateinit var pagerAdapter: FragmentStateAdapter private val fragments = arrayListOf() + private var watchlistJobRunning = false private val model: MediaFragmentViewModel by activityViewModels() @@ -112,12 +112,9 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : else -> "" } - // TODO set "my list" indicator -// if (StorageController.myList.contains(media.aodId)) { -// Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) -// } else { -// Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) -// } + // set "watchlist" indicator + val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 + Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction) // clear fragments, since it lives in onCreate scope (don't do this in onPause/onStop -> FragmentManager transaction) val fragmentsSize = if (fragments.lastIndex < 0) 0 else fragments.lastIndex @@ -223,20 +220,18 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) : // add or remove media from myList binding.linearMyListAction.setOnClickListener { - // TODO reimplement -// if (StorageController.myList.contains(media.aodId)) { -// StorageController.myList.remove(media.aodId) -// Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction) -// } else { -// StorageController.myList.add(media.aodId) -// Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction) -// } -// StorageController.saveMyList(requireContext()) -// -// // notify home fragment on change -// parentFragmentManager.findFragmentByTag("HomeFragment")?.let { -// (it as HomeFragment).updateMyListMedia() -// } + // don't allow parallel execution + if (!watchlistJobRunning) { + watchlistJobRunning = true + lifecycleScope.launch { + setWatchlist() + + // update "watchlist" indicator + val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24 + Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction) + watchlistJobRunning = false + } + } } } diff --git a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt index 6acd0a6..5372d45 100644 --- a/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt +++ b/app/src/main/java/org/mosad/teapod/ui/activity/main/viewmodel/MediaFragmentViewModel.kt @@ -29,7 +29,10 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic var episodesCrunchy = NoneEpisodes internal set val currentEpisodesCrunchy = arrayListOf() // used for EpisodeItemAdapter (easier updates) + var isWatchlist = false + internal set + // TMDB stuff var mediaType = MediaType.OTHER internal set var tmdbResult: TMDBResult = NoneTMDB // TODO rename @@ -47,7 +50,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic // load series and seasons info in parallel listOf( viewModelScope.launch { seriesCrunchy = Crunchyroll.series(crunchyId) }, - viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) } + viewModelScope.launch { seasonsCrunchy = Crunchyroll.seasons(crunchyId) }, + viewModelScope.launch { isWatchlist = Crunchyroll.isWatchlist(crunchyId) } ).joinAll() println("series: $seriesCrunchy") @@ -74,31 +78,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic loadTmdbInfo() } - /** - * Set currentSeasonCrunchy based on the season id. Also set the new seasons episodes. - * - * @param seasonId the id of the season to set - */ - suspend fun setCurrentSeason(seasonId: String) { - // return if the id hasn't changed (performance) - if (currentSeasonCrunchy.id == seasonId) return - - // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, - // don't change the current season (this should/can never happen) - currentSeasonCrunchy = seasonsCrunchy.items.firstOrNull { - it.id == seasonId - } ?: currentSeasonCrunchy - - episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) - currentEpisodesCrunchy.clear() - currentEpisodesCrunchy.addAll(episodesCrunchy.items) - } - /** * Load the tmdb info for the selected media. * The TMDB search return a media type, use this to get the details (movie/tv show and season) */ - suspend fun loadTmdbInfo() { + private suspend fun loadTmdbInfo() { val tmdbApiController = TMDBApiController() val tmdbSearchResult = when(mediaType) { @@ -124,6 +108,36 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic // } else NoneTMDBTVSeason } + /** + * Set currentSeasonCrunchy based on the season id. Also set the new seasons episodes. + * + * @param seasonId the id of the season to set + */ + suspend fun setCurrentSeason(seasonId: String) { + // return if the id hasn't changed (performance) + if (currentSeasonCrunchy.id == seasonId) return + + // set currentSeasonCrunchy to the new season with id == seasonId, if the id isn't found, + // don't change the current season (this should/can never happen) + currentSeasonCrunchy = seasonsCrunchy.items.firstOrNull { + it.id == seasonId + } ?: currentSeasonCrunchy + + episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id) + currentEpisodesCrunchy.clear() + currentEpisodesCrunchy.addAll(episodesCrunchy.items) + } + + suspend fun setWatchlist() { + isWatchlist = if (isWatchlist) { + Crunchyroll.deleteWatchlist(seriesCrunchy.id) + false + } else { + Crunchyroll.postWatchlist(seriesCrunchy.id) + true + } + } + /** * get the next episode based on episodeId * if no matching is found, use first episode