move TMDBApiCOntroller to Fuel and kotlinx.serialization

* add year and maturityRatings to MediaFragment
* don't show season selection if only one season is present
This commit is contained in:
Jannik 2021-12-31 16:03:15 +01:00
parent 206a00fed5
commit 9f47304b55
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
9 changed files with 261 additions and 202 deletions

View File

@ -46,7 +46,7 @@ dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"]) implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1") implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1'
implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.appcompat:appcompat:1.3.1'

View File

@ -76,6 +76,8 @@ object Crunchyroll {
): Result<FuelJson, FuelError> = coroutineScope { ): Result<FuelJson, FuelError> = coroutineScope {
val path = if (url.isEmpty()) "$baseUrl$endpoint" else url val path = if (url.isEmpty()) "$baseUrl$endpoint" else url
// TODO before sending a request, make sure the accessToken is not expired
return@coroutineScope (Dispatchers.IO) { return@coroutineScope (Dispatchers.IO) {
val (request, response, result) = Fuel.get(path, params) val (request, response, result) = Fuel.get(path, params)
.header("Authorization", "$tokenType $accessToken") .header("Authorization", "$tokenType $accessToken")
@ -182,7 +184,6 @@ object Crunchyroll {
val result = request(episodesEndpoint, parameters) val result = request(episodesEndpoint, parameters)
return result.component1()?.obj()?.let { return result.component1()?.obj()?.let {
println(it)
json.decodeFromString(it.toString()) json.decodeFromString(it.toString())
} ?: NoneSeasons } ?: NoneSeasons
} }
@ -200,7 +201,6 @@ object Crunchyroll {
val result = request(episodesEndpoint, parameters) val result = request(episodesEndpoint, parameters)
return result.component1()?.obj()?.let { return result.component1()?.obj()?.let {
println(it)
json.decodeFromString(it.toString()) json.decodeFromString(it.toString())
} ?: NoneEpisodes } ?: NoneEpisodes
} }
@ -209,7 +209,6 @@ object Crunchyroll {
val result = request("", url = url) val result = request("", url = url)
return result.component1()?.obj()?.let { return result.component1()?.obj()?.let {
println(it)
json.decodeFromString(it.toString()) json.decodeFromString(it.toString())
} ?: NonePlayback } ?: NonePlayback
} }

View File

