Merge pull request 'crunchyroll support' (#49) from feature/crunchyroll into develop

Reviewed-on: #49
This commit is contained in:
Jannik 2022-03-19 20:42:54 +01:00
commit 0aece1d8fa
49 changed files with 2485 additions and 1406 deletions

View File

@ -1,14 +1,13 @@
# Teapod
Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all your favourite animes from AoD on your android device. To use Teapod you need to have a subscription to AoD.
Teapod is a unofficial App for Crunchyroll. It allows you to watch all your favourite animes from Crunchyroll on your android device. To use Teapod you need to have a account at Crunchyroll.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">](https://f-droid.org/de/packages/org.mosad.teapod/)
## Features
* Watch all animes from AoD on your Android device
* Watch all animes from Crunchyroll on your Android device
* Native Player based on ExoPayer
* Prefer the OmU version via the app settings
* Save your favorite animes to "My List"
## Screenshots
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Home.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Home.webp)
@ -17,14 +16,14 @@ Teapod is a unofficial App for Anime on Demand (AoD). It allows you to watch all
[<img src="https://www.mosad.xyz/images/Teapod/Teapod_Search.webp" width=180>](https://www.mosad.xyz/images/Teapod/Teapod_Search.webp)
### License
Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Anime on Demand in any way. But they allow open source apps for their service.
Teapod is licensed under the terms and conditions of GPL 3. This Project is not associated with Crunchyroll in any way.
### Contributing
Currentl you need to have an AoD account to contrtibut to Teapod. Contributing without on is kind of impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write me an email.
Currently you need to have an Crunchyroll account to contribute to Teapod. Contributing without one is impossible.If you want to contribute to Teapod and need an account on this gitea instance, please write an email.
### [FAQ](https://git.mosad.xyz/Seil0/teapod/wiki#hilfe)
#### Why is it called Teapod?
Teapod is a Acronym for "The ultimate anime app on demand", hence this project is called Teapod and not Teapot.
Teapod © 2020-2021 [@Seil0](https://git.mosad.xyz/Seil0)
Teapod © 2020-2022 [@Seil0](https://git.mosad.xyz/Seil0)

View File

@ -1,6 +1,9 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'org.jetbrains.kotlin.plugin.serialization' version "$kotlin_version"
}
android {
compileSdkVersion 30
@ -10,8 +13,8 @@ android {
applicationId "org.mosad.teapod"
minSdkVersion 23
targetSdkVersion 30
versionCode 4200 //00.04.200
versionName "0.5.0-alpha2"
versionCode 9000 //00.09.000
versionName "1.0.0-beta1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "build_time", buildTime()
@ -43,6 +46,7 @@ dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
@ -55,18 +59,21 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
implementation 'com.google.android.material:material:1.4.0'
implementation 'com.google.code.gson:gson:2.8.8'
implementation 'com.google.code.gson:gson:2.8.8' // TODO remove, still used by metadb
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-hls:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
implementation 'org.jsoup:jsoup:1.14.2'
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation 'jp.wasabeef:glide-transformations:4.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:core:3.3.0' // TODO remove once unused
implementation 'com.afollestad.material-dialogs:bottomsheets:3.3.0' // TODO remove once unused
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version"
implementation "io.ktor:ktor-client-serialization:$ktor_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'

View File

@ -22,10 +22,40 @@
#-renamesourcefileattribute SourceFile
-keep class org.mosad.teapod.util.** { <fields>; }
-keep class org.json.** { *; }
#Gson
-keepattributes Signature
-dontwarn sun.misc.**
# kotlinx.serialization
# Keep `Companion` object fields of serializable classes.
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <1>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
# Keep `INSTANCE.serializer()` of serializable objects.
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
#misc
-dontwarn java.lang.instrument.ClassFileTransformer
-dontwarn java.lang.ClassValue

View File

@ -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
}
)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
/**
* season list data classes
*/
@Serializable
data class SeasonListItem(
@SerialName("id") val id: String,
@SerialName("localization") val localization: SeasonListLocalization
)
@Serializable
data class SeasonListLocalization(
@SerialName("title") val title: String,
@SerialName("description") val description: String,
)
/**
* continue_watching_item data classes
*/
@Serializable
data class ContinueWatchingItem(
@SerialName("panel") val panel: EpisodePanel,
@SerialName("new") val new: Boolean,
@SerialName("new_content") val newContent: Boolean,
// not present in up_next_account -> continue_watching_item
// @SerialName("is_favorite") val isFavorite: Boolean,
// @SerialName("never_watched") val neverWatched: Boolean,
// @SerialName("completion_status") val completionStatus: Boolean,
@SerialName("playhead") val playhead: Int,
// not present in watchlist -> continue_watching_item
@SerialName("fully_watched") val fullyWatched: Boolean = false,
)
// EpisodePanel is used in ContinueWatchingItem
@Serializable
data class EpisodePanel(
@SerialName("id") val id: String,
@SerialName("title") val title: String,
@SerialName("type") val type: String,
@SerialName("channel_id") val channelId: String,
@SerialName("description") val description: String,
@SerialName("episode_metadata") val episodeMetadata: EpisodeMetadata,
@SerialName("images") val images: Thumbnail,
@SerialName("playback") val playback: String,
)
@Serializable
data class EpisodeMetadata(
@SerialName("duration_ms") val durationMs: Int,
@SerialName("season_id") val seasonId: String,
@SerialName("series_id") val seriesId: String,
@SerialName("series_title") val seriesTitle: String,
)
val NoneItem = Item("", "", "", "", "", Images(emptyList(), emptyList()))
val NoneEpisodeMetadata = EpisodeMetadata(0, "", "", "")
val NoneEpisodePanel = EpisodePanel("", "", "", "", "", NoneEpisodeMetadata, Thumbnail(listOf()), "")
val NoneCollection = Collection<Item>(0, emptyList())
val NoneSearchResult = SearchResult(0, emptyList())
val NoneBrowseResult = BrowseResult(0, emptyList())
val NoneDiscSeasonList = DiscSeasonList(0, emptyList())
val NoneContinueWatchingList = ContinueWatchingList(0, emptyList())
val NoneUpNextSeriesItem = UpNextSeriesItem(0, false, false, NoneEpisodePanel)
/**
* Series data type
*/
@Serializable
data class Series(
@SerialName("id") val id: String,
@SerialName("title") val title: String,
@SerialName("description") val description: String,
@SerialName("images") val images: Images,
@SerialName("maturity_ratings") val maturityRatings: List<String>
)
val NoneSeries = Series("", "", "", Images(emptyList(), emptyList()), emptyList())
/**
* Seasons data type
*/
@Serializable
data class Seasons(
@SerialName("total") val total: Int,
@SerialName("items") val items: List<Season>
) {
fun getPreferredSeason(local: Locale): Season {
return items.firstOrNull { season ->
// try to get the the first seasons which matches the preferred local
season.slugTitle.endsWith("${local.getDisplayLanguage(Locale.ENGLISH)}-dub", true)
} ?: items.firstOrNull { season ->
// if there is no season with the preferred local, try to find a subbed season
season.isSubbed
} ?: items.first() // if no preferred language and no sub, use the first season
}
}
@Serializable
data class Season(
@SerialName("id") val id: String,