49 changed files with 2477 additions and 1398 deletions
@ -1,472 +0,0 @@
|
||||
/** |
||||
* Teapod |
||||
* |
||||
* Copyright 2020-2021 <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 |
||||
|
||||
import android.util.Log |
||||
import com.google.gson.JsonParser |
||||
import kotlinx.coroutines.* |
||||
import org.jsoup.Connection |
||||
import org.jsoup.Jsoup |
||||
import org.mosad.teapod.preferences.EncryptedPreferences |
||||
import org.mosad.teapod.util.* |
||||
import org.mosad.teapod.util.DataTypes.MediaType |
||||
import java.io.IOException |
||||
import java.net.CookieStore |
||||
import java.util.* |
||||
import kotlin.random.Random |
||||
import kotlin.reflect.jvm.jvmName |
||||
|
||||
object AoDParser { |
||||
|
||||
private const val baseUrl = "https://www.anime-on-demand.de" |
||||
private const val loginPath = "/users/sign_in" |
||||
private const val libraryPath = "/animes" |
||||
private const val subscriptionPath = "/mypools" |
||||
|
||||
private const val userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0" |
||||
|
||||
private lateinit var cookieStore: CookieStore |
||||
private var csrfToken: String = "" |
||||
private var loginSuccess = false |
||||
|
||||
private val aodMediaList = arrayListOf<AoDMedia>() // actual media (data) |
||||
|
||||
// gui media |
||||
val guiMediaList = arrayListOf<ItemMedia>() |
||||
val highlightsList = arrayListOf<ItemMedia>() |
||||
val newEpisodesList = arrayListOf<ItemMedia>() |
||||
val newSimulcastsList = arrayListOf<ItemMedia>() |
||||
val newTitlesList = arrayListOf<ItemMedia>() |
||||
val topTenList = arrayListOf<ItemMedia>() |
||||
|
||||
fun login(): Boolean = runBlocking { |
||||
|
||||
withContext(Dispatchers.IO) { |
||||
// get the authenticity token and cookies |
||||
val conAuth = Jsoup.connect(baseUrl + loginPath) |
||||
.header("User-Agent", userAgent) |
||||
|
||||
cookieStore = conAuth.cookieStore() |
||||
csrfToken = conAuth.execute().parse().select("meta[name=csrf-token]").attr("content") |
||||
|
||||
Log.d(AoDParser::class.jvmName, "Received authenticity token: $csrfToken") |
||||
Log.d(AoDParser::class.jvmName, "Received authenticity cookies: $cookieStore") |
||||
|
||||
val data = mapOf( |
||||
Pair("user[login]", EncryptedPreferences.login), |
||||
Pair("user[password]", EncryptedPreferences.password), |
||||
Pair("user[remember_me]", "1"), |
||||
Pair("commit", "Einloggen"), |
||||
Pair("authenticity_token", csrfToken) |
||||
) |
||||
|
||||
val resLogin = Jsoup.connect(baseUrl + loginPath) |
||||
.method(Connection.Method.POST) |
||||
.timeout(60000) // login can take some time default is 60000 (60 sec) |
||||
.data(data) |
||||
.postDataCharset("UTF-8") |
||||
.cookieStore(cookieStore) |
||||
.execute() |
||||
//println(resLogin.body()) |
||||
|
||||
loginSuccess = resLogin.body().contains("Hallo, du bist jetzt angemeldet.") |
||||
Log.i(AoDParser::class.jvmName, "Status: ${resLogin.statusCode()} (${resLogin.statusMessage()}), login successful: $loginSuccess") |
||||
|
||||
loginSuccess |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* initially load all media and home screen data |
||||
*/ |
||||
suspend fun initialLoading() { |
||||
coroutineScope { |
||||
launch { loadHome() } |
||||
launch { listAnimes() } |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* get a media by it's ID (int) |
||||
* @param aodId The AoD ID of the requested media |
||||
* @return returns a AoDMedia of type Movie or TVShow if found, else return AoDMediaNone |
||||
*/ |
||||
suspend fun getMediaById(aodId: Int): AoDMedia { |
||||
return aodMediaList.firstOrNull { it.aodId == aodId } ?: |
||||
try { |
||||
loadMediaAsync(aodId).await().apply { |
||||
aodMediaList.add(this) |
||||
} |
||||
} catch (exn:NullPointerException) { |
||||
Log.e(AoDParser::class.jvmName, "Error while loading media $aodId", exn) |
||||
AoDMediaNone |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* get subscription info from aod website, remove "Anime-Abo" Prefix and trim |
||||
*/ |
||||
suspend fun getSubscriptionInfoAsync(): Deferred<String> { |
||||
return coroutineScope { |
||||
async(Dispatchers.IO) { |
||||
val res = Jsoup.connect(baseUrl + subscriptionPath) |
||||
.cookieStore(cookieStore) |
||||
.get() |
||||
|
||||
return@async res.select("a:contains(Anime-Abo)").text() |
||||
.removePrefix("Anime-Abo").trim() |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun getSubscriptionUrl(): String { |
||||
return baseUrl + subscriptionPath |
||||
} |
||||
|
||||
suspend fun markAsWatched(aodId: Int, episodeId: Int) { |
||||
val episode = getMediaById(aodId).getEpisodeById(episodeId) |
||||
episode.watched = true |
||||
sendCallback(episode.watchedCallback) |
||||
|
||||
Log.d(AoDParser::class.jvmName, "Marked episode ${episode.mediaId} as watched") |
||||
} |
||||
|
||||
// TODO don't use jsoup here |
||||
private suspend fun sendCallback(callbackPath: String) = coroutineScope { |
||||
launch(Dispatchers.IO) { |
||||
val headers = mutableMapOf( |
||||
Pair("Accept", "application/json, text/javascript, */*; q=0.01"), |
||||
Pair("Accept-Language", "de,en-US;q=0.7,en;q=0.3"), |
||||
Pair("Accept-Encoding", "gzip, deflate, br"), |
||||
Pair("X-CSRF-Token", csrfToken), |
||||
Pair("X-Requested-With", "XMLHttpRequest"), |
||||
) |
||||
|
||||
try { |
||||
Jsoup.connect(baseUrl + callbackPath) |
||||
.ignoreContentType(true) |
||||
.cookieStore(cookieStore) |
||||
.headers(headers) |
||||
.execute() |
||||
} catch (ex: IOException) { |
||||
Log.e(AoDParser::class.jvmName, "Callback for $callbackPath failed.", ex) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* load all media from aod into itemMediaList and mediaList |
||||
* TODO private suspend fun listAnimes() = withContext(Dispatchers.IO) should also work, maybe a bug in android studio? |
||||
*/ |
||||
private suspend fun listAnimes() = withContext(Dispatchers.IO) { |
||||
launch(Dispatchers.IO) { |
||||
val resAnimes = Jsoup.connect(baseUrl + libraryPath).get() |
||||
//println(resAnimes) |
||||
|
||||
guiMediaList.clear() |
||||
val animes = resAnimes.select("div.animebox") |
||||
|
||||
guiMediaList.addAll( |
||||
animes.map { |
||||
ItemMedia( |
||||
id = it.select("p.animebox-link").select("a") |
||||
.attr("href").substringAfterLast("/").toInt(), |
||||
title = it.select("h3.animebox-title").text(), |
||||
posterUrl = it.select("p.animebox-image").select("img") |
||||
.attr("src") |
||||
) |
||||
} |
||||
) |
||||
|
||||
Log.i(AoDParser::class.jvmName, "Total library size is: ${guiMediaList.size}") |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* load new episodes, titles and highlights |
||||
*/ |
||||
private suspend fun loadHome() = withContext(Dispatchers.IO) { |
||||
launch(Dispatchers.IO) { |
||||
val resHome = Jsoup.connect(baseUrl).get() |
||||
|
||||
// get highlights from AoD |
||||
highlightsList.clear() |
||||
resHome.select("#aod-highlights").select("div.news-item").forEach { |
||||
val mediaId = it.select("div.news-item-text").select("a.serienlink") |
||||
.attr("href").substringAfterLast("/").toIntOrNull() |
||||
val mediaTitle = it.select("div.news-title").select("h2").text() |
||||
val mediaImage = it.select("img").attr("src") |
||||
|
||||
if (mediaId != null) { |
||||
highlightsList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) |
||||
} |
||||
} |
||||
|
||||
// get all new episodes from AoD |
||||
newEpisodesList.clear() |
||||
resHome.select("h2:contains(Neue Episoden)").next().select("li").forEach { |
||||
val mediaId = it.select("a.thumbs").attr("href") |
||||
.substringAfterLast("/").toIntOrNull() |
||||
val mediaImage = it.select("a.thumbs > img").attr("src") |
||||
val mediaTitle = "${it.select("a").text()} - ${it.select("span.neweps").text()}" |
||||
|
||||
if (mediaId != null) { |
||||
newEpisodesList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) |
||||
} |
||||
} |
||||
|
||||
// get new simulcasts from AoD |
||||
newSimulcastsList.clear() |
||||
resHome.select("h2:contains(Neue Simulcasts)").next().select("li").forEach { |
||||
val mediaId = it.select("a.thumbs").attr("href") |
||||
.substringAfterLast("/").toIntOrNull() |
||||
val mediaImage = it.select("a.thumbs > img").attr("src") |
||||
val mediaTitle = it.select("a").text() |
||||
|
||||
if (mediaId != null) { |
||||
newSimulcastsList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) |
||||
} |
||||
} |
||||
|
||||
// get new titles from AoD |
||||
newTitlesList.clear() |
||||
resHome.select("h2:contains(Neue Anime-Titel)").next().select("li").forEach { |
||||
val mediaId = it.select("a.thumbs").attr("href") |
||||
.substringAfterLast("/").toIntOrNull() |
||||
val mediaImage = it.select("a.thumbs > img").attr("src") |
||||
val mediaTitle = it.select("a").text() |
||||
|
||||
if (mediaId != null) { |
||||
newTitlesList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) |
||||
} |
||||
} |
||||
|
||||
// get top ten from AoD |
||||
topTenList.clear() |
||||
resHome.select("h2:contains(Anime Top 10)").next().select("li").forEach { |
||||
val mediaId = it.select("a.thumbs").attr("href") |
||||
.substringAfterLast("/").toIntOrNull() |
||||
val mediaImage = it.select("a.thumbs > img").attr("src") |
||||
val mediaTitle = it.select("a").text() |
||||
|
||||
if (mediaId != null) { |
||||
topTenList.add(ItemMedia(mediaId, mediaTitle, mediaImage)) |
||||
} |
||||
} |
||||
|
||||
// if highlights is empty, add a random new title |
||||
if (highlightsList.isEmpty()) { |
||||
if (newTitlesList.isNotEmpty()) { |
||||
highlightsList.add(newTitlesList[Random.nextInt(0, newTitlesList.size)]) |
||||
} else { |
||||
highlightsList.add(ItemMedia(0,"", "")) |
||||
} |
||||
} |
||||
|
||||
Log.i(AoDParser::class.jvmName, "loaded home") |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* TODO catch SocketTimeoutException from loading to show a waring dialog |
||||
* Load media async. Every media has a playlist. |
||||
* @param aodId The AoD ID of the requested media |
||||
*/ |
||||
private suspend fun loadMediaAsync(aodId: Int): Deferred<AoDMedia> = coroutineScope { |
||||
return@coroutineScope async (Dispatchers.IO) { |
||||
if (cookieStore.cookies.isEmpty()) login() // TODO is this needed? |
||||
|
||||
// return none object, if login wasn't successful |
||||
if (!loginSuccess) { |
||||
Log.w(AoDParser::class.jvmName, "Login was not successful") |
||||
return@async AoDMediaNone |
||||
} |
||||
|
||||
// get the media page |
||||
val res = Jsoup.connect("$baseUrl/anime/$aodId") |
||||
.cookieStore(cookieStore) |
||||
.get() |
||||
// println(res) |
||||
|
||||
if (csrfToken.isEmpty()) { |
||||
csrfToken = res.select("meta[name=csrf-token]").attr("content") |
||||
Log.d(AoDParser::class.jvmName, "New csrf token is $csrfToken") |
||||
} |
||||
|
||||
// playlist parsing TODO can this be async to the general info parsing? |
||||
val besides = res.select("div.besides").first()!! |
||||
val aodPlaylists = besides.select("input.streamstarter_html5").map { streamstarter -> |
||||
parsePlaylistAsync( |
||||
streamstarter.attr("data-playlist"), |
||||
streamstarter.attr("data-lang") |
||||
) |
||||
} |
||||
|
||||
/** |
||||
* generic aod media data |
||||
*/ |
||||
val title = res.select("h1[itemprop=name]").text() |
||||
val description = res.select("div[itemprop=description]").text() |
||||
val posterURL = res.select("img.fullwidth-image").attr("src") |
||||
val type = when { |
||||
posterURL.contains("films") -> MediaType.MOVIE |
||||
posterURL.contains("series") -> MediaType.TVSHOW |
||||
else -> MediaType.OTHER |
||||
} |
||||
|
||||
var year = 0 |
||||
var age = 0 |
||||
res.select("table.vertical-table").select("tr").forEach { row -> |
||||
when (row.select("th").text().lowercase(Locale.ROOT)) { |
||||
"produktionsjahr" -> year = row.select("td").text().toInt() |
||||
"fsk" -> age = row.select("td").text().toInt() |
||||
} |
||||
} |
||||
|
||||
// similar titles from media page |
||||
val similar = res.select("h2:contains(รhnliche Animes)").next().select("li").mapNotNull { |
||||
val mediaId = it.select("a.thumbs").attr("href") |
||||
.substringAfterLast("/").toIntOrNull() |
||||
val mediaImage = it.select("a.thumbs > img").attr("src") |
||||
val mediaTitle = it.select("a").text() |
||||
|
||||
if (mediaId != null) { |
||||
ItemMedia(mediaId, mediaTitle, mediaImage) |
||||
} else { |
||||
Log.i(AoDParser::class.jvmName, "MediaId for similar to $aodId was null") |
||||
null |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* additional information for episodes: |
||||
* description: a short description of the episode |
||||
* watched: indicates if the episodes has been watched |
||||
* watched callback: url to set watched in aod |
||||
*/ |
||||
val episodesInfo: Map<Int, AoDEpisodeInfo> = if (type == MediaType.TVSHOW) { |
||||
res.select("div.three-box-container > div.episodebox").mapNotNull { episodeBox -> |
||||
// make sure the episode has a streaming link |
||||
if (episodeBox.select("input.streamstarter_html5").isNotEmpty()) { |
||||
val mediaId = episodeBox.select("div.flip-front").attr("id").substringAfter("-").toInt() |
||||
val episodeShortDesc = episodeBox.select("p.episodebox-shorttext").text() |
||||
val episodeWatched = episodeBox.select("div.episodebox-icons > div").hasClass("status-icon-orange") |
||||
val episodeWatchedCallback = episodeBox.select("input.streamstarter_html5").eachAttr("data-playlist").first() |
||||
|
||||
AoDEpisodeInfo(mediaId, episodeShortDesc, episodeWatched, episodeWatchedCallback) |
||||
} else { |
||||
Log.i(AoDParser::class.jvmName, "Episode info for $aodId has empty streamstarter_html5 ") |
||||
null |
||||
} |
||||
}.associateBy { it.aodMediaId } |
||||
} else { |
||||
mapOf() |
||||
} |
||||
|
||||
// map the aod api playlist to a teapod playlist |
||||
val playlist: List<AoDEpisode> = aodPlaylists.awaitAll().flatMap { aodPlaylist -> |
||||
aodPlaylist.list.mapIndexed { index, episode -> |
||||
AoDEpisode( |
||||
mediaId = episode.mediaid, |
||||
title = episode.title, |
||||
description = episode.description, |
||||
shortDesc = episodesInfo[episode.mediaid]?.shortDesc ?: "", |
||||
imageURL = episode.image, |
||||
numberStr = episode.title.substringAfter(", Ep. ", ""), // TODO move to parsePalylist |
||||
index = index, |
||||
watched = episodesInfo[episode.mediaid]?.watched ?: false, |
||||
watchedCallback = episodesInfo[episode.mediaid]?.watchedCallback ?: "", |
||||
streams = mutableListOf(Stream(episode.sources.first().file, aodPlaylist.language)) |
||||
) |
||||
} |
||||
}.groupingBy { it.mediaId }.reduce{ _, accumulator, element -> |
||||
accumulator.copy().also { |
||||
it.streams.addAll(element.streams) |
||||
} |
||||
}.values.toList() |
||||
|
||||
return@async AoDMedia( |
||||
aodId = aodId, |
||||
type = type, |
||||
title = title, |
||||
shortText = description, |
||||
posterURL = posterURL, |
||||
year = year, |
||||
age = age, |
||||
similar = similar, |
||||
playlist = playlist |
||||
) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* don't use Gson().fromJson() as we don't have any control over the api and it may change |
||||
*/ |
||||
private fun parsePlaylistAsync(playlistPath: String, language: String): Deferred<AoDPlaylist> { |
||||
if (playlistPath == "[]") { |
||||
return CompletableDeferred(AoDPlaylist(listOf(), Locale.ROOT)) |
||||
} |
||||
|
||||
return CoroutineScope(Dispatchers.IO).async(Dispatchers.IO) { |
||||
val headers = mutableMapOf( |
||||
Pair("Accept", "application/json, text/javascript, */*; q=0.01"), |
||||
Pair("Accept-Language", "de,en-US;q=0.7,en;q=0.3"), |
||||
Pair("Accept-Encoding", "gzip, deflate, br"), |
||||
Pair("X-CSRF-Token", csrfToken), |
||||
Pair("X-Requested-With", "XMLHttpRequest"), |
||||
) |
||||
|
||||
//println("loading streaminfo with cstf: $csrfToken") |
||||
|
||||
val res = Jsoup.connect(baseUrl + playlistPath) |
||||
.ignoreContentType(true) |
||||
.cookieStore(cookieStore) |
||||
.headers(headers) |
||||
.timeout(120000) // loading the playlist can take some time |
||||
.execute() |
||||
|
||||
//Gson().fromJson(res.body(), AoDObject::class.java) |
||||
|
||||
return@async AoDPlaylist(JsonParser.parseString(res.body()).asJsonObject |
||||
.get("playlist").asJsonArray.map { |
||||
Playlist( |
||||
sources = it.asJsonObject.get("sources").asJsonArray.map { source -> |
||||
Source(source.asJsonObject.get("file").asString) |
||||
}, |
||||
image = it.asJsonObject.get("image").asString, |
||||
title = it.asJsonObject.get("title").asString, |
||||
description = it.asJsonObject.get("description").asString, |
||||
mediaid = it.asJsonObject.get("mediaid").asInt |
||||
) |
||||
}, |
||||
// TODO improve language handling (via display language etc.) |
||||
language = when (language) { |
||||
"ger" -> Locale.GERMAN |
||||
"jap" -> Locale.JAPANESE |
||||
else -> Locale.ROOT |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,588 @@
|
||||
/** |
||||
* 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 |
||||
|
||||
import android.util.Log |
||||
import io.ktor.client.* |
||||
import io.ktor.client.call.* |
||||
import io.ktor.client.features.json.* |
||||
import io.ktor.client.features.json.serializer.* |
||||
import io.ktor.client.request.* |
||||
import io.ktor.client.request.forms.* |
||||
import io.ktor.client.statement.* |
||||
import io.ktor.http.* |
||||
import kotlinx.coroutines.* |
||||
import kotlinx.serialization.SerializationException |
||||
import kotlinx.serialization.json.Json |
||||
import kotlinx.serialization.json.JsonObject |
||||
import kotlinx.serialization.json.buildJsonObject |
||||
import kotlinx.serialization.json.put |
||||
import org.mosad.teapod.preferences.EncryptedPreferences |
||||
import org.mosad.teapod.preferences.Preferences |
||||
import org.mosad.teapod.util.concatenate |
||||
|
||||
private val json = Json { ignoreUnknownKeys = true } |
||||
|
||||
object Crunchyroll { |
||||
private val TAG = javaClass.name |
||||
|
||||
private val client = HttpClient { |
||||
install(JsonFeature) { |
||||
serializer = KotlinxSerializer(json) |
||||
} |
||||
} |
||||
private const val baseUrl = "https://beta-api.crunchyroll.com" |
||||
private const val basicApiTokenUrl = "https://gitlab.com/-/snippets/2274956/raw/main/snippetfile1.txt" |
||||
private var basicApiToken: String = "" |
||||
|
||||
private lateinit var token: Token |
||||
private var tokenValidUntil: Long = 0 |
||||
|
||||
private var accountID = "" |
||||
|
||||
private var policy = "" |
||||
private var signature = "" |
||||
private var keyPairID = "" |
||||
|
||||
private val browsingCache = arrayListOf<Item>() |
||||
|
||||
/** |
||||
* Load the pai token, see: |
||||
* https://git.mosad.xyz/mosad/NonePublicIssues/issues/1 |
||||
* |
||||
* TODO handle empty file |
||||
*/ |
||||
fun initBasicApiToken() = runBlocking { |
||||
withContext(Dispatchers.IO) { |
||||
basicApiToken = (client.get(basicApiTokenUrl) as HttpResponse).readText() |
||||
Log.i(TAG, "basic auth token: $basicApiToken") |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Login to the crunchyroll API. |
||||
* |
||||
* @param username The Username/Email of the user to log in |
||||
* @param password The Accounts Password |
||||
* |
||||
* @return Boolean: True if login was successful, else false |
||||
*/ |
||||
fun login(username: String, password: String): Boolean = runBlocking { |
||||
val tokenEndpoint = "/auth/v1/token" |
||||
val formData = Parameters.build { |
||||
append("username", username) |
||||
append("password", password) |
||||
append("grant_type", "password") |
||||
append("scope", "offline_access") |
||||
} |
||||
|
||||
var success = false// is false |
||||
withContext(Dispatchers.IO) { |
||||
// TODO handle exceptions |
||||
val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) { |
||||
header("Authorization", "Basic $basicApiToken") |
||||
} |
||||
token = response.receive() |
||||
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000) |
||||
|
||||
Log.i(TAG, "login complete with code ${response.status}") |
||||
success = (response.status == HttpStatusCode.OK) |
||||
} |
||||
|
||||
return@runBlocking success |
||||
} |
||||
|
||||
private fun refreshToken() { |
||||
login(EncryptedPreferences.login, EncryptedPreferences.password) |
||||
} |
||||
|
||||
/** |
||||
* Requests: get, post, delete |
||||
*/ |
||||
|
||||
private suspend inline fun <reified T> request( |
||||
url: String, |
||||
httpMethod: HttpMethod, |
||||
params: List<Pair<String, Any?>> = listOf(), |
||||
bodyObject: Any = Any() |
||||
): T = coroutineScope { |
||||
if (System.currentTimeMillis() > tokenValidUntil) refreshToken() |
||||
|
||||
return@coroutineScope (Dispatchers.IO) { |
||||
val response: T = client.request(url) { |
||||
method = httpMethod |
||||
header("Authorization", "${token.tokenType} ${token.accessToken}") |
||||
params.forEach { |
||||
parameter(it.first, it.second) |
||||
} |
||||
|
||||
// for json set body and content type |
||||
if (bodyObject is JsonObject) { |
||||
body = bodyObject |
||||
contentType(ContentType.Application.Json) |
||||
} |
||||
} |
||||
|
||||
response |
||||
} |
||||
} |
||||
|
||||
private suspend inline fun <reified T> requestGet( |
||||
endpoint: String, |
||||
params: List<Pair<String, Any?>> = listOf(), |
||||
url: String = "" |
||||
): T { |
||||
val path = url.ifEmpty { "$baseUrl$endpoint" } |
||||
|
||||
return request(path, HttpMethod.Get, params) |
||||
} |
||||
|
||||
private suspend fun requestPost( |
||||
endpoint: String, |
||||
params: List<Pair<String, Any?>> = listOf(), |
||||
bodyObject: JsonObject |
||||
) { |
||||
val path = "$baseUrl$endpoint" |
||||
|
||||
val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject) |
||||
Log.i(TAG, "Response: $response") |
||||
} |
||||
|
||||
private suspend fun requestPatch( |
||||
endpoint: String, |
||||
params: List<Pair<String, Any?>> = listOf(), |
||||
bodyObject: JsonObject |
||||
) { |
||||
val path = "$baseUrl$endpoint" |
||||
|
||||
val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject) |
||||
Log.i(TAG, "Response: $response") |
||||
} |
||||
|
||||
private suspend fun requestDelete( |
||||
endpoint: String, |
||||
params: List<Pair<String, Any?>> = listOf(), |
||||
url: String = "" |
||||
) = coroutineScope { |
||||
val path = url.ifEmpty { "$baseUrl$endpoint" } |
||||
|
||||
val response: HttpResponse = request(path, HttpMethod.Delete, params) |
||||
Log.i(TAG, "Response: $response") |
||||
} |
||||
|
||||
/** |
||||
* 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. |
||||
*/ |
||||
suspend fun index() { |
||||
val indexEndpoint = "/index/v2" |
||||
|
||||
val index: Index = requestGet(indexEndpoint) |
||||
policy = index.cms.policy |
||||
signature = index.cms.signature |
||||
keyPairID = index.cms.keyPairId |
||||
|
||||
Log.i(TAG, "Policy : $policy") |
||||
Log.i(TAG, "Signature : $signature") |
||||
Log.i(TAG, "Key Pair ID : $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 account: Account = try { |
||||
requestGet(indexEndpoint) |
||||
} catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in account(). This is bad!", ex) |
||||
NoneAccount |
||||
} |
||||
|
||||
accountID = account.accountId |
||||
} |
||||
|
||||
/** |
||||
* General element/media functions: browse, search, objects, season_list |
||||
*/ |
||||
|
||||
// TODO categories |
||||
/** |
||||
* Browse the media available on crunchyroll. |
||||
* |
||||
* @param sortBy |
||||
* @param n Number of items to return, defaults to 10 |
||||
* |
||||
* @return A **[BrowseResult]** object is returned. |
||||
*/ |
||||
suspend fun browse( |
||||
sortBy: SortBy = SortBy.ALPHABETICAL, |
||||
seasonTag: String = "", |
||||
start: Int = 0, |
||||
n: Int = 10 |
||||
): BrowseResult { |
||||
val browseEndpoint = "/content/v1/browse" |
||||
val noneOptParams = listOf( |
||||
"locale" to Preferences.preferredLocale.toLanguageTag(), |
||||
"sort_by" to sortBy.str, |
||||
"start" to start, |
||||
"n" to n |
||||
) |
||||
|
||||
// if a season tag is present add it to the parameters |
||||
val parameters = if (seasonTag.isNotEmpty()) { |
||||
concatenate(noneOptParams, listOf("season_tag" to seasonTag)) |
||||
} else { |
||||
noneOptParams |
||||
} |
||||
|
||||
val browseResult: BrowseResult = try { |
||||
requestGet(browseEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in browse().", ex) |
||||
NoneBrowseResult |
||||
} |
||||
|
||||
// add results to cache TODO improve |
||||
browsingCache.clear() |
||||
browsingCache.addAll(browseResult.items) |
||||
|
||||
return browseResult |
||||
} |
||||
|
||||
/** |
||||
* TODO |
||||
*/ |
||||
suspend fun search(query: String, n: Int = 10): SearchResult { |
||||
val searchEndpoint = "/content/v1/search" |
||||
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, |
||||
// to work around this, for now only tv shows are supported |
||||
|
||||
return try { |
||||
requestGet(searchEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in search(), with query = \"$query\".", ex) |
||||
NoneSearchResult |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get a collection of series objects. |
||||
* Note: episode objects are currently not supported |
||||
* |
||||
* @param objects The object IDs as list of Strings |
||||
* @return A **[Collection]** of Panels |
||||
*/ |
||||
suspend fun objects(objects: List<String>): Collection<Item> { |
||||
val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}" |
||||
val parameters = listOf( |
||||
"locale" to Preferences.preferredLocale.toLanguageTag(), |
||||
"Signature" to signature, |
||||
"Policy" to policy, |
||||
"Key-Pair-Id" to keyPairID |
||||
) |
||||
|
||||
return try { |
||||
requestGet(episodesEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in objects().", ex) |
||||
NoneCollection |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* List all available seasons as **[SeasonListItem]**. |
||||
*/ |
||||
@Suppress("unused") |
||||
suspend fun seasonList(): DiscSeasonList { |
||||
val seasonListEndpoint = "/content/v1/season_list" |
||||
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |
||||
|
||||
return try { |
||||
requestGet(seasonListEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in seasonList().", ex) |
||||
NoneDiscSeasonList |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Main media functions: series, season, episodes, playback |
||||
*/ |
||||
|
||||
/** |
||||
* series id == crunchyroll id? |
||||
*/ |
||||
suspend fun series(seriesId: String): Series { |
||||
val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId" |
||||
val parameters = listOf( |
||||
"locale" to Preferences.preferredLocale.toLanguageTag(), |
||||
"Signature" to signature, |
||||
"Policy" to policy, |
||||
"Key-Pair-Id" to keyPairID |
||||
) |
||||
|
||||
return try { |
||||
requestGet(seriesEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in series().", ex) |
||||
NoneSeries |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* TODO |
||||
*/ |
||||
suspend fun upNextSeries(seriesId: String): UpNextSeriesItem { |
||||
val upNextSeriesEndpoint = "/content/v1/up_next_series" |
||||
val parameters = listOf( |
||||
"series_id" to seriesId, |
||||
"locale" to Preferences.preferredLocale.toLanguageTag() |
||||
) |
||||
|
||||
return try { |
||||
requestGet(upNextSeriesEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in upNextSeries().", ex) |
||||
NoneUpNextSeriesItem |
||||
} |
||||
} |
||||
|
||||
suspend fun seasons(seriesId: String): Seasons { |
||||
val seasonsEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/seasons" |
||||
val parameters = listOf( |
||||
"series_id" to seriesId, |
||||
"locale" to Preferences.preferredLocale.toLanguageTag(), |
||||
"Signature" to signature, |
||||
"Policy" to policy, |
||||
"Key-Pair-Id" to keyPairID |
||||
) |
||||
|
||||
return try { |
||||
requestGet(seasonsEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in seasons().", ex) |
||||
NoneSeasons |
||||
} |
||||
} |
||||
|
||||
suspend fun episodes(seasonId: String): Episodes { |
||||
val episodesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/episodes" |
||||
val parameters = listOf( |
||||
"season_id" to seasonId, |
||||
"locale" to Preferences.preferredLocale.toLanguageTag(), |
||||
"Signature" to signature, |
||||
"Policy" to policy, |
||||
"Key-Pair-Id" to keyPairID |
||||
) |
||||
|
||||
return try { |
||||
requestGet(episodesEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in episodes().", ex) |
||||
NoneEpisodes |
||||
} |
||||
} |
||||
|
||||
suspend fun playback(url: String): Playback { |
||||
return try { |
||||
requestGet("", url = url) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in playback(), with url = $url.", ex) |
||||
NonePlayback |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Additional media functions: watchlist (series), 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 watchlistSeriesEndpoint = "/content/v1/watchlist/$accountID/$seriesId" |
||||
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |
||||
|
||||
return try { |
||||
(requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) |
||||
.containsKey(seriesId) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in isWatchlist() with seriesId = $seriesId", ex) |
||||
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 watchlistPostEndpoint = "/content/v1/watchlist/$accountID" |
||||
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |
||||
|
||||
val json = buildJsonObject { |
||||
put("content_id", seriesId) |
||||
} |
||||
|
||||
requestPost(watchlistPostEndpoint, parameters, json) |
||||
} |
||||
|
||||
/** |
||||
* 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 watchlistDeleteEndpoint = "/content/v1/watchlist/$accountID/$seriesId" |
||||
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |
||||
|
||||
requestDelete(watchlistDeleteEndpoint, parameters) |
||||
} |
||||
|
||||
/** |
||||
* Get playhead information for all episodes in episodeIDs. |
||||
* The Information returned contains the playhead position, watched state |
||||
* and last modified date. |
||||
* |
||||
* @param episodeIDs A **[List]** of episodes IDs as strings. |
||||
* @return A **[Map]**<String, **[PlayheadObject]**> containing playback info. |
||||
*/ |
||||
suspend fun playheads(episodeIDs: List<String>): PlayheadsMap { |
||||
val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}" |
||||
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |
||||
|
||||
return try { |
||||
requestGet(playheadsEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in upNextSeries().", ex) |
||||
emptyMap() |
||||
} |
||||
} |
||||
|
||||
suspend fun postPlayheads(episodeId: String, playhead: Int) { |
||||
val playheadsEndpoint = "/content/v1/playheads/$accountID" |
||||
val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) |
||||
|
||||
val json = buildJsonObject { |
||||
put("content_id", episodeId) |
||||
put("playhead", playhead) |
||||
} |
||||
|
||||
requestPost(playheadsEndpoint, parameters, json) |
||||
} |
||||
|
||||
/** |
||||
* Listing functions: watchlist (list), up_next_account |
||||
*/ |
||||
|
||||
/** |
||||
* List items present in the watchlist. |
||||
* |
||||
* @param n Number of items to return, defaults to 20. |
||||
* @return A **[Watchlist]** containing up to n **[Item]**. |
||||
*/ |
||||
suspend fun watchlist(n: Int = 20): Watchlist { |
||||
val watchlistEndpoint = "/content/v1/$accountID/watchlist" |
||||
val parameters = listOf( |
||||
"locale" to Preferences.preferredLocale.toLanguageTag(), |
||||
"n" to n |
||||
) |
||||
|
||||
val list: ContinueWatchingList = try { |
||||
requestGet(watchlistEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in watchlist().", ex) |
||||
NoneContinueWatchingList |
||||
} |
||||
|
||||
val objects = list.items.map{ it.panel.episodeMetadata.seriesId } |
||||
return objects(objects) |
||||
} |
||||
|
||||
/** |
||||
* List the next up episodes for the logged in account. |
||||
* |
||||
* @param n Number of items to return, defaults to 20. |
||||
* @return A **[ContinueWatchingList]** containing up to n **[ContinueWatchingItem]**. |
||||
*/ |
||||
suspend fun upNextAccount(n: Int = 20): ContinueWatchingList { |
||||
val watchlistEndpoint = "/content/v1/$accountID/up_next_account" |
||||
val parameters = listOf( |
||||
"locale" to Preferences.preferredLocale.toLanguageTag(), |
||||
"n" to n |
||||
) |
||||
|
||||
return try { |
||||
requestGet(watchlistEndpoint, parameters) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in upNextAccount().", ex) |
||||
NoneContinueWatchingList |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Account/Profile functions |
||||
*/ |
||||
|
||||
suspend fun profile(): Profile { |
||||
val profileEndpoint = "/accounts/v1/me/profile" |
||||
|
||||
return try { |
||||
requestGet(profileEndpoint) |
||||
}catch (ex: SerializationException) { |
||||
Log.e(TAG, "SerializationException in profile().", ex) |
||||
NoneProfile |
||||
} |
||||
} |
||||
|
||||
suspend fun postPrefSubLanguage(languageTag: String) { |
||||
val profileEndpoint = "/accounts/v1/me/profile" |
||||
val json = buildJsonObject { |
||||
put("preferred_content_subtitle_language", languageTag) |
||||
} |
||||
|
||||
requestPatch(profileEndpoint, bodyObject = json) |
||||
} |
||||
|
||||
} |
@ -0,0 +1,379 @@
|
||||
/** |
||||
* 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 |
||||
|
||||
import kotlinx.serialization.SerialName |
||||
import kotlinx.serialization.Serializable |
||||
import java.util.* |
||||
|
||||
val supportedLocals = listOf( |
||||
Locale.forLanguageTag("ar-SA"), |
||||
Locale.forLanguageTag("de-DE"), |
||||
Locale.forLanguageTag("en-US"), |
||||
Locale.forLanguageTag("es-419"), |
||||
Locale.forLanguageTag("es-ES"), |
||||
Locale.forLanguageTag("fr-FR"), |
||||
Locale.forLanguageTag("it-IT"), |
||||
Locale.forLanguageTag("pt-BR"), |
||||
Locale.forLanguageTag("pt-PT"), |
||||
Locale.forLanguageTag("ru-RU"), |
||||
Locale.ROOT |
||||
) |
||||
|
||||
/** |
||||
* data classes for browse |
||||
* TODO make class names more clear/possibly overlapping for now |
||||
*/ |
||||
enum class SortBy(val str: String) { |
||||
ALPHABETICAL("alphabetical"), |
||||
NEWLY_ADDED("newly_added"), |
||||
POPULARITY("popularity") |
||||
} |
||||
|
||||
/** |
||||
* 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 |
||||
data class Index( |
||||
@SerialName("cms") val cms: CMS, |
||||
@SerialName("service_available") val serviceAvailable: Boolean, |
||||
) |
||||
|
||||
@Serializable |
||||
data class CMS( |
||||
@SerialName("bucket") val bucket: String, |
||||
@SerialName("policy") val policy: String, |
||||
@SerialName("signature") val signature: String, |
||||
@SerialName("key_pair_id") val keyPairId: String, |
||||
@SerialName("expires") val expires: String, |
||||
) |
||||
|
||||
@Serializable |
||||
data class Account( |
||||
@SerialName("account_id") val accountId: String, |
||||
@SerialName("external_id") val externalId: String, |
||||
@SerialName("email_verified") val emailVerified: Boolean, |
||||
@SerialName("created") val created: String, |
||||
) |
||||
val NoneAccount = Account("", "", false, "") |
||||
|
||||
/** |
||||
* search, browse, DiscSeasonList, Watchlist, ContinueWatchingList data types all use Collection |
||||
*/ |
||||
|
||||
@Serializable |
||||
data class Collection<T>( |
||||
@SerialName("total") val total: Int, |
||||
@SerialName("items") val items: List<T> |
||||
) |
||||
|
||||
typealias SearchResult = Collection<SearchCollection> |
||||
typealias SearchCollection = Collection<Item> |
||||
typealias BrowseResult = Collection<Item> |
||||
typealias DiscSeasonList = Collection<SeasonListItem> |
||||
typealias Watchlist = Collection<Item> |
||||
typealias ContinueWatchingList = Collection<ContinueWatchingItem> |
||||
|
||||
@Serializable |
||||
data class UpNextSeriesItem( |
||||
@SerialName("playhead") val playhead: Int, |
||||
@SerialName("fully_watched") val fullyWatched: Boolean, |
||||
@SerialName("never_watched") val neverWatched: Boolean, |
||||
@SerialName("panel") val panel: EpisodePanel, |
||||
) |
||||
|
||||
/** |
||||
* panel data classes |
||||
*/ |
||||
|
||||
// the data class Item is used in browse and search |
||||
// TODO rename to MediaPanel |
||||
@Serializable |
||||
data class Item( |
||||
val id: String, |
||||
val title: String, |
||||
val type: String, |
||||
val channel_id: String, |
||||
val description: String, |
||||
val images: Images |
||||
// TODO series_metadata etc. |
||||
) |
||||
|
||||
@Serializable |
||||
data class Images(val poster_tall: List<List<Poster>>, val poster_wide: List<List<Poster>>) |
||||
// crunchyroll why? |
||||
|
||||
@Serializable |
||||
data class Poster(val height: Int, val width: Int, val source: String, val type: String) |
||||
|
||||
/** |
||||