@ -64,12 +64,13 @@ data class Poster(val height: Int, val width: Int, val source: String, val type:
*/ */
@Serializable @Serializable
data class Series( data class Series(
val id: String, @SerialName("id") val id: String,
val title: String, @SerialName("title") val title: String,
val description: String, @SerialName("description") val description: String,
val images: Images @SerialName("images") val images: Images,
@SerialName("maturity_ratings") val maturityRatings: List<String>
) )
val NoneSeries = Series("", "", "", Images(listOf(), listOf())) val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList())
/** /**
@ -77,8 +78,8 @@ val NoneSeries = Series("", "", "", Images(listOf(), listOf()))
*/ */
@Serializable @Serializable
data class Seasons( data class Seasons(
val total: Int, @SerialName("total") val total: Int,
val items: List<Season> @SerialName("items") val items: List<Season>
) { ) {
fun getPreferredSeason(local: Locale): Season { fun getPreferredSeason(local: Locale): Season {
// try to get the the first seasons which matches the preferred local // try to get the the first seasons which matches the preferred local
@ -111,14 +112,17 @@ data class Season(
) )
val NoneSeasons = Seasons(0, listOf()) val NoneSeasons = Seasons(0, listOf())
val NoneSeason = Season("", "", "", 0, false, false) val NoneSeason = Season("", "", "", 0, isSubbed = false, isDubbed = false)
/** /**
* Episodes data type * Episodes data type
*/ */
@Serializable @Serializable
data class Episodes(val total: Int, val items: List<Episode>) data class Episodes(
@SerialName("total") val total: Int,
@SerialName("items") val items: List<Episode>
)
@Serializable @Serializable
data class Episode( data class Episode(

View File

@ -24,10 +24,9 @@ import org.mosad.teapod.parser.crunchyroll.Item
import org.mosad.teapod.parser.crunchyroll.NoneItem 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.DataTypes.MediaType
import org.mosad.teapod.util.StorageController
import org.mosad.teapod.util.tmdb.TMDBMovie
import org.mosad.teapod.util.tmdb.TMDBApiController import org.mosad.teapod.util.tmdb.TMDBApiController
import org.mosad.teapod.util.tmdb.TMDBMovie
import org.mosad.teapod.util.tmdb.TMDBTVShow
/** /**
* The media detail fragment. * The media detail fragment.
@ -48,6 +47,7 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
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)
binding.frameLoading.visibility = View.VISIBLE binding.frameLoading.visibility = View.VISIBLE
@ -89,9 +89,9 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
*/ */
private fun updateGUI() = with(model) { private fun updateGUI() = with(model) {
// generic gui // generic gui
val backdropUrl = tmdbResult?.backdropPath?.let { TMDBApiController.imageUrl + it } val backdropUrl = tmdbResult.backdropPath?.let { TMDBApiController.imageUrl + it }
?: seriesCrunchy.images.poster_wide[0][2].source ?: seriesCrunchy.images.poster_wide[0][2].source
val posterUrl = tmdbResult?.posterPath?.let { TMDBApiController.imageUrl + it } val posterUrl = tmdbResult.posterPath?.let { TMDBApiController.imageUrl + it }
?: seriesCrunchy.images.poster_tall[0][2].source ?: seriesCrunchy.images.poster_tall[0][2].source
// load poster and backdrop // load poster and backdrop
@ -103,9 +103,14 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
.into(binding.imageBackdrop) .into(binding.imageBackdrop)
binding.textTitle.text = seriesCrunchy.title binding.textTitle.text = seriesCrunchy.title
//binding.textYear.text = media.year.toString() // TODO get from tmdb
//binding.textAge.text = media.age.toString() // TODO get from tmdb
binding.textOverview.text = seriesCrunchy.description binding.textOverview.text = seriesCrunchy.description
binding.textAge.text = seriesCrunchy.maturityRatings.firstOrNull()
binding.textYear.text = when(tmdbResult) {
is TMDBTVShow -> (tmdbResult as TMDBTVShow).firstAirDate.substring(0, 4)
is TMDBMovie -> (tmdbResult as TMDBMovie).releaseDate.substring(0, 4)
else -> ""
}
// TODO set "my list" indicator // TODO set "my list" indicator
// if (StorageController.myList.contains(media.aodId)) { // if (StorageController.myList.contains(media.aodId)) {
@ -119,8 +124,7 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
fragments.clear() fragments.clear()
pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize) pagerAdapter.notifyItemRangeRemoved(0, fragmentsSize)
// add the episodes fragment (as tab). Note: Movies are tv shows!
// add the episodes fragment (as tab)
MediaFragmentEpisodes().also { MediaFragmentEpisodes().also {
fragments.add(it) fragments.add(it)
pagerAdapter.notifyItemInserted(fragments.indexOf(it)) pagerAdapter.notifyItemInserted(fragments.indexOf(it))
@ -128,6 +132,33 @@ class MediaFragment(private val mediaIdStr: String, mediaCr: Item = NoneItem) :
// TODO reimplement via tmdb/metaDB // TODO reimplement via tmdb/metaDB
// specific gui // specific gui
when (tmdbResult) {
is TMDBTVShow -> {
// episodes count
binding.textEpisodesOrRuntime.text = resources.getQuantityString(
R.plurals.text_episodes_count,
episodesCrunchy.total,
episodesCrunchy.total
)
}
is TMDBMovie -> {
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
}
}
else -> {
println("else")
}
}
// if (mediaCrunchy.type == MediaType.TVSHOW.str) { // if (mediaCrunchy.type == MediaType.TVSHOW.str) {
// // TODO get next episode // // TODO get next episode
//// nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId //// nextEpisodeId = media.playlist.firstOrNull{ !it.watched }?.mediaId

View File

@ -31,7 +31,7 @@ class MediaFragmentEpisodes : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
adapterRecEpisodes = EpisodeItemAdapter(model.currentEpisodesCrunchy, model.tmdbTVSeason?.episodes) adapterRecEpisodes = EpisodeItemAdapter(model.currentEpisodesCrunchy, model.tmdbTVSeason.episodes)
binding.recyclerEpisodes.adapter = adapterRecEpisodes binding.recyclerEpisodes.adapter = adapterRecEpisodes
// set onItemClick, adapter is initialized // set onItemClick, adapter is initialized
@ -39,10 +39,14 @@ class MediaFragmentEpisodes : Fragment() {
playEpisode(seasonId, episodeId) playEpisode(seasonId, episodeId)
} }
// TODO don't show selection if only one season is present // don't show season selection if only one season is present
binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title if (model.seasonsCrunchy.total < 2) {
binding.buttonSeasonSelection.setOnClickListener { v -> binding.buttonSeasonSelection.visibility = View.GONE
showSeasonSelection(v) } else {
binding.buttonSeasonSelection.text = model.currentSeasonCrunchy.title
binding.buttonSeasonSelection.setOnClickListener { v ->
showSeasonSelection(v)
}
} }
} }

View File

@ -1,18 +1,15 @@
package org.mosad.teapod.ui.activity.main.viewmodel package org.mosad.teapod.ui.activity.main.viewmodel
import android.app.Application import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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.Meta import org.mosad.teapod.util.Meta
import org.mosad.teapod.util.tmdb.TMDBApiController import org.mosad.teapod.util.tmdb.*
import org.mosad.teapod.util.tmdb.TMDBResult
import org.mosad.teapod.util.tmdb.TMDBTVSeason
/** /**
* handle media, next ep and tmdb * handle media, next ep and tmdb
@ -22,7 +19,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// var mediaCrunchy = NoneItem // var mediaCrunchy = NoneItem
// internal set // internal set
var seriesCrunchy = NoneSeries // TODO it seems movies also series? var seriesCrunchy = NoneSeries // movies are also series
internal set internal set
var seasonsCrunchy = NoneSeasons var seasonsCrunchy = NoneSeasons
internal set internal set
@ -32,9 +29,9 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
internal set internal set
val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates) val currentEpisodesCrunchy = arrayListOf<Episode>() // used for EpisodeItemAdapter (easier updates)
var tmdbResult: TMDBResult? = null // TODO rename var tmdbResult: TMDBResult = NoneTMDB // TODO rename
internal set internal set
var tmdbTVSeason: TMDBTVSeason? =null var tmdbTVSeason: TMDBTVSeason = NoneTMDBTVSeason
internal set internal set
var mediaMeta: Meta? = null var mediaMeta: Meta? = null
internal set internal set
@ -42,9 +39,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
/** /**
* @param crunchyId the crunchyroll series id * @param crunchyId the crunchyroll series id
*/ */
suspend fun loadCrunchy(crunchyId: String) {
val tmdbApiController = TMDBApiController()
suspend fun loadCrunchy(crunchyId: String) {
// 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) },
@ -54,6 +50,8 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
println("series: $seriesCrunchy") println("series: $seriesCrunchy")
println("seasons: $seasonsCrunchy") println("seasons: $seasonsCrunchy")
// 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) episodesCrunchy = Crunchyroll.episodes(currentSeasonCrunchy.id)
@ -62,18 +60,17 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
println("episodes: $episodesCrunchy") println("episodes: $episodesCrunchy")
// TODO check if metaDB knows the title // TODO check if metaDB knows the title
// use tmdb search to get media info TODO media type is hardcoded, use episodeNumber? (if null it should be a movie)
mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media
val tmdbId = tmdbApiController.search(seriesCrunchy.title, MediaType.TVSHOW)
tmdbResult = when (MediaType.TVSHOW) { // use tmdb search to get media info
MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) loadTmdbInfo()
MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId)
else -> null
}
} }
/**
* 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) { suspend fun setCurrentSeason(seasonId: String) {
// return if the id hasn't changed (performance) // return if the id hasn't changed (performance)
if (currentSeasonCrunchy.id == seasonId) return if (currentSeasonCrunchy.id == seasonId) return
@ -90,49 +87,33 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
} }
/** /**
* set media, tmdb and nextEpisode * 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 loadAoD(aodId: Int) { @ExperimentalSerializationApi
// val tmdbApiController = TMDBApiController() suspend fun loadTmdbInfo() {
// media = AoDParser.getMediaById(aodId) val tmdbApiController = TMDBApiController()
//
// // check if metaDB knows the title val tmdbSearchResult = tmdbApiController.searchMulti(seriesCrunchy.title)
// val tmdbId: Int = if (MetaDBController.mediaList.media.contains(aodId)) { println(tmdbSearchResult)
// // load media info from metaDB
// val metaDB = MetaDBController() tmdbResult = if (tmdbSearchResult.results.isNotEmpty()) {
// mediaMeta = when (media.type) { val result = tmdbSearchResult.results.first()
// MediaType.MOVIE -> metaDB.getMovieMetadata(media.aodId)
// MediaType.TVSHOW -> metaDB.getTVShowMetadata(media.aodId) when (result.mediaType) {
// else -> null "movie" -> tmdbApiController.getMovieDetails(result.id)
// } "tv" -> tmdbApiController.getTVShowDetails(result.id)
// else -> NoneTMDB
// mediaMeta?.tmdbId ?: -1 }
// } else { } else NoneTMDB
// // use tmdb search to get media info
// mediaMeta = null // set mediaMeta to null, if metaDB doesn't know the media println(tmdbResult)
// tmdbApiController.search(stripTitleInfo(media.title), media.type)
// } // currently not used
// // tmdbTVSeason = if (tmdbResult is TMDBTVShow) {
// tmdbResult = when (media.type) { // tmdbApiController.getTVSeasonDetails(tmdbResult.id, 0)
// MediaType.MOVIE -> tmdbApiController.getMovieDetails(tmdbId) // } else NoneTMDBTVSeason
// MediaType.TVSHOW -> tmdbApiController.getTVShowDetails(tmdbId) }
// else -> null
// }
//
// // get season info, if metaDB knows the tv show
// tmdbTVSeason = if (media.type == MediaType.TVSHOW && mediaMeta != null) {
// val tvShowMeta = mediaMeta as TVShowMeta
// tmdbApiController.getTVSeasonDetails(tvShowMeta.tmdbId, tvShowMeta.tmdbSeasonNumber)
// } else {
// null
// }
//
// if (media.type == MediaType.TVSHOW) {
// //nextEpisode = media.episodes.firstOrNull{ !it.watched } ?: media.episodes.first()
// nextEpisodeId = media.playlist.firstOrNull { !it.watched }?.mediaId
// ?: media.playlist.first().mediaId
// }
// }
/** /**
* get the next episode based on episodeId * get the next episode based on episodeId
@ -146,4 +127,4 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
// ?: media.playlist.first().mediaId // ?: media.playlist.first().mediaId
} }
} }

View File

@ -5,3 +5,7 @@ import android.widget.TextView
fun TextView.setDrawableTop(drawable: Int) { fun TextView.setDrawableTop(drawable: Int) {
this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0) this.setCompoundDrawablesWithIntrinsicBounds(0, drawable, 0, 0)
} }
fun <T> concatenate(vararg lists: List<T>): List<T> {
return listOf(*lists).flatten()
}

View File

@ -22,116 +22,113 @@
package org.mosad.teapod.util.tmdb package org.mosad.teapod.util.tmdb
import android.util.Log import com.github.kittinunf.fuel.Fuel
import com.google.gson.Gson import com.github.kittinunf.fuel.core.FuelError
import com.google.gson.JsonParser import com.github.kittinunf.fuel.core.Parameters
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.coroutines.*
import org.mosad.teapod.util.DataTypes.MediaType import kotlinx.serialization.ExperimentalSerializationApi
import java.io.FileNotFoundException import kotlinx.serialization.decodeFromString
import java.net.URL import kotlinx.serialization.json.Json
import java.net.URLEncoder import org.mosad.teapod.util.concatenate
/** /**
* Controller for tmdb api integration. * Controller for tmdb api integration.
* Data types are in TMDBDataTypes. For the type definitions see: * Data types are in TMDBDataTypes. For the type definitions see:
* https://developers.themoviedb.org/3/getting-started/introduction * https://developers.themoviedb.org/3/getting-started/introduction
* *
* TODO evaluate Klaxon
*/ */
class TMDBApiController { class TMDBApiController {
private val json = Json { ignoreUnknownKeys = true }
private val apiUrl = "https://api.themoviedb.org/3" private val apiUrl = "https://api.themoviedb.org/3"
private val searchMovieUrl = "$apiUrl/search/movie"
private val searchTVUrl = "$apiUrl/search/tv"
private val detailsMovieUrl = "$apiUrl/movie"
private val detailsTVUrl = "$apiUrl/tv"
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2" private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
private val language = "de" private val language = "de"
private val preparedParameters = "?api_key=$apiKey&language=$language"
companion object{ companion object{
const val imageUrl = "https://image.tmdb.org/t/p/w500" const val imageUrl = "https://image.tmdb.org/t/p/w500"
} }
@Suppress("BlockingMethodInNonBlockingContext") private suspend fun request(
endpoint: String,
parameters: Parameters = emptyList()
): Result<FuelJson, FuelError> = coroutineScope {
val path = "$apiUrl$endpoint"
val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters)
// TODO handle FileNotFoundException
return@coroutineScope (Dispatchers.IO) {
val (_, _, result) = Fuel.get(path, params)
.responseJson()
result
}
}
/** /**
* Search for a media(movie or tv show) in tmdb * Search for a media(movie or tv show) in tmdb
* @param query The query text * @param query The query text
* @param type The media type (movie or tv show) * @return A TMDBSearch object, or NoneTMDBSearch if nothing was found
* @return The media tmdb id, or -1 if not found
*/ */
suspend fun search(query: String, type: MediaType): Int = withContext(Dispatchers.IO) { @ExperimentalSerializationApi
val searchUrl = when (type) { suspend fun searchMulti(query: String): TMDBSearch {
MediaType.MOVIE -> searchMovieUrl val searchEndpoint = "/search/multi"
MediaType.TVSHOW -> searchTVUrl val parameters = listOf("query" to query, "include_adult" to false)
else -> {
Log.e(javaClass.name, "Wrong Type: $type")
return@withContext -1
}
}
val url = URL("$searchUrl$preparedParameters&query=${URLEncoder.encode(query, "UTF-8")}") val result = request(searchEndpoint, parameters)
val response = JsonParser.parseString(url.readText()).asJsonObject return result.component1()?.obj()?.let {
val sortedResults = response.get("results").asJsonArray.toList().sortedBy { json.decodeFromString(it.toString())
it.asJsonObject.get("title")?.asString } ?: NoneTMDBSearch
}
return@withContext sortedResults.firstOrNull()?.asJsonObject?.get("id")?.asInt ?: -1
} }
@Suppress("BlockingMethodInNonBlockingContext")
/** /**
* Get details for a movie from tmdb * Get details for a movie from tmdb
* @param movieId The tmdb ID of the movie * @param movieId The tmdb ID of the movie
* @return A tmdb movie object, or null if not found * @return A TMDBMovie object, or NoneTMDBMovie if not found
*/ */
suspend fun getMovieDetails(movieId: Int): TMDBMovie? = withContext(Dispatchers.IO) { suspend fun getMovieDetails(movieId: Int): TMDBMovie {
val url = URL("$detailsMovieUrl/$movieId?api_key=$apiKey&language=$language") val movieEndpoint = "/movie/$movieId"
return@withContext try { // TODO is FileNotFoundException handling needed?
val json = url.readText() val result = request(movieEndpoint)
Gson().fromJson(json, TMDBMovie::class.java) return result.component1()?.obj()?.let {
} catch (ex: FileNotFoundException) { json.decodeFromString(it.toString())
Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $movieId", ex) } ?: NoneTMDBMovie
null
}
} }
@Suppress("BlockingMethodInNonBlockingContext")
/** /**
* Get details for a tv show from tmdb * Get details for a tv show from tmdb
* @param tvId The tmdb ID of the tv show * @param tvId The tmdb ID of the tv show
* @return A tmdb tv show object, or null if not found * @return A TMDBTVShow object, or NoneTMDBTVShow if not found
*/ */
suspend fun getTVShowDetails(tvId: Int): TMDBTVShow? = withContext(Dispatchers.IO) { suspend fun getTVShowDetails(tvId: Int): TMDBTVShow {
val url = URL("$detailsTVUrl/$tvId?api_key=$apiKey&language=$language") val tvShowEndpoint = "/tv/$tvId"
return@withContext try { // TODO is FileNotFoundException handling needed?
val json = url.readText() val result = request(tvShowEndpoint)
Gson().fromJson(json, TMDBTVShow::class.java) return result.component1()?.obj()?.let {
} catch (ex: FileNotFoundException) { json.decodeFromString(it.toString())
Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $tvId", ex) } ?: NoneTMDBTVShow
null
}
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("unused")
/** /**
* Get details for a tv show season from tmdb * Get details for a tv show season from tmdb
* @param tvId The tmdb ID of the tv show * @param tvId The tmdb ID of the tv show
* @param seasonNumber The tmdb season number * @param seasonNumber The tmdb season number
* @return A tmdb tv season object, or null if not found * @return A TMDBTVSeason object, or NoneTMDBTVSeason if not found
*/ */
suspend fun getTVSeasonDetails(tvId: Int, seasonNumber: Int): TMDBTVSeason? = withContext(Dispatchers.IO) { suspend fun getTVSeasonDetails(tvId: Int, seasonNumber: Int): TMDBTVSeason {
val url = URL("$detailsTVUrl/$tvId/season/$seasonNumber?api_key=$apiKey&language=$language") val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
return@withContext try { // TODO is FileNotFoundException handling needed?
val json = url.readText() val result = request(tvShowSeasonEndpoint)
Gson().fromJson(json, TMDBTVSeason::class.java) return result.component1()?.obj()?.let {
} catch (ex: FileNotFoundException) { json.decodeFromString(it.toString())
Log.w(javaClass.name, "Waring: The requested media was not found. Requested ID: $tvId, Season: $seasonNumber", ex) } ?: NoneTMDBTVSeason
null
}
} }
} }

View File

@ -22,71 +22,110 @@
package org.mosad.teapod.util.tmdb package org.mosad.teapod.util.tmdb
import com.google.gson.annotations.SerializedName import android.os.Build
import androidx.annotation.RequiresApi
import kotlinx.serialization.*
import kotlinx.serialization.json.JsonNames
import java.text.DateFormat
import java.time.LocalDate
import java.util.*
/**
* New TMDB API data classes
*/
@ExperimentalSerializationApi
@Serializable
data class TMDBSearch(
val page: Int,
val results: List<TMDBSearchResult>
)
@ExperimentalSerializationApi
@Serializable
data class TMDBSearchResult(
@SerialName("id") val id: Int,
@SerialName("media_type") val mediaType: String,
@JsonNames("name", "title") val name: String, // tv show = name, movie = title
@SerialName("overview") val overview: String?,
@SerialName("poster_path") val posterPath: String?,
@SerialName("backdrop_path") val backdropPath: String?,
)
@ExperimentalSerializationApi
val NoneTMDBSearch = TMDBSearch(0, emptyList())
/** /**
* These data classes represent the tmdb api json objects. * These data classes represent the tmdb api json objects.
* Fields which are nullable in the tmdb api are also nullable here. * Fields which are nullable in the tmdb api are also nullable here.
*/ */
abstract class TMDBResult{ interface TMDBResult {
abstract val id: Int val id: Int
abstract val name: String val name: String
abstract val overview: String? // for movies tmdb return string or null val overview: String? // for movies tmdb return string or null
abstract val posterPath: String? val posterPath: String?
abstract val backdropPath: String? val backdropPath: String?
} }
data class TMDBMovie( data class TMDBBase(
override val id: Int, override val id: Int,
override val name: String, override val name: String,
override val overview: String?, override val overview: String?,
@SerializedName("poster_path")
override val posterPath: String?, override val posterPath: String?,
@SerializedName("backdrop_path") override val backdropPath: String?
override val backdropPath: String?, ) : TMDBResult
@SerializedName("release_date")
val releaseDate: String,
@SerializedName("runtime")
val runtime: Int?,
// TODO generes
): TMDBResult()
@Serializable
data class TMDBMovie(
@SerialName("id") override val id: Int,
@SerialName("title") override val name: String, // for movies the name is in the field title
@SerialName("overview") override val overview: String?,
@SerialName("poster_path") override val posterPath: String?,
@SerialName("backdrop_path") override val backdropPath: String?,
@SerialName("release_date") val releaseDate: String,
@SerialName("runtime") val runtime: Int?,
@SerialName("status") val status: String,
// TODO generes
) : TMDBResult
@Serializable
data class TMDBTVShow( data class TMDBTVShow(
override val id: Int, @SerialName("id")override val id: Int,
override val name: String, @SerialName("name")override val name: String,
override val overview: String, @SerialName("overview")override val overview: String,
@SerializedName("poster_path") @SerialName("poster_path") override val posterPath: String?,
override val posterPath: String?, @SerialName("backdrop_path") override val backdropPath: String?,
@SerializedName("backdrop_path") @SerialName("first_air_date") val firstAirDate: String,
override val backdropPath: String?, @SerialName("last_air_date") val lastAirDate: String,
@SerializedName("first_air_date") @SerialName("status") val status: String,
val firstAirDate: String,
@SerializedName("status")
val status: String,
// TODO generes // TODO generes
): TMDBResult() ) : TMDBResult
// use null for nullable types, the gui needs to handle/implement a fallback for null values
val NoneTMDB = TMDBBase(0, "", "", null, null)
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "")
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "")
@Serializable
data class TMDBTVSeason( data class TMDBTVSeason(
val id: Int, @SerialName("id") val id: Int,
val name: String, @SerialName("name") val name: String,
val overview: String, @SerialName("overview") val overview: String,
@SerializedName("poster_path") @SerialName("poster_path") val posterPath: String?,
val posterPath: String?, @SerialName("air_date") val airDate: String,
@SerializedName("air_date") @SerialName("episodes") val episodes: List<TMDBTVEpisode>,
val airDate: String, @SerialName("season_number") val seasonNumber: Int
@SerializedName("episodes")
val episodes: List<TMDBTVEpisode>,
@SerializedName("season_number")
val seasonNumber: Int
) )
@Serializable
data class TMDBTVEpisode( data class TMDBTVEpisode(
val id: Int, @SerialName("id") val id: Int,
val name: String, @SerialName("name") val name: String,
val overview: String, @SerialName("overview") val overview: String,
@SerializedName("air_date") @SerialName("air_date") val airDate: String,
val airDate: String, @SerialName("episode_number") val episodeNumber: Int
@SerializedName("episode_number") )
val episodeNumber: Int
) // use null for nullable types, the gui needs to handle/implement a fallback for null values
val NoneTMDBTVSeason = TMDBTVSeason(0, "", "", null, "", emptyList(), 0)