crunchyroll support #49
@ -4,12 +4,15 @@ import android.util.Log
|
|||||||
import com.github.kittinunf.fuel.Fuel
|
import com.github.kittinunf.fuel.Fuel
|
||||||
import com.github.kittinunf.fuel.core.FuelError
|
import com.github.kittinunf.fuel.core.FuelError
|
||||||
import com.github.kittinunf.fuel.core.Parameters
|
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.FuelJson
|
||||||
import com.github.kittinunf.fuel.json.responseJson
|
import com.github.kittinunf.fuel.json.responseJson
|
||||||
import com.github.kittinunf.result.Result
|
import com.github.kittinunf.result.Result
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
import org.mosad.teapod.preferences.Preferences
|
import org.mosad.teapod.preferences.Preferences
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@ -22,6 +25,8 @@ object Crunchyroll {
|
|||||||
private var accessToken = ""
|
private var accessToken = ""
|
||||||
private var tokenType = ""
|
private var tokenType = ""
|
||||||
|
|
||||||
|
private var accountID = ""
|
||||||
|
|
||||||
private var policy = ""
|
private var policy = ""
|
||||||
private var signature = ""
|
private var signature = ""
|
||||||
private var keyPairID = ""
|
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
|
* Retrieve the identifiers necessary for streaming. If the identifiers are
|
||||||
* retrieved, set the corresponding global var. The identifiers are valid for 24h.
|
* retrieved, set the corresponding global var. The identifiers are valid for 24h.
|
||||||
@ -110,6 +151,24 @@ object Crunchyroll {
|
|||||||
println("keyPairID: $keyPairID")
|
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
|
// TODO locale de-DE, categories
|
||||||
/**
|
/**
|
||||||
@ -213,4 +272,59 @@ object Crunchyroll {
|
|||||||
} ?: NonePlayback
|
} ?: 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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -135,10 +135,6 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
private fun load() {
|
private fun load() {
|
||||||
val time = measureTimeMillis {
|
val time = measureTimeMillis {
|
||||||
// start the initial loading
|
// start the initial loading
|
||||||
// val loadingJob = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope"))
|
|
||||||
// .async {
|
|
||||||
// launch { MetaDBController.list() }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// load all saved stuff here
|
// load all saved stuff here
|
||||||
Preferences.load(this)
|
Preferences.load(this)
|
||||||
@ -150,7 +146,7 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
showOnboarding()
|
showOnboarding()
|
||||||
} else {
|
} else {
|
||||||
Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password)
|
Crunchyroll.login(EncryptedPreferences.login, EncryptedPreferences.password)
|
||||||
runBlocking { Crunchyroll.index() }
|
runBlocking { initCrunchyroll().joinAll() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (EncryptedPreferences.password.isEmpty()) {
|
// if (EncryptedPreferences.password.isEmpty()) {
|
||||||
@ -179,6 +175,14 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
|||||||
wasInitialized = true
|
wasInitialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun initCrunchyroll(): List<Job> {
|
||||||
|
val scope = CoroutineScope(Dispatchers.IO + CoroutineName("InitialLoadingScope"))
|
||||||
|
return listOf(
|
||||||
|
scope.launch { Crunchyroll.index() },
|
||||||
|
scope.launch { Crunchyroll.account() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showLoginDialog() {
|
private fun showLoginDialog() {
|
||||||
LoginDialog(this, false).positiveButton {
|
LoginDialog(this, false).positiveButton {
|
||||||
EncryptedPreferences.saveCredentials(login, password, context)
|
EncryptedPreferences.saveCredentials(login, password, context)
|
||||||
|
@ -17,11 +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.Item
|
|
||||||
import org.mosad.teapod.parser.crunchyroll.NoneItem
|
|
||||||
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
|
||||||
@ -33,12 +32,13 @@ import org.mosad.teapod.util.tmdb.TMDBTVShow
|
|||||||
* Note: the fragment is created only once, when selecting a similar title etc.
|
* 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
|
* 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 binding: FragmentMediaBinding
|
||||||
private lateinit var pagerAdapter: FragmentStateAdapter
|
private lateinit var pagerAdapter: FragmentStateAdapter
|
||||||
|
|
||||||
private val fragments = arrayListOf<Fragment>()
|
private val fragments = arrayListOf<Fragment>()
|
||||||
|
private var watchlistJobRunning = false
|
||||||
|
|
||||||
private val model: MediaFragmentViewModel by activityViewModels()
|
private val model: MediaFragmentViewModel by activityViewModels()
|
||||||
|
|
||||||
@ -112,12 +112,9 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
|
|||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO set "my list" indicator
|
// set "watchlist" indicator
|
||||||
// if (StorageController.myList.contains(media.aodId)) {
|
val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24
|
||||||
// Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction)
|
Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction)
|
||||||
// } else {
|
|
||||||
// Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// clear fragments, since it lives in onCreate scope (don't do this in onPause/onStop -> FragmentManager transaction)
|
// 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
|
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
|
// add or remove media from myList
|
||||||
binding.linearMyListAction.setOnClickListener {
|
binding.linearMyListAction.setOnClickListener {
|
||||||
// TODO reimplement
|
// don't allow parallel execution
|
||||||
// if (StorageController.myList.contains(media.aodId)) {
|
if (!watchlistJobRunning) {
|
||||||
// StorageController.myList.remove(media.aodId)
|
watchlistJobRunning = true
|
||||||
// Glide.with(requireContext()).load(R.drawable.ic_baseline_add_24).into(binding.imageMyListAction)
|
lifecycleScope.launch {
|
||||||
// } else {
|
setWatchlist()
|
||||||
// StorageController.myList.add(media.aodId)
|
|
||||||
// Glide.with(requireContext()).load(R.drawable.ic_baseline_check_24).into(binding.imageMyListAction)
|
// update "watchlist" indicator
|
||||||
// }
|
val watchlistIcon = if (isWatchlist) R.drawable.ic_baseline_check_24 else R.drawable.ic_baseline_add_24
|
||||||
// StorageController.saveMyList(requireContext())
|
Glide.with(requireContext()).load(watchlistIcon).into(binding.imageMyListAction)
|
||||||
//
|
watchlistJobRunning = false
|
||||||
// // notify home fragment on change
|
}
|
||||||
// parentFragmentManager.findFragmentByTag("HomeFragment")?.let {
|
}
|
||||||
// (it as HomeFragment).updateMyListMedia()
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +29,10 @@ 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)
|
||||||
|
var isWatchlist = false
|
||||||
|
internal set
|
||||||
|
|
||||||
|
// TMDB stuff
|
||||||
var mediaType = MediaType.OTHER
|
var mediaType = MediaType.OTHER
|
||||||
internal set
|
internal set
|
||||||
var tmdbResult: TMDBResult = NoneTMDB // TODO rename
|
var tmdbResult: TMDBResult = NoneTMDB // TODO rename
|
||||||
@ -47,7 +50,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
// load series and seasons info in parallel
|
// load series and seasons info in parallel
|
||||||
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) }
|
||||||
).joinAll()
|
).joinAll()
|
||||||
|
|
||||||
println("series: $seriesCrunchy")
|
println("series: $seriesCrunchy")
|
||||||
@ -74,31 +78,11 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
loadTmdbInfo()
|
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.
|
* 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)
|
* 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 tmdbApiController = TMDBApiController()
|
||||||
|
|
||||||
val tmdbSearchResult = when(mediaType) {
|
val tmdbSearchResult = when(mediaType) {
|
||||||
@ -124,6 +108,36 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
// } else NoneTMDBTVSeason
|
// } 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
|
* get the next episode based on episodeId
|
||||||
* if no matching is found, use first episode
|
* if no matching is found, use first episode
|
||||||
|
Loading…
Reference in New Issue
Block a user