Use ktor instead of fuel for http requests [Part 2/2]
* update preferred locale in preferences, is is the actual locale implementation * update token handling for crunchy (country via token) * update TMDBApiController to use ktor * add parsable dates to NoneTMDBTVShow and NoneTMDBMovie
This commit is contained in:
parent
2016e03e56
commit
75204e522d
|
@ -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-alpha4"
|
versionName "1.0.0-alpha5"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
resValue "string", "build_time", buildTime()
|
resValue "string", "build_time", buildTime()
|
||||||
|
@ -72,11 +72,6 @@ dependencies {
|
||||||
implementation 'com.afollestad.material-dialogs:core:3.3.0'
|
implementation 'com.afollestad.material-dialogs:core:3.3.0'
|
||||||
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0'
|
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0'
|
||||||
|
|
||||||
implementation 'com.github.kittinunf.fuel:fuel:2.3.1'
|
|
||||||
implementation 'com.github.kittinunf.fuel:fuel-android:2.3.1'
|
|
||||||
implementation 'com.github.kittinunf.fuel:fuel-json:2.3.1'
|
|
||||||
|
|
||||||
// TODO replace fuel with ktor
|
|
||||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||||
implementation "io.ktor:ktor-client-android:$ktor_version"
|
implementation "io.ktor:ktor-client-android:$ktor_version"
|
||||||
implementation "io.ktor:ktor-client-serialization:$ktor_version"
|
implementation "io.ktor:ktor-client-serialization:$ktor_version"
|
||||||
|
|
|
@ -1,16 +1,34 @@
|
||||||
|
/**
|
||||||
|
* Teapod
|
||||||
|
*
|
||||||
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
||||||
|
* MA 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
package org.mosad.teapod.parser.crunchyroll
|
package org.mosad.teapod.parser.crunchyroll
|
||||||
|
|
||||||
import android.util.Log
|
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.json.FuelJson
|
|
||||||
import com.github.kittinunf.fuel.json.responseJson
|
|
||||||
import com.github.kittinunf.result.Result
|
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.features.json.*
|
import io.ktor.client.features.json.*
|
||||||
import io.ktor.client.features.json.serializer.*
|
import io.ktor.client.features.json.serializer.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.request.forms.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
@ -35,8 +53,7 @@ object Crunchyroll {
|
||||||
}
|
}
|
||||||
private const val baseUrl = "https://beta-api.crunchyroll.com"
|
private const val baseUrl = "https://beta-api.crunchyroll.com"
|
||||||
|
|
||||||
private var accessToken = ""
|
private lateinit var token: Token
|
||||||
private var tokenType = ""
|
|
||||||
private var tokenValidUntil: Long = 0
|
private var tokenValidUntil: Long = 0
|
||||||
|
|
||||||
private var accountID = ""
|
private var accountID = ""
|
||||||
|
@ -45,10 +62,6 @@ object Crunchyroll {
|
||||||
private var signature = ""
|
private var signature = ""
|
||||||
private var keyPairID = ""
|
private var keyPairID = ""
|
||||||
|
|
||||||
// TODO temp helper vary
|
|
||||||
private var locale: String = Preferences.preferredLocal.toLanguageTag()
|
|
||||||
private var country: String = Preferences.preferredLocal.country
|
|
||||||
|
|
||||||
private val browsingCache = arrayListOf<Item>()
|
private val browsingCache = arrayListOf<Item>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -61,39 +74,24 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
fun login(username: String, password: String): Boolean = runBlocking {
|
fun login(username: String, password: String): Boolean = runBlocking {
|
||||||
val tokenEndpoint = "/auth/v1/token"
|
val tokenEndpoint = "/auth/v1/token"
|
||||||
val formData = listOf(
|
val formData = Parameters.build {
|
||||||
"username" to username,
|
append("username", username)
|
||||||
"password" to password,
|
append("password", password)
|
||||||
"grant_type" to "password",
|
append("grant_type", "password")
|
||||||
"scope" to "offline_access"
|
append("scope", "offline_access")
|
||||||
)
|
}
|
||||||
|
|
||||||
var success: Boolean // is false
|
var success = false// is false
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val (request, response, result) = Fuel.post("$baseUrl$tokenEndpoint", parameters = formData)
|
// TODO handle exceptions
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) {
|
||||||
.appendHeader(
|
header("Authorization", "Basic ")
|
||||||
"Authorization",
|
|
||||||
"Basic "
|
|
||||||
)
|
|
||||||
.responseJson()
|
|
||||||
|
|
||||||
// TODO fix JSONException: No value for
|
|
||||||
result.component1()?.obj()?.let {
|
|
||||||
accessToken = it.get("access_token").toString()
|
|
||||||
tokenType = it.get("token_type").toString()
|
|
||||||
|
|
||||||
// token will be invalid 1 sec
|
|
||||||
val expiresIn = (it.get("expires_in").toString().toLong() - 1)
|
|
||||||
tokenValidUntil = System.currentTimeMillis() + (expiresIn * 1000)
|
|
||||||
}
|
}
|
||||||
|
token = response.receive()
|
||||||
|
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000)
|
||||||
|
|
||||||
// println("request: $request")
|
Log.i(TAG, "login complete with code ${response.status}")
|
||||||
// println("response: $response")
|
success = (response.status == HttpStatusCode.OK)
|
||||||
// println("response: $result")
|
|
||||||
|
|
||||||
Log.i(TAG, "login complete with code ${response.statusCode}")
|
|
||||||
success = (response.statusCode == 200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return@runBlocking success
|
return@runBlocking success
|
||||||
|
@ -110,22 +108,22 @@ object Crunchyroll {
|
||||||
private suspend inline fun <reified T> request(
|
private suspend inline fun <reified T> request(
|
||||||
url: String,
|
url: String,
|
||||||
httpMethod: HttpMethod,
|
httpMethod: HttpMethod,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
bodyA: Any = Any()
|
bodyObject: Any = Any()
|
||||||
): T = coroutineScope {
|
): T = coroutineScope {
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
||||||
|
|
||||||
return@coroutineScope (Dispatchers.IO) {
|
return@coroutineScope (Dispatchers.IO) {
|
||||||
val response: T = client.request(url) {
|
val response: T = client.request(url) {
|
||||||
method = httpMethod
|
method = httpMethod
|
||||||
body = bodyA
|
header("Authorization", "${token.tokenType} ${token.accessToken}")
|
||||||
header("Authorization", "$tokenType $accessToken")
|
|
||||||
params.forEach {
|
params.forEach {
|
||||||
parameter(it.first, it.second)
|
parameter(it.first, it.second)
|
||||||
}
|
}
|
||||||
|
|
||||||
// for json body set content type
|
// for json set body and content type
|
||||||
if (bodyA is JsonObject) {
|
if (bodyObject is JsonObject) {
|
||||||
|
body = bodyObject
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -136,88 +134,45 @@ object Crunchyroll {
|
||||||
|
|
||||||
private suspend inline fun <reified T> requestGet(
|
private suspend inline fun <reified T> requestGet(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
url: String = ""
|
url: String = ""
|
||||||
): T = coroutineScope {
|
): T {
|
||||||
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
return@coroutineScope (Dispatchers.IO) {
|
return request(path, HttpMethod.Get, params)
|
||||||
client.request(path) {
|
|
||||||
method = HttpMethod.Get
|
|
||||||
header("Authorization", "$tokenType $accessToken")
|
|
||||||
params.forEach {
|
|
||||||
parameter(it.first, it.second)
|
|
||||||
}
|
|
||||||
} as T
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestPost(
|
private suspend fun requestPost(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
bodyObject: JsonObject
|
bodyObject: JsonObject
|
||||||
) = coroutineScope {
|
) {
|
||||||
val path = "$baseUrl$endpoint"
|
val path = "$baseUrl$endpoint"
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject)
|
||||||
val response: HttpResponse = client.request(path) {
|
Log.i(TAG, "Response: $response")
|
||||||
method = HttpMethod.Post
|
|
||||||
body = bodyObject
|
|
||||||
header("Authorization", "$tokenType $accessToken")
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
params.forEach {
|
|
||||||
parameter(it.first, it.second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Response: $response")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestPatch(
|
private suspend fun requestPatch(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
bodyObject: JsonObject
|
bodyObject: JsonObject
|
||||||
) = coroutineScope {
|
) {
|
||||||
val path = "$baseUrl$endpoint"
|
val path = "$baseUrl$endpoint"
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject)
|
||||||
val response: HttpResponse = client.request(path) {
|
Log.i(TAG, "Response: $response")
|
||||||
method = HttpMethod.Patch
|
|
||||||
body = bodyObject
|
|
||||||
header("Authorization", "$tokenType $accessToken")
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
params.forEach {
|
|
||||||
parameter(it.first, it.second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Response: $response")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestDelete(
|
private suspend fun requestDelete(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
params: Parameters = listOf(),
|
params: List<Pair<String, Any?>> = listOf(),
|
||||||
url: String = ""
|
url: String = ""
|
||||||
) = coroutineScope {
|
) = coroutineScope {
|
||||||
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
||||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
val response: HttpResponse = request(path, HttpMethod.Delete, params)
|
||||||
val response: HttpResponse = client.request(path) {
|
Log.i(TAG, "Response: $response")
|
||||||
method = HttpMethod.Delete
|
|
||||||
header("Authorization", "$tokenType $accessToken")
|
|
||||||
params.forEach {
|
|
||||||
parameter(it.first, it.second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Response : $response")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -282,7 +237,7 @@ object Crunchyroll {
|
||||||
): BrowseResult {
|
): BrowseResult {
|
||||||
val browseEndpoint = "/content/v1/browse"
|
val browseEndpoint = "/content/v1/browse"
|
||||||
val noneOptParams = listOf(
|
val noneOptParams = listOf(
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"sort_by" to sortBy.str,
|
"sort_by" to sortBy.str,
|
||||||
"start" to start,
|
"start" to start,
|
||||||
"n" to n
|
"n" to n
|
||||||
|
@ -314,7 +269,12 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
suspend fun search(query: String, n: Int = 10): SearchResult {
|
suspend fun search(query: String, n: Int = 10): SearchResult {
|
||||||
val searchEndpoint = "/content/v1/search"
|
val searchEndpoint = "/content/v1/search"
|
||||||
val parameters = listOf("q" to query, "n" to n, "locale" to locale, "type" to "series")
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"q" to query,
|
||||||
|
"n" to n,
|
||||||
|
"type" to "series"
|
||||||
|
)
|
||||||
|
|
||||||
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
|
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
|
||||||
// to work around this, for now only tv shows are supported
|
// to work around this, for now only tv shows are supported
|
||||||
|
@ -337,7 +297,7 @@ object Crunchyroll {
|
||||||
suspend fun objects(objects: List<String>): Collection<Item> {
|
suspend fun objects(objects: List<String>): Collection<Item> {
|
||||||
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
|
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
|
@ -357,7 +317,7 @@ object Crunchyroll {
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
suspend fun seasonList(): DiscSeasonList {
|
suspend fun seasonList(): DiscSeasonList {
|
||||||
val seasonListEndpoint = "/content/v1/season_list"
|
val seasonListEndpoint = "/content/v1/season_list"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
requestGet(seasonListEndpoint, parameters)
|
requestGet(seasonListEndpoint, parameters)
|
||||||
|
@ -375,9 +335,9 @@ object Crunchyroll {
|
||||||
* series id == crunchyroll id?
|
* series id == crunchyroll id?
|
||||||
*/
|
*/
|
||||||
suspend fun series(seriesId: String): Series {
|
suspend fun series(seriesId: String): Series {
|
||||||
val seriesEndpoint = "/cms/v2/$country/M3/crunchyroll/series/$seriesId"
|
val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
|
@ -398,7 +358,7 @@ object Crunchyroll {
|
||||||
val upNextSeriesEndpoint = "/content/v1/up_next_series"
|
val upNextSeriesEndpoint = "/content/v1/up_next_series"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"series_id" to seriesId,
|
"series_id" to seriesId,
|
||||||
"locale" to locale
|
"locale" to Preferences.preferredLocale.toLanguageTag()
|
||||||
)
|
)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
@ -410,10 +370,10 @@ object Crunchyroll {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun seasons(seriesId: String): Seasons {
|
suspend fun seasons(seriesId: String): Seasons {
|
||||||
val seasonsEndpoint = "/cms/v2/$country/M3/crunchyroll/seasons"
|
val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"series_id" to seriesId,
|
"series_id" to seriesId,
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
|
@ -428,10 +388,10 @@ object Crunchyroll {
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun episodes(seasonId: String): Episodes {
|
suspend fun episodes(seasonId: String): Episodes {
|
||||||
val episodesEndpoint = "/cms/v2/$country/M3/crunchyroll/episodes"
|
val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes"
|
||||||
val parameters = listOf(
|
val parameters = listOf(
|
||||||
"season_id" to seasonId,
|
"season_id" to seasonId,
|
||||||
"locale" to locale,
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
"Signature" to signature,
|
"Signature" to signature,
|
||||||
"Policy" to policy,
|
"Policy" to policy,
|
||||||
"Key-Pair-Id" to keyPairID
|
"Key-Pair-Id" to keyPairID
|
||||||
|
@ -466,7 +426,7 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
suspend fun isWatchlist(seriesId: String): Boolean {
|
suspend fun isWatchlist(seriesId: String): Boolean {
|
||||||
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
val watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
|
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject)
|
||||||
|
@ -484,7 +444,7 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
suspend fun postWatchlist(seriesId: String) {
|
suspend fun postWatchlist(seriesId: String) {
|
||||||
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
|
val watchlistPostEndpoint = "/content/v1/watchlist/$accountID"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val json = buildJsonObject {
|
val json = buildJsonObject {
|
||||||
put("content_id", seriesId)
|
put("content_id", seriesId)
|
||||||
|
@ -500,7 +460,7 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
suspend fun deleteWatchlist(seriesId: String) {
|
suspend fun deleteWatchlist(seriesId: String) {
|
||||||
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
val watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
requestDelete(watchlistDeleteEndpoint, parameters)
|
requestDelete(watchlistDeleteEndpoint, parameters)
|
||||||
}
|
}
|
||||||
|
@ -515,7 +475,7 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
|
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap {
|
||||||
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
|
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
requestGet(playheadsEndpoint, parameters)
|
requestGet(playheadsEndpoint, parameters)
|
||||||
|
@ -527,7 +487,7 @@ object Crunchyroll {
|
||||||
|
|
||||||
suspend fun postPlayheads(episodeId: String, playhead: Int) {
|
suspend fun postPlayheads(episodeId: String, playhead: Int) {
|
||||||
val playheadsEndpoint = "/content/v1/playheads/$accountID"
|
val playheadsEndpoint = "/content/v1/playheads/$accountID"
|
||||||
val parameters = listOf("locale" to locale)
|
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
val json = buildJsonObject {
|
val json = buildJsonObject {
|
||||||
put("content_id", episodeId)
|
put("content_id", episodeId)
|
||||||
|
@ -549,7 +509,10 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
suspend fun watchlist(n: Int = 20): Watchlist {
|
suspend fun watchlist(n: Int = 20): Watchlist {
|
||||||
val watchlistEndpoint = "/content/v1/$accountID/watchlist"
|
val watchlistEndpoint = "/content/v1/$accountID/watchlist"
|
||||||
val parameters = listOf("locale" to locale, "n" to n)
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
val list: ContinueWatchingList = try {
|
val list: ContinueWatchingList = try {
|
||||||
requestGet(watchlistEndpoint, parameters)
|
requestGet(watchlistEndpoint, parameters)
|
||||||
|
@ -570,7 +533,10 @@ object Crunchyroll {
|
||||||
*/
|
*/
|
||||||
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
|
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList {
|
||||||
val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
|
val watchlistEndpoint = "/content/v1/$accountID/up_next_account"
|
||||||
val parameters = listOf("locale" to locale, "n" to n)
|
val parameters = listOf(
|
||||||
|
"locale" to Preferences.preferredLocale.toLanguageTag(),
|
||||||
|
"n" to n
|
||||||
|
)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
requestGet(watchlistEndpoint, parameters)
|
requestGet(watchlistEndpoint, parameters)
|
||||||
|
|
|
@ -1,3 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Teapod
|
||||||
|
*
|
||||||
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation; either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program; if not, write to the Free Software
|
||||||
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
||||||
|
* MA 02110-1301, USA.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
package org.mosad.teapod.parser.crunchyroll
|
package org.mosad.teapod.parser.crunchyroll
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
|
@ -29,8 +51,19 @@ enum class SortBy(val str: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* index, account. This must pe present for the app to work!
|
* token, index, account. This must pe present for the app to work!
|
||||||
*/
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Token(
|
||||||
|
@SerialName("access_token") val accessToken: String,
|
||||||
|
@SerialName("refresh_token") val refreshToken: String,
|
||||||
|
@SerialName("expires_in") val expiresIn: Int,
|
||||||
|
@SerialName("token_type") val tokenType: String,
|
||||||
|
@SerialName("scope") val scope: String,
|
||||||
|
@SerialName("country") val country: String,
|
||||||
|
@SerialName("account_id") val accountId: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Index(
|
data class Index(
|
||||||
@SerialName("cms") val cms: CMS,
|
@SerialName("cms") val cms: CMS,
|
||||||
|
|
|
@ -10,7 +10,7 @@ object Preferences {
|
||||||
|
|
||||||
var preferSecondary = false
|
var preferSecondary = false
|
||||||
internal set
|
internal set
|
||||||
var preferredLocal = Locale.GERMANY
|
var preferredLocale: Locale = Locale.forLanguageTag("en-US") // TODO this should be saved (potential offline usage) but fetched on start
|
||||||
internal set
|
internal set
|
||||||
var autoplay = true
|
var autoplay = true
|
||||||
internal set
|
internal set
|
||||||
|
@ -35,6 +35,15 @@ object Preferences {
|
||||||
this.preferSecondary = preferSecondary
|
this.preferSecondary = preferSecondary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePreferredLocal(context: Context, preferredLocale: Locale) {
|
||||||
|
with(getSharedPref(context).edit()) {
|
||||||
|
putString(context.getString(R.string.save_key_preferred_local), preferredLocale.toLanguageTag())
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preferredLocale = preferredLocale
|
||||||
|
}
|
||||||
|
|
||||||
fun saveAutoplay(context: Context, autoplay: Boolean) {
|
fun saveAutoplay(context: Context, autoplay: Boolean) {
|
||||||
with(getSharedPref(context).edit()) {
|
with(getSharedPref(context).edit()) {
|
||||||
putBoolean(context.getString(R.string.save_key_autoplay), autoplay)
|
putBoolean(context.getString(R.string.save_key_autoplay), autoplay)
|
||||||
|
@ -71,6 +80,11 @@ object Preferences {
|
||||||
preferSecondary = sharedPref.getBoolean(
|
preferSecondary = sharedPref.getBoolean(
|
||||||
context.getString(R.string.save_key_prefer_secondary), false
|
context.getString(R.string.save_key_prefer_secondary), false
|
||||||
)
|
)
|
||||||
|
preferredLocale = Locale.forLanguageTag(
|
||||||
|
sharedPref.getString(
|
||||||
|
context.getString(R.string.save_key_preferred_local), "en-US"
|
||||||
|
) ?: "en-US"
|
||||||
|
)
|
||||||
autoplay = sharedPref.getBoolean(
|
autoplay = sharedPref.getBoolean(
|
||||||
context.getString(R.string.save_key_autoplay), true
|
context.getString(R.string.save_key_autoplay), true
|
||||||
)
|
)
|
||||||
|
|
|
@ -180,18 +180,21 @@ class AccountFragment : Fragment() {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(R.string.settings_content_language)
|
.setTitle(R.string.settings_content_language)
|
||||||
.setSingleChoiceItems(items, initialSelection){ dialog, which ->
|
.setSingleChoiceItems(items, initialSelection){ dialog, which ->
|
||||||
updatePrefContentLanguage(supportedLocals[which].toLanguageTag())
|
updatePrefContentLanguage(supportedLocals[which])
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
@kotlinx.coroutines.ExperimentalCoroutinesApi
|
@kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
private fun updatePrefContentLanguage(languageTag: String) {
|
private fun updatePrefContentLanguage(preferredLocale: Locale) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
Crunchyroll.postPrefSubLanguage(languageTag)
|
Crunchyroll.postPrefSubLanguage(preferredLocale.toLanguageTag())
|
||||||
|
|
||||||
}.invokeOnCompletion {
|
}.invokeOnCompletion {
|
||||||
|
// update the local preferred content language
|
||||||
|
Preferences.savePreferredLocal(requireContext(), preferredLocale)
|
||||||
|
|
||||||
// update profile since the language selection might have changed
|
// update profile since the language selection might have changed
|
||||||
profile = lifecycleScope.async { Crunchyroll.profile() }
|
profile = lifecycleScope.async { Crunchyroll.profile() }
|
||||||
profile.invokeOnCompletion {
|
profile.invokeOnCompletion {
|
||||||
|
|
|
@ -62,7 +62,7 @@ class MediaFragmentViewModel(application: Application) : AndroidViewModel(applic
|
||||||
println(upNextSeries)
|
println(upNextSeries)
|
||||||
|
|
||||||
// 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.preferredLocale)
|
||||||
|
|
||||||
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
|
// load episodes and metaDB in parallel (tmdb needs mediaType, which is set via episodes)
|
||||||
listOf(
|
listOf(
|
||||||
|
|
|
@ -83,7 +83,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||||
var currentPlayback = NonePlayback
|
var currentPlayback = NonePlayback
|
||||||
|
|
||||||
// current playback settings
|
// current playback settings
|
||||||
var currentLanguage: Locale = Preferences.preferredLocal
|
var currentLanguage: Locale = Preferences.preferredLocale
|
||||||
internal set
|
internal set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
|
@ -22,15 +22,17 @@
|
||||||
|
|
||||||
package org.mosad.teapod.util.tmdb
|
package org.mosad.teapod.util.tmdb
|
||||||
|
|
||||||
import com.github.kittinunf.fuel.Fuel
|
import android.util.Log
|
||||||
import com.github.kittinunf.fuel.core.FuelError
|
import io.ktor.client.*
|
||||||
import com.github.kittinunf.fuel.core.Parameters
|
import io.ktor.client.call.*
|
||||||
import com.github.kittinunf.fuel.json.FuelJson
|
import io.ktor.client.features.json.*
|
||||||
import com.github.kittinunf.fuel.json.responseJson
|
import io.ktor.client.features.json.serializer.*
|
||||||
import com.github.kittinunf.result.Result
|
import io.ktor.client.request.*
|
||||||
import kotlinx.coroutines.*
|
import io.ktor.client.statement.*
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.invoke
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.mosad.teapod.util.concatenate
|
import org.mosad.teapod.util.concatenate
|
||||||
|
|
||||||
|
@ -41,8 +43,14 @@ import org.mosad.teapod.util.concatenate
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
class TMDBApiController {
|
class TMDBApiController {
|
||||||
|
private val classTag = javaClass.name
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val client = HttpClient {
|
||||||
|
install(JsonFeature) {
|
||||||
|
serializer = KotlinxSerializer(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val apiUrl = "https://api.themoviedb.org/3"
|
private val apiUrl = "https://api.themoviedb.org/3"
|
||||||
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
|
private val apiKey = "de959cf9c07a08b5ca7cb51cda9a40c2"
|
||||||
|
@ -52,19 +60,22 @@ class TMDBApiController {
|
||||||
const val imageUrl = "https://image.tmdb.org/t/p/w500"
|
const val imageUrl = "https://image.tmdb.org/t/p/w500"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun request(
|
private suspend inline fun <reified T> request(
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
parameters: Parameters = emptyList()
|
parameters: List<Pair<String, Any?>> = emptyList()
|
||||||
): Result<FuelJson, FuelError> = coroutineScope {
|
): T = coroutineScope {
|
||||||
val path = "$apiUrl$endpoint"
|
val path = "$apiUrl$endpoint"
|
||||||
val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters)
|
val params = concatenate(listOf("api_key" to apiKey, "language" to language), parameters)
|
||||||
|
|
||||||
// TODO handle FileNotFoundException
|
// TODO handle FileNotFoundException
|
||||||
return@coroutineScope (Dispatchers.IO) {
|
return@coroutineScope (Dispatchers.IO) {
|
||||||
val (_, _, result) = Fuel.get(path, params)
|
val response: HttpResponse = client.get(path) {
|
||||||
.responseJson()
|
params.forEach {
|
||||||
|
parameter(it.first, it.second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result
|
response.receive<T>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,10 +89,12 @@ class TMDBApiController {
|
||||||
val searchEndpoint = "/search/multi"
|
val searchEndpoint = "/search/multi"
|
||||||
val parameters = listOf("query" to query, "include_adult" to false)
|
val parameters = listOf("query" to query, "include_adult" to false)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(searchEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBSearchMovie
|
Log.e(classTag, "SerializationException in searchMovie(), with query = $query.", ex)
|
||||||
|
NoneTMDBSearchMovie
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -94,10 +107,12 @@ class TMDBApiController {
|
||||||
val searchEndpoint = "/search/tv"
|
val searchEndpoint = "/search/tv"
|
||||||
val parameters = listOf("query" to query, "include_adult" to false)
|
val parameters = listOf("query" to query, "include_adult" to false)
|
||||||
|
|
||||||
val result = request(searchEndpoint, parameters)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(searchEndpoint, parameters)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBSearchTVShow
|
Log.e(classTag, "SerializationException in searchTVShow(), with query = $query.", ex)
|
||||||
|
NoneTMDBSearchTVShow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -109,10 +124,12 @@ class TMDBApiController {
|
||||||
val movieEndpoint = "/movie/$movieId"
|
val movieEndpoint = "/movie/$movieId"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(movieEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(movieEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBMovie
|
Log.e(classTag, "SerializationException in getMovieDetails(), with movieId = $movieId.", ex)
|
||||||
|
NoneTMDBMovie
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -124,10 +141,12 @@ class TMDBApiController {
|
||||||
val tvShowEndpoint = "/tv/$tvId"
|
val tvShowEndpoint = "/tv/$tvId"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(tvShowEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(tvShowEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBTVShow
|
Log.e(classTag, "SerializationException in getTVShowDetails(), with tvId = $tvId.", ex)
|
||||||
|
NoneTMDBTVShow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
|
@ -141,10 +160,12 @@ class TMDBApiController {
|
||||||
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
|
val tvShowSeasonEndpoint = "/tv/$tvId/season/$seasonNumber"
|
||||||
|
|
||||||
// TODO is FileNotFoundException handling needed?
|
// TODO is FileNotFoundException handling needed?
|
||||||
val result = request(tvShowSeasonEndpoint)
|
return try {
|
||||||
return result.component1()?.obj()?.let {
|
request(tvShowSeasonEndpoint)
|
||||||
json.decodeFromString(it.toString())
|
}catch (ex: SerializationException) {
|
||||||
} ?: NoneTMDBTVSeason
|
Log.e(classTag, "SerializationException in getTVSeasonDetails(), with tvId = $tvId, seasonNumber = $seasonNumber.", ex)
|
||||||
|
NoneTMDBTVSeason
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,8 +110,8 @@ data class TMDBTVShow(
|
||||||
|
|
||||||
// use null for nullable types, the gui needs to handle/implement a fallback for null values
|
// use null for nullable types, the gui needs to handle/implement a fallback for null values
|
||||||
val NoneTMDB = TMDBBase(0, "", "", null, null)
|
val NoneTMDB = TMDBBase(0, "", "", null, null)
|
||||||
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "", null, "")
|
val NoneTMDBMovie = TMDBMovie(0, "", "", null, null, "1970-01-01", null, "")
|
||||||
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "", "", "")
|
val NoneTMDBTVShow = TMDBTVShow(0, "", "", null, null, "1970-01-01", "1970-01-01", "")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TMDBTVSeason(
|
data class TMDBTVSeason(
|
||||||
|
|
|
@ -132,6 +132,7 @@
|
||||||
<string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string>
|
<string name="save_key_user_login" translatable="false">org.mosad.teapod.user_login</string>
|
||||||
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
|
<string name="save_key_user_password" translatable="false">org.mosad.teapod.user_password</string>
|
||||||
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
|
<string name="save_key_prefer_secondary" translatable="false">org.mosad.teapod.prefer_secondary</string>
|
||||||
|
<string name="save_key_preferred_local" translatable="false">org.mosad.teapod.preferred_local</string>
|
||||||
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
|
<string name="save_key_autoplay" translatable="false">org.mosad.teapod.autoplay</string>
|
||||||
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>
|
<string name="save_key_dev_settings" translatable="false">org.mosad.teapod.dev.settings</string>
|
||||||
<string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string>
|
<string name="save_key_theme" translatable="false">org.mosad.teapod.theme</string>
|
||||||
|
|
Loading…
Reference in New Issue