Initial commit
7
src/all/animeonsen/build.gradle
Normal file
|
@ -0,0 +1,7 @@
|
|||
ext {
|
||||
extName = 'AnimeOnsen'
|
||||
extClass = '.AnimeOnsen'
|
||||
extVersionCode = 7
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/all/animeonsen/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
src/all/animeonsen/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
src/all/animeonsen/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5.6 KiB |
BIN
src/all/animeonsen/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
src/all/animeonsen/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
src/all/animeonsen/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 164 KiB |
|
@ -0,0 +1,56 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animeonsen
|
||||
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
|
||||
class AOAPIInterceptor(client: OkHttpClient) : Interceptor {
|
||||
|
||||
private val token: String
|
||||
|
||||
init {
|
||||
token = try {
|
||||
val body = """
|
||||
{
|
||||
"client_id": "f296be26-28b5-4358-b5a1-6259575e23b7",
|
||||
"client_secret": "349038c4157d0480784753841217270c3c5b35f4281eaee029de21cb04084235",
|
||||
"grant_type": "client_credentials"
|
||||
}
|
||||
""".trimIndent().toRequestBody("application/json".toMediaType())
|
||||
|
||||
val headers = Headers.headersOf("user-agent", AO_USER_AGENT)
|
||||
|
||||
val tokenResponse = client.newCall(
|
||||
POST(
|
||||
"https://auth.animeonsen.xyz/oauth/token",
|
||||
headers,
|
||||
body,
|
||||
),
|
||||
).execute().body.string()
|
||||
|
||||
val tokenObject = Json.decodeFromString<JsonObject>(tokenResponse)
|
||||
|
||||
tokenObject["access_token"]!!.jsonPrimitive.content
|
||||
} catch (_: Throwable) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
return chain.proceed(newRequest)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,192 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animeonsen
|
||||
|
||||
import android.app.Application
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.AnimeDetails
|
||||
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.AnimeListItem
|
||||
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.AnimeListResponse
|
||||
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.EpisodeDto
|
||||
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.SearchResponse
|
||||
import eu.kanade.tachiyomi.animeextension.all.animeonsen.dto.VideoData
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class AnimeOnsen : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
|
||||
override val name = "AnimeOnsen"
|
||||
|
||||
override val baseUrl = "https://animeonsen.xyz"
|
||||
|
||||
private val apiUrl = "https://api.animeonsen.xyz/v4"
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
override val client by lazy {
|
||||
network.client.newBuilder()
|
||||
.addInterceptor(AOAPIInterceptor(network.client))
|
||||
.build()
|
||||
}
|
||||
|
||||
private val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().add("user-agent", AO_USER_AGENT)
|
||||
|
||||
// ============================== Popular ===============================
|
||||
// The site doesn't have a popular anime tab, so we use the home page instead (latest anime).
|
||||
override fun popularAnimeRequest(page: Int) =
|
||||
GET("$apiUrl/content/index?start=${(page - 1) * 20}&limit=20")
|
||||
|
||||
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||
val responseJson = response.parseAs<AnimeListResponse>()
|
||||
val animes = responseJson.content.map { it.toSAnime() }
|
||||
// we can't (easily) serialize this thing because it returns a array with
|
||||
// two types: a boolean and a integer.
|
||||
val hasNextPage = responseJson.cursor.next.firstOrNull()?.jsonPrimitive?.boolean == true
|
||||
return AnimesPage(animes, hasNextPage)
|
||||
}
|
||||
|
||||
// =============================== Latest ===============================
|
||||
override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
|
||||
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
// =============================== Search ===============================
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList) =
|
||||
GET("$apiUrl/search/$query")
|
||||
|
||||
override fun searchAnimeParse(response: Response): AnimesPage {
|
||||
val searchResult = response.parseAs<SearchResponse>().result
|
||||
val results = searchResult.map { it.toSAnime() }
|
||||
return AnimesPage(results, false)
|
||||
}
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
override fun animeDetailsRequest(anime: SAnime) = GET("$apiUrl/content/${anime.url}/extensive")
|
||||
|
||||
override fun getAnimeUrl(anime: SAnime) = "$baseUrl/details/${anime.url}"
|
||||
|
||||
override fun animeDetailsParse(response: Response) = SAnime.create().apply {
|
||||
val details = response.parseAs<AnimeDetails>()
|
||||
url = details.content_id
|
||||
title = details.content_title ?: details.content_title_en!!
|
||||
status = parseStatus(details.mal_data?.status)
|
||||
author = details.mal_data?.studios?.joinToString { it.name }
|
||||
genre = details.mal_data?.genres?.joinToString { it.name }
|
||||
description = details.mal_data?.synopsis
|
||||
thumbnail_url = "$apiUrl/image/210x300/${details.content_id}"
|
||||
}
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
override fun episodeListRequest(anime: SAnime) = GET("$apiUrl/content/${anime.url}/episodes")
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||
val contentId = response.request.url.toString().substringBeforeLast("/episodes")
|
||||
.substringAfterLast("/")
|
||||
val responseJson = response.parseAs<Map<String, EpisodeDto>>()
|
||||
return responseJson.map { (epNum, item) ->
|
||||
SEpisode.create().apply {
|
||||
url = "$contentId/video/$epNum"
|
||||
episode_number = epNum.toFloat()
|
||||
name = "Episode $epNum: ${item.name}"
|
||||
}
|
||||
}.sortedByDescending { it.episode_number }
|
||||
}
|
||||
|
||||
// ============================ Video Links =============================
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val videoData = response.parseAs<VideoData>()
|
||||
val videoUrl = videoData.uri.stream
|
||||
val subtitleLangs = videoData.metadata.subtitles
|
||||
val headers = headersBuilder().add("referer", baseUrl).build()
|
||||
|
||||
val subs = videoData.uri.subtitles.sortSubs().map { (langPrefix, subUrl) ->
|
||||
val language = subtitleLangs[langPrefix]!!
|
||||
Track(subUrl, language)
|
||||
}
|
||||
|
||||
val video = Video(videoUrl, "Default (720p)", videoUrl, headers, subtitleTracks = subs)
|
||||
return listOf(video)
|
||||
}
|
||||
|
||||
override fun videoListRequest(episode: SEpisode) = GET("$apiUrl/content/${episode.url}")
|
||||
|
||||
override fun videoUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
// ============================== Settings ==============================
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_SUB_KEY
|
||||
title = PREF_SUB_TITLE
|
||||
entries = PREF_SUB_ENTRIES
|
||||
entryValues = PREF_SUB_VALUES
|
||||
setDefaultValue(PREF_SUB_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
private fun parseStatus(statusString: String?): Int {
|
||||
return when (statusString?.trim()) {
|
||||
"finished_airing" -> SAnime.COMPLETED
|
||||
else -> SAnime.ONGOING
|
||||
}
|
||||
}
|
||||
|
||||
private fun AnimeListItem.toSAnime() = SAnime.create().apply {
|
||||
url = content_id
|
||||
title = content_title ?: content_title_en!!
|
||||
thumbnail_url = "$apiUrl/image/210x300/$content_id"
|
||||
}
|
||||
|
||||
private fun Map<String, String>.sortSubs(): List<Map.Entry<String, String>> {
|
||||
val sub = preferences.getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
|
||||
|
||||
return entries.sortedWith(
|
||||
compareBy { it.key.contains(sub) },
|
||||
).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
const val AO_USER_AGENT = "Aniyomi/app (mobile)"
|
||||
private const val PREF_SUB_KEY = "preferred_subLang"
|
||||
private const val PREF_SUB_TITLE = "Preferred sub language"
|
||||
const val PREF_SUB_DEFAULT = "en-US"
|
||||
private val PREF_SUB_ENTRIES = arrayOf(
|
||||
"العربية", "Deutsch", "English", "Español (Spain)",
|
||||
"Español (Latin)", "Français", "Italiano",
|
||||
"Português (Brasil)", "Русский",
|
||||
)
|
||||
private val PREF_SUB_VALUES = arrayOf(
|
||||
"ar-ME", "de-DE", "en-US", "es-ES",
|
||||
"es-LA", "fr-FR", "it-IT",
|
||||
"pt-BR", "ru-RU",
|
||||
)
|
|
@ -0,0 +1,83 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animeonsen.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||
|
||||
@Serializable
|
||||
data class AnimeListResponse(
|
||||
val content: List<AnimeListItem>,
|
||||
val cursor: AnimeListCursor,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AnimeListItem(
|
||||
val content_id: String,
|
||||
val content_title: String? = null,
|
||||
val content_title_en: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AnimeListCursor(val next: JsonArray)
|
||||
|
||||
@Serializable
|
||||
data class AnimeDetails(
|
||||
val content_id: String,
|
||||
val content_title: String?,
|
||||
val content_title_en: String?,
|
||||
@Serializable(with = MalSerializer::class)
|
||||
val mal_data: MalData?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EpisodeDto(
|
||||
@SerialName("contentTitle_episode_en")
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MalData(
|
||||
val genres: List<Genre>?,
|
||||
val status: String?,
|
||||
val studios: List<Studio>?,
|
||||
val synopsis: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Genre(val name: String)
|
||||
|
||||
@Serializable
|
||||
data class Studio(val name: String)
|
||||
|
||||
@Serializable
|
||||
data class VideoData(
|
||||
val metadata: MetaData,
|
||||
val uri: StreamData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MetaData(val subtitles: Map<String, String>)
|
||||
|
||||
@Serializable
|
||||
data class StreamData(
|
||||
val stream: String,
|
||||
val subtitles: Map<String, String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchResponse(
|
||||
val status: Int,
|
||||
val result: List<AnimeListItem>,
|
||||
)
|
||||
|
||||
object MalSerializer : JsonTransformingSerializer<MalData>(MalData.serializer()) {
|
||||
override fun transformDeserialize(element: JsonElement): JsonElement =
|
||||
when (element) {
|
||||
is JsonPrimitive -> JsonObject(emptyMap())
|
||||
else -> element
|
||||
}
|
||||
}
|
11
src/all/animeworldindia/build.gradle
Normal file
|
@ -0,0 +1,11 @@
|
|||
ext {
|
||||
extName = 'AnimeWorld India'
|
||||
extClass = '.AnimeWorldIndiaFactory'
|
||||
extVersionCode = 12
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
BIN
src/all/animeworldindia/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
src/all/animeworldindia/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
src/all/animeworldindia/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
src/all/animeworldindia/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
src/all/animeworldindia/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 22 KiB |
|
@ -0,0 +1,246 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animeworldindia
|
||||
|
||||
import android.app.Application
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class AnimeWorldIndia(
|
||||
final override val lang: String,
|
||||
private val language: String,
|
||||
) : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
|
||||
|
||||
override val name = "AnimeWorld India"
|
||||
|
||||
override val baseUrl = "https://anime-world.in"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
// ============================== Popular ===============================
|
||||
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/advanced-search/page/$page/?s_lang=$lang&s_orderby=viewed")
|
||||
|
||||
override fun popularAnimeSelector() = searchAnimeSelector()
|
||||
|
||||
override fun popularAnimeFromElement(element: Element) = searchAnimeFromElement(element)
|
||||
|
||||
override fun popularAnimeNextPageSelector() = searchAnimeNextPageSelector()
|
||||
|
||||
// =============================== Latest ===============================
|
||||
override fun latestUpdatesNextPageSelector() = searchAnimeNextPageSelector()
|
||||
|
||||
override fun latestUpdatesSelector() = searchAnimeSelector()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = searchAnimeFromElement(element)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/advanced-search/page/$page/?s_lang=$lang&s_orderby=update")
|
||||
|
||||
// =============================== Search ===============================
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
val searchParams = AnimeWorldIndiaFilters().getSearchParams(filters)
|
||||
return GET("$baseUrl/advanced-search/page/$page/?s_keyword=$query&s_lang=$lang$searchParams")
|
||||
}
|
||||
|
||||
override fun searchAnimeSelector() = "div.col-span-1"
|
||||
|
||||
override fun searchAnimeFromElement(element: Element) = SAnime.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
|
||||
thumbnail_url = element.selectFirst("img")!!.attr("abs:src")
|
||||
title = element.selectFirst("div.font-medium.line-clamp-2.mb-3")!!.text()
|
||||
}
|
||||
|
||||
override fun searchAnimeNextPageSelector() = "ul.page-numbers li:has(span.current) + li"
|
||||
|
||||
override fun getFilterList() = AnimeWorldIndiaFilters().filters
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
|
||||
title = document.selectFirst("h2.text-4xl")!!.text()
|
||||
genre = document.select("span.leading-6 a[class~=border-opacity-30]").joinToString { it.text() }
|
||||
description = document.selectFirst("div[data-synopsis]")?.text()
|
||||
author = document.selectFirst("span.leading-6 a[href*=\"producer\"]:first-child")?.text()
|
||||
artist = document.selectFirst("span.leading-6 a[href*=\"studio\"]:first-child")?.text()
|
||||
status = parseStatus(document)
|
||||
}
|
||||
|
||||
private val selector = "ul li:has(div.w-1.h-1.bg-gray-500.rounded-full) + li"
|
||||
|
||||
private fun parseStatus(document: Document): Int {
|
||||
return when (document.selectFirst("$selector a:not(:contains(Ep))")?.text()) {
|
||||
null -> SAnime.UNKNOWN
|
||||
"Movie" -> SAnime.COMPLETED
|
||||
else -> {
|
||||
val epParts = document.selectFirst("$selector a:not(:contains(TV))")
|
||||
?.text()
|
||||
?.drop(3)
|
||||
?.split("/")
|
||||
?.takeIf { it.size >= 2 }
|
||||
?: return SAnime.UNKNOWN
|
||||
if (epParts.first().trim().compareTo(epParts[1].trim()) == 0) {
|
||||
SAnime.COMPLETED
|
||||
} else {
|
||||
SAnime.ONGOING
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
override fun episodeListSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun episodeFromElement(element: Element): SEpisode = throw UnsupportedOperationException()
|
||||
|
||||
@Serializable
|
||||
data class SeasonDto(val episodes: EpisodeTypeDto)
|
||||
|
||||
@Serializable
|
||||
data class EpisodeTypeDto(val all: List<EpisodeDto>) {
|
||||
@Serializable
|
||||
data class EpisodeDto(val id: Int, val metadata: MetadataDto)
|
||||
|
||||
@Serializable
|
||||
data class MetadataDto(
|
||||
val number: String,
|
||||
val title: String,
|
||||
val released: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||
val document = response.asJsoup()
|
||||
val isMovie = document.selectFirst("nav li > a[href*=\"type/movies/\"]") != null
|
||||
|
||||
val seasonsJson = json.decodeFromString<List<SeasonDto>>(
|
||||
document.html()
|
||||
.substringAfter("var season_list = ")
|
||||
.substringBefore("var season_label =")
|
||||
.trim().dropLast(1),
|
||||
)
|
||||
|
||||
var episodeNumberFallback = 1F
|
||||
val isSingleSeason = seasonsJson.size == 1
|
||||
return seasonsJson.flatMapIndexed { seasonNumber, season ->
|
||||
val seasonName = if (isSingleSeason) "" else "Season ${seasonNumber + 1}"
|
||||
|
||||
season.episodes.all.reversed().map { episode ->
|
||||
val episodeTitle = episode.metadata.title
|
||||
val epNum = episode.metadata.number.toIntOrNull() ?: episodeNumberFallback.toInt()
|
||||
|
||||
val episodeName = when {
|
||||
isMovie -> "Movie"
|
||||
else -> buildString {
|
||||
if (seasonName.isNotBlank()) append("$seasonName - ")
|
||||
append("Episode $epNum")
|
||||
if (episodeTitle.isNotBlank()) append(" - $episodeTitle")
|
||||
}
|
||||
}
|
||||
|
||||
SEpisode.create().apply {
|
||||
name = episodeName
|
||||
episode_number = when {
|
||||
isSingleSeason -> epNum.toFloat()
|
||||
else -> episodeNumberFallback
|
||||
}
|
||||
episodeNumberFallback++
|
||||
setUrlWithoutDomain("$baseUrl/wp-json/kiranime/v1/episode?id=${episode.id}")
|
||||
date_upload = episode.metadata.released?.toLongOrNull()?.times(1000) ?: 0L
|
||||
}
|
||||
}
|
||||
}.sortedByDescending { it.episode_number }
|
||||
}
|
||||
|
||||
// ============================ Video Links =============================
|
||||
override fun videoFromElement(element: Element): Video = throw UnsupportedOperationException()
|
||||
|
||||
override fun videoUrlParse(document: Document) = throw UnsupportedOperationException()
|
||||
|
||||
override fun videoListSelector() = throw UnsupportedOperationException()
|
||||
|
||||
@Serializable
|
||||
private data class PlayerDto(
|
||||
val type: String,
|
||||
val url: String,
|
||||
val language: String,
|
||||
val server: String,
|
||||
)
|
||||
|
||||
private val mystreamExtractor by lazy { MyStreamExtractor(client, headers) }
|
||||
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val body = response.body.string()
|
||||
val documentTrimmed = body
|
||||
.substringAfterLast("\"players\":")
|
||||
.substringBefore(",\"noplayer\":")
|
||||
.trim()
|
||||
|
||||
val playersList = json.decodeFromString<List<PlayerDto>>(documentTrimmed)
|
||||
.filter { it.type == "stream" && it.url.isNotBlank() }
|
||||
.also { require(it.isNotEmpty()) { "No streams available!" } }
|
||||
.filter { language.isEmpty() || it.language.equals(language) }
|
||||
.also { require(it.isNotEmpty()) { "No videos for your language!" } }
|
||||
|
||||
return playersList.flatMap {
|
||||
when (it.server) {
|
||||
"Mystream" -> mystreamExtractor.videosFromUrl(it.url, it.language)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
|
||||
|
||||
return sortedWith(
|
||||
compareBy { it.quality.contains(quality) },
|
||||
).reversed()
|
||||
}
|
||||
|
||||
// ============================ Preferences =============================
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_QUALITY_KEY
|
||||
title = PREF_QUALITY_TITLE
|
||||
entries = PREF_QUALITY_ENTRIES
|
||||
entryValues = PREF_QUALITY_VALUES
|
||||
setDefaultValue(PREF_QUALITY_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_QUALITY_KEY = "preferred_quality"
|
||||
private const val PREF_QUALITY_TITLE = "Preferred quality"
|
||||
private const val PREF_QUALITY_DEFAULT = "1080"
|
||||
private val PREF_QUALITY_ENTRIES = arrayOf("1080p", "720p", "480p", "360p", "240p")
|
||||
private val PREF_QUALITY_VALUES = arrayOf("1080", "720", "480", "360", "240")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animeworldindia
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
|
||||
|
||||
class AnimeWorldIndiaFactory : AnimeSourceFactory {
|
||||
|
||||
override fun createSources() = listOf(
|
||||
AnimeWorldIndia("all", ""),
|
||||
AnimeWorldIndia("bn", "bengali"),
|
||||
AnimeWorldIndia("en", "english"),
|
||||
AnimeWorldIndia("hi", "hindi"),
|
||||
AnimeWorldIndia("ja", "japanese"),
|
||||
AnimeWorldIndia("ml", "malayalam"),
|
||||
AnimeWorldIndia("mr", "marathi"),
|
||||
AnimeWorldIndia("ta", "tamil"),
|
||||
AnimeWorldIndia("te", "telugu"),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,179 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animeworldindia
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
|
||||
class AnimeWorldIndiaFilters {
|
||||
|
||||
private data class StringQuery(val name: String, val query: String)
|
||||
|
||||
private class TypeList(types: Array<String>) : AnimeFilter.Select<String>("Type", types)
|
||||
private val typesName = getTypeList().map {
|
||||
it.name
|
||||
}.toTypedArray()
|
||||
|
||||
private fun getTypeList() = listOf(
|
||||
StringQuery("Any", "all"),
|
||||
StringQuery("TV", "tv"),
|
||||
StringQuery("Movie", "movie"),
|
||||
)
|
||||
|
||||
private class StatusList(statuses: Array<String>) : AnimeFilter.Select<String>("Status", statuses)
|
||||
private val statusesName = getStatusesList().map {
|
||||
it.name
|
||||
}.toTypedArray()
|
||||
|
||||
private fun getStatusesList() = listOf(
|
||||
StringQuery("Any", "all"),
|
||||
StringQuery("Currently Airing", "airing"),
|
||||
StringQuery("Finished Airing", "completed"),
|
||||
)
|
||||
|
||||
private class StyleList(styles: Array<String>) : AnimeFilter.Select<String>("Style", styles)
|
||||
private val stylesName = getStyleList().map {
|
||||
it.name
|
||||
}.toTypedArray()
|
||||
|
||||
private fun getStyleList() = listOf(
|
||||
StringQuery("Any", "all"),
|
||||
StringQuery("Anime", "anime"),
|
||||
StringQuery("Cartoon", "cartoon"),
|
||||
)
|
||||
|
||||
private class YearList(years: Array<String>) : AnimeFilter.Select<String>("Year", years)
|
||||
private val yearsName = getYearList().map {
|
||||
it.name
|
||||
}.toTypedArray()
|
||||
|
||||
private fun getYearList() = listOf(
|
||||
StringQuery("Any", "all"),
|
||||
StringQuery("2024", "2024"),
|
||||
StringQuery("2023", "2023"),
|
||||
StringQuery("2022", "2022"),
|
||||
StringQuery("2021", "2021"),
|
||||
StringQuery("2020", "2020"),
|
||||
StringQuery("2019", "2019"),
|
||||
StringQuery("2018", "2018"),
|
||||
StringQuery("2017", "2017"),
|
||||
StringQuery("2016", "2016"),
|
||||
StringQuery("2015", "2015"),
|
||||
StringQuery("2014", "2014"),
|
||||
StringQuery("2013", "2013"),
|
||||
StringQuery("2012", "2012"),
|
||||
StringQuery("2011", "2011"),
|
||||
StringQuery("2010", "2010"),
|
||||
StringQuery("2009", "2009"),
|
||||
StringQuery("2008", "2008"),
|
||||
StringQuery("2007", "2007"),
|
||||
StringQuery("2006", "2006"),
|
||||
StringQuery("2005", "2005"),
|
||||
StringQuery("2004", "2004"),
|
||||
StringQuery("2003", "2003"),
|
||||
StringQuery("2002", "2002"),
|
||||
StringQuery("2001", "2001"),
|
||||
StringQuery("2000", "2000"),
|
||||
StringQuery("1999", "1999"),
|
||||
StringQuery("1998", "1998"),
|
||||
StringQuery("1997", "1997"),
|
||||
StringQuery("1996", "1996"),
|
||||
StringQuery("1995", "1995"),
|
||||
StringQuery("1994", "1994"),
|
||||
StringQuery("1993", "1993"),
|
||||
StringQuery("1992", "1992"),
|
||||
StringQuery("1991", "1991"),
|
||||
StringQuery("1990", "1990"),
|
||||
)
|
||||
|
||||
private class SortList(sorts: Array<String>) : AnimeFilter.Select<String>("Sort", sorts)
|
||||
private val sortsName = getSortList().map {
|
||||
it.name
|
||||
}.toTypedArray()
|
||||
|
||||
private fun getSortList() = listOf(
|
||||
StringQuery("Default", "default"),
|
||||
StringQuery("Ascending", "title_a_z"),
|
||||
StringQuery("Descending", "title_z_a"),
|
||||
StringQuery("Updated", "update"),
|
||||
StringQuery("Published", "date"),
|
||||
StringQuery("Most Viewed", "viewed"),
|
||||
StringQuery("Favourite", "favorite"),
|
||||
)
|
||||
|
||||
internal class Genre(val id: String) : AnimeFilter.CheckBox(id)
|
||||
private class GenreList(genres: List<Genre>) : AnimeFilter.Group<Genre>("Genres", genres)
|
||||
private fun genresName() = listOf(
|
||||
Genre("Action"),
|
||||
Genre("Adult Cast"),
|
||||
Genre("Adventure"),
|
||||
Genre("Animation"),
|
||||
Genre("Comedy"),
|
||||
Genre("Detective"),
|
||||
Genre("Drama"),
|
||||
Genre("Ecchi"),
|
||||
Genre("Family"),
|
||||
Genre("Fantasy"),
|
||||
Genre("Isekai"),
|
||||
Genre("Kids"),
|
||||
Genre("Martial Arts"),
|
||||
Genre("Mecha"),
|
||||
Genre("Military"),
|
||||
Genre("Mystery"),
|
||||
Genre("Otaku Culture"),
|
||||
Genre("Reality"),
|
||||
Genre("Romance"),
|
||||
Genre("School"),
|
||||
Genre("Sci-Fi"),
|
||||
Genre("Seinen"),
|
||||
Genre("Shounen"),
|
||||
Genre("Slice of Life"),
|
||||
Genre("Sports"),
|
||||
Genre("Super Power"),
|
||||
Genre("SuperHero"),
|
||||
Genre("Supernatural"),
|
||||
Genre("TV Movie"),
|
||||
)
|
||||
|
||||
val filters: AnimeFilterList get() = AnimeFilterList(
|
||||
TypeList(typesName),
|
||||
StatusList(statusesName),
|
||||
StyleList(stylesName),
|
||||
YearList(yearsName),
|
||||
SortList(sortsName),
|
||||
GenreList(genresName()),
|
||||
)
|
||||
|
||||
fun getSearchParams(filters: AnimeFilterList): String {
|
||||
return "&" + filters.mapNotNull { filter ->
|
||||
when (filter) {
|
||||
is TypeList -> {
|
||||
val type = getTypeList()[filter.state].query
|
||||
"s_type=$type"
|
||||
}
|
||||
is StatusList -> {
|
||||
val status = getStatusesList()[filter.state].query
|
||||
"s_status=$status"
|
||||
}
|
||||
is StyleList -> {
|
||||
val style = getStyleList()[filter.state].query
|
||||
"s_sub_type=$style"
|
||||
}
|
||||
is YearList -> {
|
||||
val year = getYearList()[filter.state].query
|
||||
"s_year=$year"
|
||||
}
|
||||
is SortList -> {
|
||||
val sort = getSortList()[filter.state].query
|
||||
"s_orderby=$sort"
|
||||
}
|
||||
is GenreList -> {
|
||||
"s_genre=" + filter.state.filter { it.state }
|
||||
.joinToString("%2C") {
|
||||
val genre = it.id.replace(" ", "-")
|
||||
"$genre%2C"
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}.joinToString("&")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animeworldindia
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MyStreamExtractor(private val client: OkHttpClient, private val headers: Headers) {
|
||||
|
||||
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
|
||||
|
||||
fun videosFromUrl(url: String, language: String): List<Video> {
|
||||
val host = url.substringBefore("/watch")
|
||||
|
||||
return runCatching {
|
||||
val response = client.newCall(GET(url, headers)).execute()
|
||||
val body = response.body.string()
|
||||
|
||||
val streamCode = body
|
||||
.substringAfter("sniff(") // Video function
|
||||
.substringAfter(", \"") // our beloved ID
|
||||
.substringBefore('"')
|
||||
|
||||
val streamUrl = "$host/m3u8/$streamCode/master.txt?s=1&cache=1"
|
||||
|
||||
val cookie = response.headers.firstOrNull {
|
||||
it.first.startsWith("set-cookie", true) && it.second.startsWith("PHPSESSID", true)
|
||||
}?.second?.substringBefore(";") ?: ""
|
||||
|
||||
val newHeaders = headers.newBuilder()
|
||||
.set("cookie", cookie)
|
||||
.set("accept", "*/*")
|
||||
.build()
|
||||
|
||||
playlistUtils.extractFromHls(
|
||||
streamUrl,
|
||||
masterHeaders = newHeaders,
|
||||
videoHeaders = newHeaders,
|
||||
videoNameGen = { "[$language] MyStream: $it" },
|
||||
)
|
||||
}.getOrElse { emptyList<Video>() }
|
||||
}
|
||||
}
|
17
src/all/animexin/build.gradle
Normal file
|
@ -0,0 +1,17 @@
|
|||
ext {
|
||||
extName = 'AnimeXin'
|
||||
extClass = '.AnimeXin'
|
||||
themePkg = 'animestream'
|
||||
baseUrl = 'https://animexin.vip'
|
||||
overrideVersionCode = 8
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib:dailymotion-extractor'))
|
||||
implementation(project(':lib:okru-extractor'))
|
||||
implementation(project(':lib:gdriveplayer-extractor'))
|
||||
implementation(project(':lib:dood-extractor'))
|
||||
implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1"
|
||||
}
|
BIN
src/all/animexin/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src/all/animexin/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
src/all/animexin/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.2 KiB |
BIN
src/all/animexin/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src/all/animexin/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 21 KiB |
|
@ -0,0 +1,91 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animexin
|
||||
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animeextension.all.animexin.extractors.VidstreamingExtractor
|
||||
import eu.kanade.tachiyomi.animeextension.all.animexin.extractors.YouTubeExtractor
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.dailymotionextractor.DailymotionExtractor
|
||||
import eu.kanade.tachiyomi.lib.doodextractor.DoodExtractor
|
||||
import eu.kanade.tachiyomi.lib.gdriveplayerextractor.GdrivePlayerExtractor
|
||||
import eu.kanade.tachiyomi.lib.okruextractor.OkruExtractor
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStream
|
||||
|
||||
class AnimeXin : AnimeStream(
|
||||
"all",
|
||||
"AnimeXin",
|
||||
"https://animexin.vip",
|
||||
) {
|
||||
override val id = 4620219025406449669
|
||||
|
||||
// ============================ Video Links =============================
|
||||
private val dailymotionExtractor by lazy { DailymotionExtractor(client, headers) }
|
||||
private val doodExtractor by lazy { DoodExtractor(client) }
|
||||
private val gdrivePlayerExtractor by lazy { GdrivePlayerExtractor(client) }
|
||||
private val okruExtractor by lazy { OkruExtractor(client) }
|
||||
private val vidstreamingExtractor by lazy { VidstreamingExtractor(client) }
|
||||
private val youTubeExtractor by lazy { YouTubeExtractor(client) }
|
||||
|
||||
override fun getVideoList(url: String, name: String): List<Video> {
|
||||
val prefix = "$name - "
|
||||
return when {
|
||||
url.contains("ok.ru") -> okruExtractor.videosFromUrl(url, prefix)
|
||||
url.contains("dailymotion") -> dailymotionExtractor.videosFromUrl(url, prefix)
|
||||
url.contains("https://dood") -> doodExtractor.videosFromUrl(url, name)
|
||||
url.contains("gdriveplayer") -> {
|
||||
val gdriveHeaders = headersBuilder()
|
||||
.add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
.add("Referer", "$baseUrl/")
|
||||
.build()
|
||||
gdrivePlayerExtractor.videosFromUrl(url, name, gdriveHeaders)
|
||||
}
|
||||
url.contains("youtube.com") -> youTubeExtractor.videosFromUrl(url, prefix)
|
||||
url.contains("vidstreaming") -> vidstreamingExtractor.videosFromUrl(url, prefix)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Settings ==============================
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen) // Quality preferences
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_LANG_KEY
|
||||
title = PREF_LANG_TITLE
|
||||
entries = PREF_LANG_VALUES
|
||||
entryValues = PREF_LANG_VALUES
|
||||
setDefaultValue(PREF_LANG_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(prefQualityKey, prefQualityDefault)!!
|
||||
val language = preferences.getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
|
||||
|
||||
return sortedWith(
|
||||
compareBy(
|
||||
{ it.quality.contains(quality) },
|
||||
{ it.quality.contains(language, true) },
|
||||
),
|
||||
).reversed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_LANG_KEY = "preferred_language"
|
||||
private const val PREF_LANG_TITLE = "Preferred Video Language"
|
||||
private const val PREF_LANG_DEFAULT = "All Sub"
|
||||
private val PREF_LANG_VALUES = arrayOf(
|
||||
"All Sub", "Arabic", "English", "German", "Indonesia", "Italian",
|
||||
"Polish", "Portuguese", "Spanish", "Thai", "Turkish",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animexin.extractors
|
||||
|
||||
import android.util.Base64
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.lang.Exception
|
||||
import java.util.Locale
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
class VidstreamingExtractor(private val client: OkHttpClient) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
fun videosFromUrl(serverUrl: String, prefix: String): List<Video> {
|
||||
try {
|
||||
val document = client.newCall(GET(serverUrl)).execute().asJsoup()
|
||||
val iv = document.select("div.wrapper")
|
||||
.attr("class").substringAfter("container-")
|
||||
.filter { it.isDigit() }.toByteArray()
|
||||
val secretKey = document.select("body[class]")
|
||||
.attr("class").substringAfter("container-")
|
||||
.filter { it.isDigit() }.toByteArray()
|
||||
val decryptionKey = document.select("div.videocontent")
|
||||
.attr("class").substringAfter("videocontent-")
|
||||
.filter { it.isDigit() }.toByteArray()
|
||||
val encryptAjaxParams = cryptoHandler(
|
||||
document.select("script[data-value]")
|
||||
.attr("data-value"),
|
||||
iv,
|
||||
secretKey,
|
||||
false,
|
||||
).substringAfter("&")
|
||||
|
||||
val httpUrl = serverUrl.toHttpUrl()
|
||||
val host = "https://" + httpUrl.host + "/"
|
||||
val id = httpUrl.queryParameter("id") ?: throw Exception("error getting id")
|
||||
val encryptedId = cryptoHandler(id, iv, secretKey)
|
||||
val token = httpUrl.queryParameter("token")
|
||||
val qualitySuffix = if (token != null) " (Vid-mp4 - Gogostream)" else " (Vid-mp4 - Vidstreaming)"
|
||||
|
||||
val jsonResponse = client.newCall(
|
||||
GET(
|
||||
"${host}encrypt-ajax.php?id=$encryptedId&$encryptAjaxParams&alias=$id",
|
||||
Headers.headersOf(
|
||||
"X-Requested-With",
|
||||
"XMLHttpRequest",
|
||||
),
|
||||
),
|
||||
).execute().body.string()
|
||||
val data = json.decodeFromString<JsonObject>(jsonResponse)["data"]!!.jsonPrimitive.content
|
||||
val decryptedData = cryptoHandler(data, iv, decryptionKey, false)
|
||||
val videoList = mutableListOf<Video>()
|
||||
val autoList = mutableListOf<Video>()
|
||||
val array = json.decodeFromString<JsonObject>(decryptedData)["source"]!!.jsonArray
|
||||
if (array.size == 1 && array[0].jsonObject["type"]!!.jsonPrimitive.content == "hls") {
|
||||
val fileURL = array[0].jsonObject["file"].toString().trim('"')
|
||||
val masterPlaylist = client.newCall(GET(fileURL)).execute().body.string()
|
||||
masterPlaylist.substringAfter("#EXT-X-STREAM-INF:")
|
||||
.split("#EXT-X-STREAM-INF:").forEach {
|
||||
val quality = it.substringAfter("RESOLUTION=").substringAfter("x").substringBefore(",").substringBefore("\n") + "p"
|
||||
var videoUrl = it.substringAfter("\n").substringBefore("\n")
|
||||
if (!videoUrl.startsWith("http")) {
|
||||
videoUrl = fileURL.substringBeforeLast("/") + "/$videoUrl"
|
||||
}
|
||||
videoList.add(Video(videoUrl, prefix + quality + qualitySuffix, videoUrl))
|
||||
}
|
||||
} else {
|
||||
array.forEach {
|
||||
val label = it.jsonObject["label"].toString().lowercase(Locale.ROOT)
|
||||
.trim('"').replace(" ", "")
|
||||
val fileURL = it.jsonObject["file"].toString().trim('"')
|
||||
val videoHeaders = Headers.headersOf("Referer", serverUrl)
|
||||
if (label == "auto") {
|
||||
autoList.add(
|
||||
Video(
|
||||
fileURL,
|
||||
label + qualitySuffix,
|
||||
fileURL,
|
||||
headers = videoHeaders,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
videoList.add(Video(fileURL, label + qualitySuffix, fileURL, headers = videoHeaders))
|
||||
}
|
||||
}
|
||||
}
|
||||
return videoList.sortedByDescending {
|
||||
it.quality.substringBefore(qualitySuffix).substringBefore("p").toIntOrNull() ?: -1
|
||||
} + autoList
|
||||
} catch (e: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cryptoHandler(
|
||||
string: String,
|
||||
iv: ByteArray,
|
||||
secretKeyString: ByteArray,
|
||||
encrypt: Boolean = true,
|
||||
): String {
|
||||
val ivParameterSpec = IvParameterSpec(iv)
|
||||
val secretKey = SecretKeySpec(secretKeyString, "AES")
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
return if (!encrypt) {
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec)
|
||||
String(cipher.doFinal(Base64.decode(string, Base64.DEFAULT)))
|
||||
} else {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec)
|
||||
Base64.encodeToString(cipher.doFinal(string.toByteArray()), Base64.NO_WRAP)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.animexin.extractors
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.math.abs
|
||||
|
||||
class YouTubeExtractor(private val client: OkHttpClient) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
fun videosFromUrl(url: String, prefix: String): List<Video> {
|
||||
// Ported from https://github.com/dermasmid/scrapetube/blob/master/scrapetube/scrapetube.py
|
||||
// TODO: Make code prettier
|
||||
// GET KEY
|
||||
|
||||
val videoId = url.substringAfter("/embed/")
|
||||
|
||||
val document = client.newCall(GET(url.replace("/embed/", "/watch?v=")))
|
||||
.execute()
|
||||
.asJsoup()
|
||||
|
||||
val ytcfg = document.selectFirst("script:containsData(window.ytcfg=window.ytcfg)")
|
||||
?.data() ?: run {
|
||||
Log.e("YouTubeExtractor", "Failed while trying to fetch the api key >:(")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val clientName = ytcfg.substringAfter("INNERTUBE_CONTEXT_CLIENT_NAME\":", "")
|
||||
.substringBefore(",", "").ifEmpty { "5" }
|
||||
|
||||
val apiKey = ytcfg
|
||||
.substringAfter("innertubeApiKey\":\"", "")
|
||||
.substringBefore('"')
|
||||
|
||||
val playerUrl = "$YOUTUBE_URL/youtubei/v1/player?key=$apiKey&prettyPrint=false"
|
||||
|
||||
val body = """
|
||||
{
|
||||
"context":{
|
||||
"client":{
|
||||
"clientName":"IOS",
|
||||
"clientVersion":"17.33.2",
|
||||
"deviceModel": "iPhone14,3",
|
||||
"userAgent": "com.google.ios.youtube/17.33.2 (iPhone14,3; U; CPU iOS 15_6 like Mac OS X)",
|
||||
"hl": "en",
|
||||
"timeZone": "UTC",
|
||||
"utcOffsetMinutes": 0
|
||||
}
|
||||
},
|
||||
"videoId":"$videoId",
|
||||
"playbackContext":{
|
||||
"contentPlaybackContext":{
|
||||
"html5Preference":"HTML5_PREF_WANTS"
|
||||
}
|
||||
},
|
||||
"contentCheckOk":true,
|
||||
"racyCheckOk":true
|
||||
}
|
||||
""".trimIndent().toRequestBody("application/json".toMediaType())
|
||||
|
||||
val headers = Headers.Builder().apply {
|
||||
add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
add("Origin", YOUTUBE_URL)
|
||||
add("User-Agent", "com.google.ios.youtube/17.33.2 (iPhone14,3; U; CPU iOS 15_6 like Mac OS X)")
|
||||
add("X-Youtube-Client-Name", clientName)
|
||||
add("X-Youtube-Client-Version", "17.33.2")
|
||||
}.build()
|
||||
|
||||
val ytResponse = client.newCall(POST(playerUrl, headers, body)).execute()
|
||||
.let { json.decodeFromString<YoutubeResponse>(it.body.string()) }
|
||||
|
||||
val formats = ytResponse.streamingData.adaptiveFormats
|
||||
|
||||
// Get Audio
|
||||
val audioTracks = formats.filter { it.mimeType.startsWith("audio/webm") }
|
||||
.map { Track(it.url, it.audioQuality!! + " (${formatBits(it.averageBitrate!!)}ps)") }
|
||||
|
||||
// Get Subtitles
|
||||
val subs = ytResponse.captions?.renderer?.captionTracks?.map {
|
||||
Track(it.baseUrl, it.label)
|
||||
} ?: emptyList()
|
||||
|
||||
// Get videos, finally
|
||||
return formats.filter { it.mimeType.startsWith("video/mp4") }.map {
|
||||
val codecs = it.mimeType.substringAfter("codecs=\"").substringBefore("\"")
|
||||
Video(
|
||||
it.url,
|
||||
prefix + it.qualityLabel.orEmpty() + " ($codecs)",
|
||||
it.url,
|
||||
subtitleTracks = subs,
|
||||
audioTracks = audioTracks,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
fun formatBits(size: Long): String {
|
||||
var bits = abs(size)
|
||||
if (bits < 1000) {
|
||||
return "${bits}b"
|
||||
}
|
||||
val iterator = "kMGTPE".iterator()
|
||||
var currentChar = iterator.next()
|
||||
while (bits >= 999950 && iterator.hasNext()) {
|
||||
bits /= 1000
|
||||
currentChar = iterator.next()
|
||||
}
|
||||
return "%.0f%cb".format(bits / 1000.0, currentChar)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class YoutubeResponse(
|
||||
val streamingData: AdaptiveDto,
|
||||
val captions: CaptionsDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdaptiveDto(val adaptiveFormats: List<TrackDto>)
|
||||
|
||||
@Serializable
|
||||
data class TrackDto(
|
||||
val mimeType: String,
|
||||
val url: String,
|
||||
val averageBitrate: Long? = null,
|
||||
val qualityLabel: String? = null,
|
||||
val audioQuality: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CaptionsDto(
|
||||
@SerialName("playerCaptionsTracklistRenderer")
|
||||
val renderer: CaptionsRendererDto,
|
||||
) {
|
||||
@Serializable
|
||||
data class CaptionsRendererDto(val captionTracks: List<CaptionItem>)
|
||||
|
||||
@Serializable
|
||||
data class CaptionItem(val baseUrl: String, val name: NameDto) {
|
||||
@Serializable
|
||||
data class NameDto(val runs: List<GodDamnitYoutube>)
|
||||
|
||||
@Serializable
|
||||
data class GodDamnitYoutube(val text: String)
|
||||
|
||||
val label by lazy { name.runs.first().text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val YOUTUBE_URL = "https://www.youtube.com"
|
16
src/all/chineseanime/build.gradle
Normal file
|
@ -0,0 +1,16 @@
|
|||
ext {
|
||||
extName = 'ChineseAnime'
|
||||
extClass = '.ChineseAnime'
|
||||
themePkg = 'animestream'
|
||||
baseUrl = 'https://www.chineseanime.vip'
|
||||
overrideVersionCode = 8
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:dailymotion-extractor"))
|
||||
implementation(project(":lib:streamwish-extractor"))
|
||||
implementation(project(":lib:streamvid-extractor"))
|
||||
implementation(project(":lib:playlist-utils"))
|
||||
}
|
BIN
src/all/chineseanime/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
src/all/chineseanime/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
src/all/chineseanime/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.1 KiB |
BIN
src/all/chineseanime/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
src/all/chineseanime/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
src/all/chineseanime/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 207 KiB |
|
@ -0,0 +1,88 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.chineseanime
|
||||
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animeextension.all.chineseanime.extractors.VatchusExtractor
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.dailymotionextractor.DailymotionExtractor
|
||||
import eu.kanade.tachiyomi.lib.streamvidextractor.StreamVidExtractor
|
||||
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStream
|
||||
|
||||
class ChineseAnime : AnimeStream(
|
||||
"all",
|
||||
"ChineseAnime",
|
||||
"https://www.chineseanime.vip",
|
||||
) {
|
||||
|
||||
// =============================== Search ===============================
|
||||
override fun searchAnimeNextPageSelector() = "div.mrgn > a.r"
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
override val animeDescriptionSelector = ".entry-content"
|
||||
|
||||
// ============================== Filters ===============================
|
||||
override val filtersSelector = "div.filter > ul"
|
||||
|
||||
// ============================ Video Links =============================
|
||||
private val dailymotionExtractor by lazy { DailymotionExtractor(client, headers) }
|
||||
private val streamwishExtractor by lazy { StreamWishExtractor(client, headers) }
|
||||
private val streamvidExtractor by lazy { StreamVidExtractor(client) }
|
||||
private val vatchusExtractor by lazy { VatchusExtractor(client, headers) }
|
||||
|
||||
override fun getVideoList(url: String, name: String): List<Video> {
|
||||
val prefix = "$name - "
|
||||
return when {
|
||||
url.contains("dailymotion") -> dailymotionExtractor.videosFromUrl(url, prefix)
|
||||
url.contains("embedwish") -> streamwishExtractor.videosFromUrl(url, prefix)
|
||||
url.contains("vatchus") -> vatchusExtractor.videosFromUrl(url, prefix)
|
||||
url.contains("donghua.xyz/v/") -> streamvidExtractor.videosFromUrl(url, prefix, true)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Settings ==============================
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen) // Quality preferences
|
||||
val videoLangPref = ListPreference(screen.context).apply {
|
||||
key = PREF_LANG_KEY
|
||||
title = PREF_LANG_TITLE
|
||||
entries = PREF_LANG_VALUES
|
||||
entryValues = PREF_LANG_VALUES
|
||||
setDefaultValue(PREF_LANG_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(videoLangPref)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(prefQualityKey, prefQualityDefault)!!
|
||||
val language = preferences.getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
|
||||
|
||||
return sortedWith(
|
||||
compareBy(
|
||||
{ it.quality.contains(quality) },
|
||||
{ it.quality.contains(language, true) },
|
||||
),
|
||||
).reversed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_LANG_KEY = "preferred_language"
|
||||
private const val PREF_LANG_TITLE = "Preferred Video Language"
|
||||
private const val PREF_LANG_DEFAULT = "All Sub"
|
||||
private val PREF_LANG_VALUES = arrayOf(
|
||||
"All Sub", "Arabic", "English", "Indonesia", "Persian", "Malay",
|
||||
"Polish", "Portuguese", "Spanish", "Thai", "Vietnamese",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.chineseanime.extractors
|
||||
|
||||
import android.util.Base64
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class VatchusExtractor(private val client: OkHttpClient, private val headers: Headers) {
|
||||
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
|
||||
|
||||
fun videosFromUrl(url: String, prefix: String): List<Video> {
|
||||
val doc = client.newCall(GET(url, headers)).execute()
|
||||
.asJsoup()
|
||||
|
||||
val script = doc.selectFirst("script:containsData(document.write)")
|
||||
?.data()
|
||||
?: return emptyList()
|
||||
|
||||
val numberList = script.substringAfter(" = [").substringBefore("];")
|
||||
.replace("\"", "")
|
||||
.split(",")
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.map { String(Base64.decode(it, Base64.DEFAULT)) }
|
||||
.mapNotNull { it.filter(Char::isDigit).toIntOrNull() }
|
||||
|
||||
val offset = numberList.first() - 60
|
||||
val decodedData = numberList.joinToString("") {
|
||||
Char(it - offset).toString()
|
||||
}.trim()
|
||||
|
||||
val playlistUrl = decodedData.substringAfter("file:'").substringBefore("'")
|
||||
val subs = decodedData.substringAfter("tracks:[").substringBefore("]")
|
||||
.split("{")
|
||||
.drop(1)
|
||||
.filter { it.contains(""""kind":"captions"""") }
|
||||
.mapNotNull {
|
||||
val trackUrl = it.substringAfter("file\":\"").substringBefore('"')
|
||||
.takeIf { link -> link.startsWith("http") }
|
||||
?: return@mapNotNull null
|
||||
val language = it.substringAfter("label\":\"").substringBefore('"')
|
||||
Track(trackUrl, language)
|
||||
}
|
||||
|
||||
return playlistUtils.extractFromHls(
|
||||
playlistUrl,
|
||||
url,
|
||||
subtitleList = subs,
|
||||
videoNameGen = { prefix + it },
|
||||
)
|
||||
}
|
||||
}
|
62
src/all/googledrive/README.md
Normal file
|
@ -0,0 +1,62 @@
|
|||
# DISCLAIMER
|
||||
|
||||
This extension requires you to log in through Google and relies heavily on scraping the website of Google Drive, which may be against their terms of service. Use at your own risk.
|
||||
|
||||
# Google Drive
|
||||
|
||||
Table of Content
|
||||
- [FAQ](#FAQ)
|
||||
- [How do i add entries?](#how-do-i-add-entries)
|
||||
- [What are all these options for drive paths?](#what-are-all-these-options-for-drive-paths)
|
||||
- [I added the drive paths but it still get "Enter drive path(s) in extension settings."](#i-added-the-drive-paths-but-it-still-get-enter-drive-paths-in-extension-settings)
|
||||
- [I cannot log in through webview](#i-cannot-log-in-through-webview)
|
||||
|
||||
## FAQ
|
||||
|
||||
### How do I customize info?
|
||||
|
||||
The Google Drive Extension allow for editing the same way as [local anime](https://aniyomi.org/docs/guides/local-anime-source/advanced) .
|
||||
|
||||
### How do I add entries?
|
||||
The Google Drive Extension *only* supports google drive folders, so no shared drives (but folders inside shared drives works fine!). If you have a folder, which contains sub-folders of an anime, such as:
|
||||
```
|
||||
https://drive.google.com/drive/folders/some-long-id
|
||||
├── anime1/
|
||||
│ ├── episode 1.mkv
|
||||
│ ├── episode 2.mkv
|
||||
│ └── ...
|
||||
└── anime2/
|
||||
├── episode 1.mkv
|
||||
├── episode 2.mkv
|
||||
└── ...
|
||||
```
|
||||
Then it you should go to extension settings, and add the url there. You can add multiple drive paths by separating them with a semicolon `;`. To select between the paths, open up the extension and click the filter, from there you can select a specific drive.
|
||||
|
||||
If you instead have a folder that contains the episodes directly, such as:
|
||||
```
|
||||
https://drive.google.com/drive/folders/some-long-id
|
||||
├── episode 1.mkv
|
||||
├── episode 2.mkv
|
||||
└── ...
|
||||
```
|
||||
Then you should open the extension, click filters, then paste the folder link in the `Add single folder` filter.
|
||||
|
||||
### What are all these options for drive paths?
|
||||
The extension allows for some options when adding the drive path:
|
||||
1. You can customize the name of a drive path by prepending the url with [<insert name>]. This will change the display name when selecting different drive paths in filters. Example: `[Weekly episodes]https://drive.google.com/drive/folders/some-long-id`
|
||||
2. You can limit the recursion depth by adding a `#` to the end of the url together with a number. If you set it to `1`, the extension will not go into any sub-folders when loading episodes. If you set it to `2`, the extension will traverse into any sub-folders, but not sub-folders of sub-folders, and so on and so forth. It's useful if one folder has a separate folder for each seasons that you want to traverse through, but if another folder has separate folder for openings/endings that you *don't* want to traverse through. Example: `https://drive.google.com/drive/folders/some-long-id#3`
|
||||
3. It is also possible to specify a range of episodes to load. It needs to be added together with the recursion depth as seen in step 2. Note: it only works if the recursion depth is set to `1`. The range is inclusive, so doing #1,2,7 will load the 2nd up to, and including, the 7th item. Example: `https://drive.google.com/drive/folders/some-long-id#1,2,7`
|
||||
|
||||
It is possible to mix these options, and they work for both ways to add folders.
|
||||
|
||||
### I added the drive paths but it still get "Enter drive path(s) in extension settings."
|
||||
This can be caused by the caching that Aniyomi does. Reinstalling the extension will fix this issue (reinstalling an extension does not remove any extension settings)
|
||||
|
||||
### I cannot log in through webview
|
||||
Google can sometimes think that webview isn't a secure browser, and will thus refuse to let you log in. There are a few things you can try to mitigate this:
|
||||
1. In the top right, click the three dots then click `Clear cookies`
|
||||
2. In the top right, click the three dots then click `Refresh`
|
||||
3. Click the `Try again` button after the website doesn't let you log in
|
||||
4. Make sure that your webview is up to date
|
||||
|
||||
Try a combination of these steps, and after a few tries it should eventually let you log in.
|
11
src/all/googledrive/build.gradle
Normal file
|
@ -0,0 +1,11 @@
|
|||
ext {
|
||||
extName = 'Google Drive'
|
||||
extClass = '.GoogleDrive'
|
||||
extVersionCode = 15
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib:googledrive-extractor'))
|
||||
}
|
BIN
src/all/googledrive/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
src/all/googledrive/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
src/all/googledrive/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/all/googledrive/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.2 KiB |
BIN
src/all/googledrive/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.1 KiB |
BIN
src/all/googledrive/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 31 KiB |
|
@ -0,0 +1,720 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.googledrive
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||
import eu.kanade.tachiyomi.lib.googledriveextractor.GoogleDriveExtractor
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ProtocolException
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.commonEmptyRequestBody
|
||||
import org.jsoup.nodes.Document
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
|
||||
class GoogleDrive : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
|
||||
override val name = "Google Drive"
|
||||
|
||||
override val id = 4222017068256633289
|
||||
|
||||
override var baseUrl = "https://drive.google.com"
|
||||
|
||||
// Hack to manipulate what gets opened in webview
|
||||
private val baseUrlInternal by lazy {
|
||||
preferences.domainList.split(";").firstOrNull()
|
||||
}
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
// Overriding headersBuilder() seems to cause issues with webview
|
||||
private val getHeaders = headers.newBuilder().apply {
|
||||
add("Accept", "*/*")
|
||||
add("Connection", "keep-alive")
|
||||
add("Cookie", getCookie("https://drive.google.com"))
|
||||
add("Host", "drive.google.com")
|
||||
}.build()
|
||||
|
||||
private var nextPageToken: String? = ""
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override suspend fun getPopularAnime(page: Int): AnimesPage =
|
||||
parsePage(popularAnimeRequest(page), page)
|
||||
|
||||
override fun popularAnimeRequest(page: Int): Request {
|
||||
require(!baseUrlInternal.isNullOrEmpty()) { "Enter drive path(s) in extension settings." }
|
||||
|
||||
val match = DRIVE_FOLDER_REGEX.matchEntire(baseUrlInternal!!)!!
|
||||
val folderId = match.groups["id"]!!.value
|
||||
val recurDepth = match.groups["depth"]?.value ?: ""
|
||||
baseUrl = "https://drive.google.com/drive/folders/$folderId"
|
||||
|
||||
return GET(
|
||||
"https://drive.google.com/drive/folders/$folderId$recurDepth",
|
||||
headers = getHeaders,
|
||||
)
|
||||
}
|
||||
|
||||
override fun popularAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
|
||||
|
||||
// =============================== Latest ===============================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage = throw UnsupportedOperationException()
|
||||
|
||||
// =============================== Search ===============================
|
||||
|
||||
override fun searchAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
|
||||
|
||||
override suspend fun getSearchAnime(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: AnimeFilterList,
|
||||
): AnimesPage {
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val urlFilter = filterList.find { it is URLFilter } as URLFilter
|
||||
|
||||
return if (urlFilter.state.isEmpty()) {
|
||||
val req = searchAnimeRequest(page, query, filters)
|
||||
|
||||
if (query.isEmpty()) {
|
||||
parsePage(req, page)
|
||||
} else {
|
||||
val parentId = req.url.pathSegments.last()
|
||||
val cleanQuery = URLEncoder.encode(query, "UTF-8")
|
||||
val genMultiFormReq = searchReq(parentId, cleanQuery)
|
||||
|
||||
parsePage(req, page, genMultiFormReq)
|
||||
}
|
||||
} else {
|
||||
addSinglePage(urlFilter.state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
require(!baseUrlInternal.isNullOrEmpty()) { "Enter drive path(s) in extension settings." }
|
||||
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val serverFilter = filterList.find { it is ServerFilter } as ServerFilter
|
||||
val serverUrl = serverFilter.toUriPart()
|
||||
|
||||
val match = DRIVE_FOLDER_REGEX.matchEntire(serverUrl)!!
|
||||
val folderId = match.groups["id"]!!.value
|
||||
val recurDepth = match.groups["depth"]?.value ?: ""
|
||||
baseUrl = "https://drive.google.com/drive/folders/$folderId"
|
||||
|
||||
return GET(
|
||||
"https://drive.google.com/drive/folders/$folderId$recurDepth",
|
||||
headers = getHeaders,
|
||||
)
|
||||
}
|
||||
|
||||
// ============================== FILTERS ===============================
|
||||
|
||||
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
|
||||
ServerFilter(getDomains()),
|
||||
AnimeFilter.Separator(),
|
||||
AnimeFilter.Header("Add single folder"),
|
||||
URLFilter(),
|
||||
)
|
||||
|
||||
private class ServerFilter(domains: Array<Pair<String, String>>) : UriPartFilter(
|
||||
"Select drive path",
|
||||
domains,
|
||||
)
|
||||
|
||||
private fun getDomains(): Array<Pair<String, String>> {
|
||||
if (preferences.domainList.isBlank()) return emptyArray()
|
||||
return preferences.domainList.split(";").map {
|
||||
val name = DRIVE_FOLDER_REGEX.matchEntire(it)!!.groups["name"]?.let {
|
||||
it.value.substringAfter("[").substringBeforeLast("]")
|
||||
}
|
||||
Pair(name ?: it.toHttpUrl().encodedPath, it)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||
AnimeFilter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
private class URLFilter : AnimeFilter.Text("Url")
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
|
||||
override fun animeDetailsRequest(anime: SAnime): Request {
|
||||
val parsed = json.decodeFromString<LinkData>(anime.url)
|
||||
return GET(parsed.url, headers = getHeaders)
|
||||
}
|
||||
|
||||
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
|
||||
val parsed = json.decodeFromString<LinkData>(anime.url)
|
||||
|
||||
if (parsed.type == "single") return anime
|
||||
|
||||
val folderId = DRIVE_FOLDER_REGEX.matchEntire(parsed.url)!!.groups["id"]!!.value
|
||||
|
||||
val driveDocument = try {
|
||||
client.newCall(GET(parsed.url, headers = getHeaders)).execute().asJsoup()
|
||||
} catch (a: ProtocolException) {
|
||||
null
|
||||
} ?: return anime
|
||||
|
||||
// Get cover
|
||||
|
||||
val coverResponse = client.newCall(
|
||||
createPost(driveDocument, folderId, nextPageToken, searchReqWithType(folderId, "cover", IMAGE_MIMETYPE)),
|
||||
).execute().parseAs<PostResponse> { JSON_REGEX.find(it)!!.groupValues[1] }
|
||||
|
||||
coverResponse.items?.firstOrNull()?.let {
|
||||
anime.thumbnail_url = "https://drive.google.com/uc?id=${it.id}"
|
||||
}
|
||||
|
||||
// Get details
|
||||
|
||||
val detailsResponse = client.newCall(
|
||||
createPost(driveDocument, folderId, nextPageToken, searchReqWithType(folderId, "details.json", "")),
|
||||
).execute().parseAs<PostResponse> { JSON_REGEX.find(it)!!.groupValues[1] }
|
||||
|
||||
detailsResponse.items?.firstOrNull()?.let {
|
||||
val newPostHeaders = getHeaders.newBuilder().apply {
|
||||
add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||
set("Host", "drive.usercontent.google.com")
|
||||
add("Origin", "https://drive.google.com")
|
||||
add("Referer", "https://drive.google.com/")
|
||||
add("X-Drive-First-Party", "DriveWebUi")
|
||||
add("X-Json-Requested", "true")
|
||||
}.build()
|
||||
|
||||
val newPostUrl = "https://drive.usercontent.google.com/uc?id=${it.id}&authuser=0&export=download"
|
||||
|
||||
val newResponse = client.newCall(
|
||||
POST(newPostUrl, headers = newPostHeaders, body = commonEmptyRequestBody),
|
||||
).execute().parseAs<DownloadResponse> { JSON_REGEX.find(it)!!.groupValues[1] }
|
||||
|
||||
val downloadHeaders = headers.newBuilder().apply {
|
||||
add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
|
||||
add("Connection", "keep-alive")
|
||||
add("Cookie", getCookie("https://drive.usercontent.google.com"))
|
||||
add("Host", "drive.usercontent.google.com")
|
||||
}.build()
|
||||
|
||||
client.newCall(
|
||||
GET(newResponse.downloadUrl, headers = downloadHeaders),
|
||||
).execute().parseAs<DetailsJson>().let { t ->
|
||||
t.title?.let { anime.title = it }
|
||||
t.author?.let { anime.author = it }
|
||||
t.artist?.let { anime.artist = it }
|
||||
t.description?.let { anime.description = it }
|
||||
t.genre?.let { anime.genre = it.joinToString(", ") }
|
||||
t.status?.let { anime.status = it.toIntOrNull() ?: SAnime.UNKNOWN }
|
||||
}
|
||||
}
|
||||
|
||||
return anime
|
||||
}
|
||||
|
||||
override fun animeDetailsParse(response: Response): SAnime = throw UnsupportedOperationException()
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
|
||||
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
|
||||
val episodeList = mutableListOf<SEpisode>()
|
||||
val parsed = json.decodeFromString<LinkData>(anime.url)
|
||||
|
||||
if (parsed.type == "single") {
|
||||
return listOf(
|
||||
SEpisode.create().apply {
|
||||
name = "Video"
|
||||
scanlator = parsed.info!!.size
|
||||
url = parsed.url
|
||||
episode_number = 1F
|
||||
date_upload = -1L
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val match = DRIVE_FOLDER_REGEX.matchEntire(parsed.url)!! // .groups["id"]!!.value
|
||||
val maxRecursionDepth = match.groups["depth"]?.let {
|
||||
it.value.substringAfter("#").substringBefore(",").toInt()
|
||||
} ?: 2
|
||||
val (start, stop) = match.groups["range"]?.let {
|
||||
it.value.substringAfter(",").split(",").map { it.toInt() }
|
||||
} ?: listOf(null, null)
|
||||
|
||||
fun traverseFolder(folderUrl: String, path: String, recursionDepth: Int = 0) {
|
||||
if (recursionDepth == maxRecursionDepth) return
|
||||
|
||||
val folderId = DRIVE_FOLDER_REGEX.matchEntire(folderUrl)!!.groups["id"]!!.value
|
||||
|
||||
val driveDocument = try {
|
||||
client.newCall(GET(folderUrl, headers = getHeaders)).execute().asJsoup()
|
||||
} catch (a: ProtocolException) {
|
||||
throw Exception("Unable to get items, check webview")
|
||||
}
|
||||
|
||||
if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) return
|
||||
|
||||
var pageToken: String? = ""
|
||||
var counter = 1
|
||||
|
||||
while (pageToken != null) {
|
||||
val response = client.newCall(
|
||||
createPost(driveDocument, folderId, pageToken),
|
||||
).execute()
|
||||
|
||||
val parsed = response.parseAs<PostResponse> {
|
||||
JSON_REGEX.find(it)!!.groupValues[1]
|
||||
}
|
||||
|
||||
if (parsed.items == null) throw Exception("Failed to load items, please log in through webview")
|
||||
parsed.items.forEachIndexed { index, it ->
|
||||
if (it.mimeType.startsWith("video")) {
|
||||
val size = it.fileSize?.toLongOrNull()?.let { formatBytes(it) } ?: ""
|
||||
val pathName = if (preferences.trimEpisodeInfo) path.trimInfo() else path
|
||||
|
||||
if (start != null && maxRecursionDepth == 1 && counter < start) {
|
||||
counter++
|
||||
return@forEachIndexed
|
||||
}
|
||||
if (stop != null && maxRecursionDepth == 1 && counter > stop) return
|
||||
|
||||
episodeList.add(
|
||||
SEpisode.create().apply {
|
||||
name =
|
||||
if (preferences.trimEpisodeName) it.title.trimInfo() else it.title
|
||||
url = "https://drive.google.com/uc?id=${it.id}"
|
||||
episode_number =
|
||||
ITEM_NUMBER_REGEX.find(it.title.trimInfo())?.groupValues?.get(1)
|
||||
?.toFloatOrNull() ?: (index + 1).toFloat()
|
||||
date_upload = -1L
|
||||
scanlator = if (preferences.scanlatorOrder) {
|
||||
"/$pathName • $size"
|
||||
} else {
|
||||
"$size • /$pathName"
|
||||
}
|
||||
},
|
||||
)
|
||||
counter++
|
||||
}
|
||||
if (it.mimeType.endsWith(".folder")) {
|
||||
traverseFolder(
|
||||
"https://drive.google.com/drive/folders/${it.id}",
|
||||
if (path.isEmpty()) it.title else "$path/${it.title}",
|
||||
recursionDepth + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = parsed.nextPageToken
|
||||
}
|
||||
}
|
||||
|
||||
traverseFolder(parsed.url, "")
|
||||
|
||||
return episodeList.reversed()
|
||||
}
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> = throw UnsupportedOperationException()
|
||||
|
||||
// ============================ Video Links =============================
|
||||
|
||||
override suspend fun getVideoList(episode: SEpisode): List<Video> =
|
||||
GoogleDriveExtractor(client, headers).videosFromUrl(episode.url.substringAfter("?id="))
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
|
||||
private fun addSinglePage(folderUrl: String): AnimesPage {
|
||||
val match =
|
||||
DRIVE_FOLDER_REGEX.matchEntire(folderUrl) ?: throw Exception("Invalid drive url")
|
||||
val recurDepth = match.groups["depth"]?.value ?: ""
|
||||
|
||||
val anime = SAnime.create().apply {
|
||||
title = match.groups["name"]?.value?.substringAfter("[")?.substringBeforeLast("]")
|
||||
?: "Folder"
|
||||
url = LinkData(
|
||||
"https://drive.google.com/drive/folders/${match.groups["id"]!!.value}$recurDepth",
|
||||
"multi",
|
||||
).toJsonString()
|
||||
thumbnail_url = ""
|
||||
}
|
||||
return AnimesPage(listOf(anime), false)
|
||||
}
|
||||
|
||||
private fun createPost(
|
||||
document: Document,
|
||||
folderId: String,
|
||||
pageToken: String?,
|
||||
getMultiFormPath: (String, String, String) -> String = { folderIdStr, nextPageTokenStr, keyStr ->
|
||||
defaultGetRequest(folderIdStr, nextPageTokenStr, keyStr)
|
||||
},
|
||||
): Request {
|
||||
val keyScript = document.select("script").first { script ->
|
||||
KEY_REGEX.find(script.data()) != null
|
||||
}.data()
|
||||
val key = KEY_REGEX.find(keyScript)?.groupValues?.get(1) ?: ""
|
||||
|
||||
val versionScript = document.select("script").first { script ->
|
||||
KEY_REGEX.find(script.data()) != null
|
||||
}.data()
|
||||
val driveVersion = VERSION_REGEX.find(versionScript)?.groupValues?.get(1) ?: ""
|
||||
val sapisid =
|
||||
client.cookieJar.loadForRequest("https://drive.google.com".toHttpUrl()).firstOrNull {
|
||||
it.name == "SAPISID" || it.name == "__Secure-3PAPISID"
|
||||
}?.value ?: ""
|
||||
|
||||
val requestUrl = getMultiFormPath(folderId, pageToken ?: "", key)
|
||||
val body = """--$BOUNDARY
|
||||
|content-type: application/http
|
||||
|content-transfer-encoding: binary
|
||||
|
|
||||
|GET $requestUrl
|
||||
|X-Goog-Drive-Client-Version: $driveVersion
|
||||
|authorization: ${generateSapisidhashHeader(sapisid)}
|
||||
|x-goog-authuser: 0
|
||||
|
|
||||
|--$BOUNDARY--""".trimMargin("|")
|
||||
.toRequestBody("multipart/mixed; boundary=\"$BOUNDARY\"".toMediaType())
|
||||
|
||||
val postUrl = buildString {
|
||||
append("https://clients6.google.com/batch/drive/v2internal")
|
||||
append("?${'$'}ct=multipart/mixed; boundary=\"$BOUNDARY\"")
|
||||
append("&key=$key")
|
||||
}
|
||||
|
||||
val postHeaders = headers.newBuilder().apply {
|
||||
add("Content-Type", "text/plain; charset=UTF-8")
|
||||
add("Origin", "https://drive.google.com")
|
||||
add("Cookie", getCookie("https://drive.google.com"))
|
||||
}.build()
|
||||
|
||||
return POST(postUrl, body = body, headers = postHeaders)
|
||||
}
|
||||
|
||||
private fun parsePage(
|
||||
request: Request,
|
||||
page: Int,
|
||||
genMultiFormReq: ((String, String, String) -> String)? = null,
|
||||
): AnimesPage {
|
||||
val animeList = mutableListOf<SAnime>()
|
||||
|
||||
val recurDepth = request.url.encodedFragment?.let { "#$it" } ?: ""
|
||||
|
||||
val folderId = DRIVE_FOLDER_REGEX.matchEntire(request.url.toString())!!.groups["id"]!!.value
|
||||
|
||||
val driveDocument = try {
|
||||
client.newCall(request).execute().asJsoup()
|
||||
} catch (a: ProtocolException) {
|
||||
throw Exception("Unable to get items, check webview")
|
||||
}
|
||||
|
||||
if (driveDocument.selectFirst("title:contains(Error 404 \\(Not found\\))") != null) {
|
||||
return AnimesPage(emptyList(), false)
|
||||
}
|
||||
|
||||
if (page == 1) nextPageToken = ""
|
||||
val post = if (genMultiFormReq == null) {
|
||||
createPost(driveDocument, folderId, nextPageToken)
|
||||
} else {
|
||||
createPost(
|
||||
driveDocument,
|
||||
folderId,
|
||||
nextPageToken,
|
||||
genMultiFormReq,
|
||||
)
|
||||
}
|
||||
val response = client.newCall(post).execute()
|
||||
|
||||
val parsed = response.parseAs<PostResponse> {
|
||||
JSON_REGEX.find(it)!!.groupValues[1]
|
||||
}
|
||||
|
||||
if (parsed.items == null) throw Exception("Failed to load items, please log in through webview")
|
||||
parsed.items.forEachIndexed { index, it ->
|
||||
if (it.mimeType.startsWith("video")) {
|
||||
animeList.add(
|
||||
SAnime.create().apply {
|
||||
title = if (preferences.trimAnimeInfo) it.title.trimInfo() else it.title
|
||||
url = LinkData(
|
||||
"https://drive.google.com/uc?id=${it.id}",
|
||||
"single",
|
||||
LinkDataInfo(
|
||||
it.title,
|
||||
it.fileSize?.toLongOrNull()?.let { formatBytes(it) } ?: "",
|
||||
),
|
||||
).toJsonString()
|
||||
thumbnail_url = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
if (it.mimeType.endsWith(".folder")) {
|
||||
animeList.add(
|
||||
SAnime.create().apply {
|
||||
title = if (preferences.trimAnimeInfo) it.title.trimInfo() else it.title
|
||||
url = LinkData(
|
||||
"https://drive.google.com/drive/folders/${it.id}$recurDepth",
|
||||
"multi",
|
||||
).toJsonString()
|
||||
thumbnail_url = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nextPageToken = parsed.nextPageToken
|
||||
|
||||
return AnimesPage(animeList, nextPageToken != null)
|
||||
}
|
||||
|
||||
// https://github.com/yt-dlp/yt-dlp/blob/8f0be90ecb3b8d862397177bb226f17b245ef933/yt_dlp/extractor/youtube.py#L573
|
||||
private fun generateSapisidhashHeader(
|
||||
SAPISID: String,
|
||||
origin: String = "https://drive.google.com",
|
||||
): String {
|
||||
val timeNow = System.currentTimeMillis() / 1000
|
||||
// SAPISIDHASH algorithm from https://stackoverflow.com/a/32065323
|
||||
val sapisidhash = MessageDigest
|
||||
.getInstance("SHA-1")
|
||||
.digest("$timeNow $SAPISID $origin".toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
return "SAPISIDHASH ${timeNow}_$sapisidhash"
|
||||
}
|
||||
|
||||
private fun String.trimInfo(): String {
|
||||
var newString = this.replaceFirst("""^\[\w+\] ?""".toRegex(), "")
|
||||
val regex = """( ?\[[\s\w-]+\]| ?\([\s\w-]+\))(\.mkv|\.mp4|\.avi)?${'$'}""".toRegex()
|
||||
|
||||
while (regex.containsMatchIn(newString)) {
|
||||
newString = regex.replace(newString) { matchResult ->
|
||||
matchResult.groups[2]?.value ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
return newString.trim()
|
||||
}
|
||||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
return when {
|
||||
bytes >= 1_000_000_000 -> "%.2f GB".format(bytes / 1_000_000_000.0)
|
||||
bytes >= 1_000_000 -> "%.2f MB".format(bytes / 1_000_000.0)
|
||||
bytes >= 1_000 -> "%.2f KB".format(bytes / 1_000.0)
|
||||
bytes > 1 -> "$bytes bytes"
|
||||
bytes == 1L -> "$bytes byte"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCookie(url: String): String {
|
||||
val cookieList = client.cookieJar.loadForRequest(url.toHttpUrl())
|
||||
return if (cookieList.isNotEmpty()) {
|
||||
cookieList.joinToString("; ") { "${it.name}=${it.value}" }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun LinkData.toJsonString(): String {
|
||||
return json.encodeToString(this)
|
||||
}
|
||||
|
||||
private fun isFolder(text: String) = DRIVE_FOLDER_REGEX matches text
|
||||
|
||||
/*
|
||||
* Stolen from the MangaDex manga extension
|
||||
*
|
||||
* This will likely need to be removed or revisited when the app migrates the
|
||||
* extension preferences screen to Compose.
|
||||
*/
|
||||
private fun setupEditTextFolderValidator(editText: EditText) {
|
||||
editText.addTextChangedListener(
|
||||
object : TextWatcher {
|
||||
|
||||
override fun beforeTextChanged(
|
||||
s: CharSequence?,
|
||||
start: Int,
|
||||
count: Int,
|
||||
after: Int,
|
||||
) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun afterTextChanged(editable: Editable?) {
|
||||
requireNotNull(editable)
|
||||
|
||||
val text = editable.toString()
|
||||
|
||||
val isValid = text.isBlank() || text
|
||||
.split(";")
|
||||
.map(String::trim)
|
||||
.all(::isFolder)
|
||||
|
||||
editText.error = if (!isValid) {
|
||||
"${
|
||||
text.split(";").first { !isFolder(it) }
|
||||
} is not a valid google drive folder"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
editText.rootView.findViewById<Button>(android.R.id.button1)
|
||||
?.isEnabled = editText.error == null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DOMAIN_PREF_KEY = "domain_list"
|
||||
private const val DOMAIN_PREF_DEFAULT = ""
|
||||
|
||||
private const val TRIM_ANIME_KEY = "trim_anime_info"
|
||||
private const val TRIM_ANIME_DEFAULT = false
|
||||
|
||||
private const val TRIM_EPISODE_NAME_KEY = "trim_episode_name"
|
||||
private const val TRIM_EPISODE_NAME_DEFAULT = true
|
||||
|
||||
private const val TRIM_EPISODE_INFO_KEY = "trim_episode_info"
|
||||
private const val TRIM_EPISODE_INFO_DEFAULT = false
|
||||
|
||||
private const val SCANLATOR_ORDER_KEY = "scanlator_order"
|
||||
private const val SCANLATOR_ORDER_DEFAULT = false
|
||||
|
||||
private val DRIVE_FOLDER_REGEX = Regex(
|
||||
"""(?<name>\[[^\[\];]+\])?https?:\/\/(?:docs|drive)\.google\.com\/drive(?:\/[^\/]+)*?\/folders\/(?<id>[\w-]{28,})(?:\?[^;#]+)?(?<depth>#\d+(?<range>,\d+,\d+)?)?${'$'}""",
|
||||
)
|
||||
private val KEY_REGEX = Regex(""""(\w{39})"""")
|
||||
private val VERSION_REGEX = Regex(""""([^"]+web-frontend[^"]+)"""")
|
||||
private val JSON_REGEX = Regex("""(?:)\s*(\{(.+)\})\s*(?:)""", RegexOption.DOT_MATCHES_ALL)
|
||||
private const val BOUNDARY = "=====vc17a3rwnndj====="
|
||||
|
||||
private val ITEM_NUMBER_REGEX = Regex(""" - (?:S\d+E)?(\d+)\b""")
|
||||
}
|
||||
|
||||
private val SharedPreferences.domainList
|
||||
get() = getString(DOMAIN_PREF_KEY, DOMAIN_PREF_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.trimAnimeInfo
|
||||
get() = getBoolean(TRIM_ANIME_KEY, TRIM_ANIME_DEFAULT)
|
||||
|
||||
private val SharedPreferences.trimEpisodeName
|
||||
get() = getBoolean(TRIM_EPISODE_NAME_KEY, TRIM_EPISODE_NAME_DEFAULT)
|
||||
|
||||
private val SharedPreferences.trimEpisodeInfo
|
||||
get() = getBoolean(TRIM_EPISODE_INFO_KEY, TRIM_EPISODE_INFO_DEFAULT)
|
||||
|
||||
private val SharedPreferences.scanlatorOrder
|
||||
get() = getBoolean(SCANLATOR_ORDER_KEY, SCANLATOR_ORDER_DEFAULT)
|
||||
|
||||
// ============================== Settings ==============================
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = DOMAIN_PREF_KEY
|
||||
title = "Enter drive paths to be shown in extension"
|
||||
summary = """Enter links of drive folders to be shown in extension
|
||||
|Enter as a semicolon `;` separated list
|
||||
""".trimMargin()
|
||||
this.setDefaultValue(DOMAIN_PREF_DEFAULT)
|
||||
dialogTitle = "Path list"
|
||||
dialogMessage = """Separate paths with a semicolon.
|
||||
|- (optional) Add [] before url to customize name. For example: [drive 5]https://drive.google.com/drive/folders/whatever
|
||||
|- (optional) add #<integer> to limit the depth of recursion when loading episodes, defaults is 2. For example: https://drive.google.com/drive/folders/whatever#5
|
||||
|- (optional) add #depth,start,stop (all integers) to specify range when loading episodes. Only works if depth is 1. For example: https://drive.google.com/drive/folders/whatever#1,2,6
|
||||
""".trimMargin()
|
||||
|
||||
setOnBindEditTextListener(::setupEditTextFolderValidator)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res =
|
||||
preferences.edit().putString(DOMAIN_PREF_KEY, newValue as String).commit()
|
||||
Toast.makeText(
|
||||
screen.context,
|
||||
"Restart Aniyomi to apply changes",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
res
|
||||
} catch (e: java.lang.Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = TRIM_ANIME_KEY
|
||||
title = "Trim info from anime titles"
|
||||
setDefaultValue(TRIM_ANIME_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = TRIM_EPISODE_NAME_KEY
|
||||
title = "Trim info from episode name"
|
||||
setDefaultValue(TRIM_EPISODE_NAME_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = TRIM_EPISODE_INFO_KEY
|
||||
title = "Trim info from episode info"
|
||||
setDefaultValue(TRIM_EPISODE_INFO_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SCANLATOR_ORDER_KEY
|
||||
title = "Switch order of file path and size"
|
||||
setDefaultValue(SCANLATOR_ORDER_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.googledrive
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PostResponse(
|
||||
val nextPageToken: String? = null,
|
||||
val items: List<ResponseItem>? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class ResponseItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val mimeType: String,
|
||||
val fileSize: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LinkData(
|
||||
val url: String,
|
||||
val type: String,
|
||||
val info: LinkDataInfo? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LinkDataInfo(
|
||||
val title: String,
|
||||
val size: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DownloadResponse(
|
||||
val downloadUrl: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DetailsJson(
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val artist: String? = null,
|
||||
val description: String? = null,
|
||||
val genre: List<String>? = null,
|
||||
val status: String? = null,
|
||||
)
|
|
@ -0,0 +1,18 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.googledrive
|
||||
|
||||
fun searchReq(parentId: String, query: String): (String, String, String) -> String {
|
||||
return searchReqWithType(parentId, query, type = FOLDER_MIMETYPE)
|
||||
}
|
||||
|
||||
fun searchReqWithType(parentId: String, query: String, type: String): (String, String, String) -> String {
|
||||
return { _: String, nextPageToken: String, key: String ->
|
||||
"/drive/v2internal/files?openDrive=false&reason=111&syncType=0&errorRecovery=false&q=title%20contains%20'$query'$type%20and%20trashed%20%3D%20false%20and%20'$parentId'%20in%20ancestors&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2ChasVisitorPermissions%2CcontainsUnsubscribedChildren%2CmodifiedByMeDate%2ClastViewedByMeDate%2CalternateLink%2CfileSize%2Cowners(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2ClastModifyingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CcustomerId%2CancestorHasAugmentedPermissions%2ChasThumbnail%2CthumbnailVersion%2Ctitle%2Cid%2CresourceKey%2CabuseIsAppealable%2CabuseNoticeReason%2Cshared%2CaccessRequestsCount%2CsharedWithMeDate%2CuserPermission(role)%2CexplicitlyTrashed%2CmimeType%2CquotaBytesUsed%2Ccopyable%2Csubscribed%2CfolderColor%2ChasChildFolders%2CfileExtension%2CprimarySyncParentId%2CsharingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CflaggedForAbuse%2CfolderFeatures%2Cspaces%2CsourceAppId%2Crecency%2CrecencyReason%2Cversion%2CactionItems%2CteamDriveId%2ChasAugmentedPermissions%2CcreatedDate%2CprimaryDomainName%2CorganizationDisplayName%2CpassivelySubscribed%2CtrashingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CtrashedDate%2Cparents(id)%2Ccapabilities(canMoveItemIntoTeamDrive%2CcanUntrash%2CcanModifyContentRestriction%2CcanMoveItemWithinTeamDrive%2CcanMoveItemOutOfTeamDrive%2CcanDeleteChildren%2CcanTrashChildren%2CcanRequestApproval%2CcanReadCategoryMetadata%2CcanEditCategoryMetadata%2CcanAddMyDriveParent%2CcanRemoveMyDriveParent%2CcanShareChildFiles%2CcanShareChildFolders%2CcanRead%2CcanMoveItemWithinDrive%2CcanMoveChildrenWithinDrive%2CcanAddFolderFromAnotherDrive%2CcanChangeSecurityUpdateEnabled%2CcanBlockOwner%2CcanReportSpamOrAbuse%2CcanCopy%2CcanDownload%2CcanEdit%2CcanAddChildren%2CcanDelete%2CcanRemoveChildren%2CcanShare%2CcanTrash%2CcanRename%2CcanReadTeamDrive%2CcanMoveTeamDriveItem)%2CcontentRestrictions(readOnly)%2CapprovalMetadata(approvalVersion%2CapprovalSummaries%2ChasIncomingApproval)%2CshortcutDetails(targetId%2CtargetMimeType%2CtargetLookupStatus%2CtargetFile%2CcanRequestAccessToTarget)%2CspamMetadata(markedAsSpamDate%2CinSpamView)%2Clabels(starred%2Ctrashed%2Crestricted%2Cviewed))%2CincompleteSearch&appDataFilter=NO_APP_DATA&spaces=drive&pageToken=$nextPageToken&maxResults=50&rawUserQuery=parent%3A$parentId%20type%3Afolder%20title%3A$query&supportsTeamDrives=true&includeItemsFromAllDrives=true&corpora=default&orderBy=relevance%20desc&retryCount=0&key=$key HTTP/1.1"
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultGetRequest(folderId: String, nextPageToken: String, key: String): String {
|
||||
return "/drive/v2internal/files?openDrive=false&reason=102&syncType=0&errorRecovery=false&q=trashed%20%3D%20false%20and%20'$folderId'%20in%20parents&fields=kind%2CnextPageToken%2Citems(kind%2CmodifiedDate%2ChasVisitorPermissions%2CcontainsUnsubscribedChildren%2CmodifiedByMeDate%2ClastViewedByMeDate%2CalternateLink%2CfileSize%2Cowners(kind%2CpermissionId%2CemailAddressFromAccount%2Cdomain%2Cid)%2ClastModifyingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CcustomerId%2CancestorHasAugmentedPermissions%2ChasThumbnail%2CthumbnailVersion%2Ctitle%2Cid%2CresourceKey%2CabuseIsAppealable%2CabuseNoticeReason%2Cshared%2CaccessRequestsCount%2CsharedWithMeDate%2CuserPermission(role)%2CexplicitlyTrashed%2CmimeType%2CquotaBytesUsed%2Ccopyable%2Csubscribed%2CfolderColor%2ChasChildFolders%2CfileExtension%2CprimarySyncParentId%2CsharingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CflaggedForAbuse%2CfolderFeatures%2Cspaces%2CsourceAppId%2Crecency%2CrecencyReason%2Cversion%2CactionItems%2CteamDriveId%2ChasAugmentedPermissions%2CcreatedDate%2CprimaryDomainName%2CorganizationDisplayName%2CpassivelySubscribed%2CtrashingUser(kind%2CpermissionId%2CemailAddressFromAccount%2Cid)%2CtrashedDate%2Cparents(id)%2Ccapabilities(canMoveItemIntoTeamDrive%2CcanUntrash%2CcanMoveItemWithinTeamDrive%2CcanMoveItemOutOfTeamDrive%2CcanDeleteChildren%2CcanTrashChildren%2CcanRequestApproval%2CcanReadCategoryMetadata%2CcanEditCategoryMetadata%2CcanAddMyDriveParent%2CcanRemoveMyDriveParent%2CcanShareChildFiles%2CcanShareChildFolders%2CcanRead%2CcanMoveItemWithinDrive%2CcanMoveChildrenWithinDrive%2CcanAddFolderFromAnotherDrive%2CcanChangeSecurityUpdateEnabled%2CcanBlockOwner%2CcanReportSpamOrAbuse%2CcanCopy%2CcanDownload%2CcanEdit%2CcanAddChildren%2CcanDelete%2CcanRemoveChildren%2CcanShare%2CcanTrash%2CcanRename%2CcanReadTeamDrive%2CcanMoveTeamDriveItem)%2CcontentRestrictions(readOnly)%2CapprovalMetadata(approvalVersion%2CapprovalSummaries%2ChasIncomingApproval)%2CshortcutDetails(targetId%2CtargetMimeType%2CtargetLookupStatus%2CtargetFile%2CcanRequestAccessToTarget)%2CspamMetadata(markedAsSpamDate%2CinSpamView)%2Clabels(starred%2Ctrashed%2Crestricted%2Cviewed))%2CincompleteSearch&appDataFilter=NO_APP_DATA&spaces=drive&pageToken=$nextPageToken&maxResults=100&supportsTeamDrives=true&includeItemsFromAllDrives=true&corpora=default&orderBy=folder%2Ctitle_natural%20asc&retryCount=0&key=$key HTTP/1.1"
|
||||
}
|
||||
|
||||
const val IMAGE_MIMETYPE = "%20and%20(mimeType%20in%20'image%2Fbmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fbmp%22'%2C%20'image%2Fjpeg'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fjpeg%22'%2C%20'image%2Fpng'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fpng%22'%2C%20'image%2Fgif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fgif%22'%2C%20'image%2Ftiff'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Ftiff%22'%2C%20'image%2Fx-ms-bmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fx-ms-bmp%22'%2C%20'image%2Fsvg%2Bxml'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fsvg%2Bxml%22'%2C%20'image%2Fvnd.microsoft.icon'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fvnd.microsoft.icon%22'%2C%20'image%2Fheif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheif%22'%2C%20'image%2Fheic'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheic%22'%2C%20'image%2Fwebp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fwebp%22'%20or%20shortcutDetails.targetMimeType%20in%20'image%2Fbmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fbmp%22'%2C%20'image%2Fjpeg'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fjpeg%22'%2C%20'image%2Fpng'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fpng%22'%2C%20'image%2Fgif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fgif%22'%2C%20'image%2Ftiff'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Ftiff%22'%2C%20'image%2Fx-ms-bmp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fx-ms-bmp%22'%2C%20'image%2Fsvg%2Bxml'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fsvg%2Bxml%22'%2C%20'image%2Fvnd.microsoft.icon'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fvnd.microsoft.icon%22'%2C%20'image%2Fheif'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheif%22'%2C%20'image%2Fheic'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fheic%22'%2C%20'image%2Fwebp'%2C%20'application%2Fvnd.google-gsuite.encrypted%3B%20content%3D%22image%2Fwebp%22')"
|
||||
const val FOLDER_MIMETYPE = "%20and%20(mimeType%20in%20'application%2Fvnd.google-apps.folder'%20or%20shortcutDetails.targetMimeType%20in%20'application%2Fvnd.google-apps.folder')"
|
7
src/all/googledriveindex/build.gradle
Normal file
|
@ -0,0 +1,7 @@
|
|||
ext {
|
||||
extName = 'GoogleDriveIndex'
|
||||
extClass = '.GoogleDriveIndex'
|
||||
extVersionCode = 7
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/all/googledriveindex/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
src/all/googledriveindex/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
src/all/googledriveindex/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.4 KiB |
BIN
src/all/googledriveindex/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 8.1 KiB |
BIN
src/all/googledriveindex/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/all/googledriveindex/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 61 KiB |
|
@ -0,0 +1,743 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.googledriveindex
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.Base64
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLEncoder
|
||||
|
||||
class GoogleDriveIndex : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
|
||||
override val name = "GoogleDriveIndex"
|
||||
|
||||
override val baseUrl by lazy {
|
||||
preferences.domainList.split(",").first().removeName()
|
||||
}
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
private var pageToken: String? = ""
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
override val client = network.client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
var request = chain.request()
|
||||
|
||||
if (request.url.username.isNotBlank() && request.url.password.isNotBlank()) {
|
||||
|
||||
val credential = Credentials.basic(request.url.username, request.url.password)
|
||||
request = request.newBuilder()
|
||||
.header("Authorization", credential)
|
||||
.build()
|
||||
|
||||
val newUrl = request.url.newBuilder()
|
||||
.username("")
|
||||
.password("")
|
||||
.build()
|
||||
|
||||
request = request.newBuilder()
|
||||
.url(newUrl)
|
||||
.build()
|
||||
}
|
||||
|
||||
chain.proceed(request)
|
||||
}
|
||||
.build()
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override fun popularAnimeRequest(page: Int): Request {
|
||||
require(baseUrl.isNotEmpty()) { "Enter drive path(s) in extension settings." }
|
||||
require(baseUrl.toHttpUrl().host != "drive.google.com") {
|
||||
"This extension is only for Google Drive Index sites, not drive.google.com folders."
|
||||
}
|
||||
|
||||
if (page == 1) pageToken = ""
|
||||
val popHeaders = headers.newBuilder()
|
||||
.add("Accept", "*/*")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.add("Host", baseUrl.toHttpUrl().host)
|
||||
.add("Origin", "https://${baseUrl.toHttpUrl().host}")
|
||||
.add("Referer", baseUrl.asReferer())
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
val popBody = "password=&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
|
||||
return POST(baseUrl, body = popBody, headers = popHeaders)
|
||||
}
|
||||
|
||||
override fun popularAnimeParse(response: Response): AnimesPage = parsePage(response, baseUrl)
|
||||
|
||||
// =============================== Latest ===============================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage = throw UnsupportedOperationException()
|
||||
|
||||
// =============================== Search ===============================
|
||||
|
||||
override fun searchAnimeParse(response: Response): AnimesPage = throw UnsupportedOperationException()
|
||||
|
||||
override suspend fun getSearchAnime(
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: AnimeFilterList,
|
||||
): AnimesPage {
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val urlFilter = filterList.find { it is URLFilter } as URLFilter
|
||||
|
||||
return if (urlFilter.state.isEmpty()) {
|
||||
val req = searchAnimeRequest(page, query, filters)
|
||||
client.newCall(req).awaitSuccess()
|
||||
.let { response ->
|
||||
searchAnimeParse(response, req.url.toString())
|
||||
}
|
||||
} else {
|
||||
addSinglePage(urlFilter.state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSinglePage(inputUrl: String): AnimesPage {
|
||||
val match = URL_REGEX.matchEntire(inputUrl) ?: throw Exception("Invalid url")
|
||||
val anime = SAnime.create().apply {
|
||||
title = match.groups["name"]?.value?.substringAfter("[")?.substringBeforeLast("]") ?: "Folder"
|
||||
url = LinkData(
|
||||
type = "multi",
|
||||
url = match.groups["url"]!!.value,
|
||||
fragment = inputUrl.removeName().toHttpUrl().encodedFragment,
|
||||
).toJsonString()
|
||||
thumbnail_url = ""
|
||||
}
|
||||
return AnimesPage(listOf(anime), false)
|
||||
}
|
||||
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
require(baseUrl.isNotEmpty()) { "Enter drive path(s) in extension settings." }
|
||||
require(baseUrl.toHttpUrl().host != "drive.google.com") {
|
||||
"This extension is only for Google Drive Index sites, not drive.google.com folders."
|
||||
}
|
||||
|
||||
val filterList = if (filters.isEmpty()) getFilterList() else filters
|
||||
val serverFilter = filterList.find { it is ServerFilter } as ServerFilter
|
||||
val serverUrl = serverFilter.toUriPart()
|
||||
|
||||
if (page == 1) pageToken = ""
|
||||
val searchHeaders = headers.newBuilder()
|
||||
.add("Accept", "*/*")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.add("Host", serverUrl.toHttpUrl().host)
|
||||
.add("Origin", "https://${serverUrl.toHttpUrl().host}")
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
|
||||
return when {
|
||||
query.isBlank() -> {
|
||||
val popBody = "password=&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
|
||||
POST(
|
||||
serverUrl,
|
||||
body = popBody,
|
||||
headers = searchHeaders.add("Referer", serverUrl.asReferer()).build(),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val cleanQuery = query.replace(" ", "+")
|
||||
val searchUrl = "https://${serverUrl.toHttpUrl().hostAndCred()}/${serverUrl.toHttpUrl().pathSegments[0]}search"
|
||||
val popBody = "q=$cleanQuery&page_token=$pageToken&page_index=${page - 1}".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
|
||||
POST(
|
||||
searchUrl,
|
||||
body = popBody,
|
||||
headers = searchHeaders.add("Referer", "$searchUrl?q=$cleanQuery").build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchAnimeParse(response: Response, url: String): AnimesPage = parsePage(response, url)
|
||||
|
||||
// ============================== FILTERS ===============================
|
||||
|
||||
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
|
||||
AnimeFilter.Header("Text search will only search inside selected server"),
|
||||
ServerFilter(getDomains()),
|
||||
AnimeFilter.Header("Add single folder"),
|
||||
URLFilter(),
|
||||
)
|
||||
|
||||
private class ServerFilter(domains: Array<Pair<String, String>>) : UriPartFilter(
|
||||
"Select server",
|
||||
domains,
|
||||
)
|
||||
|
||||
private fun getDomains(): Array<Pair<String, String>> {
|
||||
if (preferences.domainList.isBlank()) return emptyArray()
|
||||
return preferences.domainList.split(",").map {
|
||||
val match = URL_REGEX.matchEntire(it)!!
|
||||
val name = match.groups["name"]?.let {
|
||||
it.value.substringAfter("[").substringBeforeLast("]")
|
||||
}
|
||||
Pair(name ?: it.toHttpUrl().encodedPath, it.removeName())
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
private open class UriPartFilter(displayName: String, val vals: Array<Pair<String, String>>) :
|
||||
AnimeFilter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
private class URLFilter : AnimeFilter.Text("Url")
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
|
||||
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
|
||||
val parsed = json.decodeFromString<LinkData>(anime.url)
|
||||
val newParsed = if (parsed.type != "search") {
|
||||
parsed
|
||||
} else {
|
||||
val idParsed = json.decodeFromString<IdUrl>(parsed.url)
|
||||
val id2pathHeaders = headers.newBuilder()
|
||||
.add("Accept", "*/*")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.add("Host", idParsed.url.toHttpUrl().host)
|
||||
.add("Origin", "https://${idParsed.url.toHttpUrl().host}")
|
||||
.add("Referer", URLEncoder.encode(idParsed.referer, "UTF-8"))
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
val postBody = "id=${idParsed.id}".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
val slug = client.newCall(
|
||||
POST(idParsed.url + "id2path", body = postBody, headers = id2pathHeaders),
|
||||
).execute().body.string()
|
||||
|
||||
LinkData(
|
||||
idParsed.type,
|
||||
idParsed.url + slug,
|
||||
parsed.info,
|
||||
)
|
||||
}
|
||||
|
||||
if (newParsed.type == "single") {
|
||||
return anime
|
||||
}
|
||||
|
||||
var newToken: String? = ""
|
||||
var newPageIndex = 0
|
||||
while (newToken != null) {
|
||||
val popHeaders = headers.newBuilder()
|
||||
.add("Accept", "*/*")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.add("Host", newParsed.url.toHttpUrl().host)
|
||||
.add("Origin", "https://${newParsed.url.toHttpUrl().host}")
|
||||
.add("Referer", URLEncoder.encode(newParsed.url, "UTF-8"))
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
val popBody = "password=&page_token=$newToken&page_index=$newPageIndex".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
|
||||
val parsed = client.newCall(
|
||||
POST(newParsed.url, body = popBody, headers = popHeaders),
|
||||
).execute().parseAs<ResponseData> { it.decrypt() }
|
||||
|
||||
parsed.data.files.forEach { item ->
|
||||
if (item.mimeType.startsWith("image/") && item.name.startsWith("cover", true)) {
|
||||
anime.thumbnail_url = joinUrl(newParsed.url, item.name)
|
||||
}
|
||||
|
||||
if (item.name.equals("details.json", true)) {
|
||||
val details = client.newCall(
|
||||
GET(joinUrl(newParsed.url, item.name)),
|
||||
).execute().body.string()
|
||||
val detailsParsed = json.decodeFromString<Details>(details)
|
||||
detailsParsed.title?.let { anime.title = it }
|
||||
detailsParsed.author?.let { anime.author = it }
|
||||
detailsParsed.artist?.let { anime.artist = it }
|
||||
detailsParsed.description?.let { anime.description = it }
|
||||
detailsParsed.genre?.let { anime.genre = it.joinToString(", ") }
|
||||
detailsParsed.status?.let { anime.status = it.toIntOrNull() ?: SAnime.UNKNOWN }
|
||||
}
|
||||
}
|
||||
|
||||
newToken = parsed.nextPageToken
|
||||
newPageIndex += 1
|
||||
}
|
||||
|
||||
return anime
|
||||
}
|
||||
|
||||
override fun animeDetailsParse(response: Response): SAnime = throw UnsupportedOperationException()
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
|
||||
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
|
||||
val episodeList = mutableListOf<SEpisode>()
|
||||
val parsed = json.decodeFromString<LinkData>(anime.url)
|
||||
var counter = 1
|
||||
val maxRecursionDepth = parsed.fragment?.substringBefore(",")?.toInt() ?: 2
|
||||
val (start, stop) = if (parsed.fragment?.contains(",") == true) {
|
||||
parsed.fragment.substringAfter(",").split(",").map { it.toInt() }
|
||||
} else {
|
||||
listOf(null, null)
|
||||
}
|
||||
|
||||
val newParsed = if (parsed.type != "search") {
|
||||
parsed
|
||||
} else {
|
||||
val idParsed = json.decodeFromString<IdUrl>(parsed.url)
|
||||
val id2pathHeaders = headers.newBuilder()
|
||||
.add("Accept", "*/*")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.add("Host", idParsed.url.toHttpUrl().host)
|
||||
.add("Origin", "https://${idParsed.url.toHttpUrl().host}")
|
||||
.add("Referer", URLEncoder.encode(idParsed.referer, "UTF-8"))
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
val postBody = "id=${idParsed.id}".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
val slug = client.newCall(
|
||||
POST(idParsed.url + "id2path", body = postBody, headers = id2pathHeaders),
|
||||
).execute().body.string()
|
||||
|
||||
LinkData(
|
||||
idParsed.type,
|
||||
idParsed.url + slug,
|
||||
parsed.info,
|
||||
)
|
||||
}
|
||||
|
||||
if (newParsed.type == "single") {
|
||||
val titleName = newParsed.url.toHttpUrl().pathSegments.last()
|
||||
episodeList.add(
|
||||
SEpisode.create().apply {
|
||||
name = if (preferences.trimEpisodeName) titleName.trimInfo() else titleName
|
||||
url = newParsed.url
|
||||
episode_number = 1F
|
||||
date_upload = -1L
|
||||
scanlator = newParsed.info
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (newParsed.type == "multi") {
|
||||
val basePathCounter = newParsed.url.toHttpUrl().pathSize
|
||||
|
||||
fun traverseDirectory(url: String, recursionDepth: Int = 0) {
|
||||
if (recursionDepth == maxRecursionDepth) return
|
||||
var newToken: String? = ""
|
||||
var newPageIndex = 0
|
||||
|
||||
while (newToken != null) {
|
||||
val popHeaders = headers.newBuilder()
|
||||
.add("Accept", "*/*")
|
||||
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
.add("Host", url.toHttpUrl().host)
|
||||
.add("Origin", "https://${url.toHttpUrl().host}")
|
||||
.add("Referer", URLEncoder.encode(url, "UTF-8"))
|
||||
.add("X-Requested-With", "XMLHttpRequest")
|
||||
.build()
|
||||
|
||||
val popBody = "password=&page_token=$newToken&page_index=$newPageIndex".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
|
||||
val parsed = client.newCall(
|
||||
POST(url, body = popBody, headers = popHeaders),
|
||||
).execute().parseAs<ResponseData> { it.decrypt() }
|
||||
|
||||
parsed.data.files.forEach { item ->
|
||||
if (item.mimeType.endsWith("folder")) {
|
||||
val newUrl = joinUrl(url, item.name).addSuffix("/")
|
||||
traverseDirectory(newUrl, recursionDepth + 1)
|
||||
}
|
||||
if (item.mimeType.startsWith("video/")) {
|
||||
if (start != null && maxRecursionDepth == 1 && counter < start) {
|
||||
counter++
|
||||
return@forEach
|
||||
}
|
||||
if (stop != null && maxRecursionDepth == 1 && counter > stop) return
|
||||
|
||||
val epUrl = joinUrl(url, item.name)
|
||||
val paths = epUrl.toHttpUrl().pathSegments
|
||||
|
||||
// Get season stuff
|
||||
val season = if (paths.size == basePathCounter) {
|
||||
""
|
||||
} else {
|
||||
paths[basePathCounter - 1]
|
||||
}
|
||||
val seasonInfoRegex = """(\([\s\w-]+\))(?: ?\[[\s\w-]+\])?${'$'}""".toRegex()
|
||||
val seasonInfo = if (seasonInfoRegex.containsMatchIn(season)) {
|
||||
"${seasonInfoRegex.find(season)!!.groups[1]!!.value} • "
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Get other info
|
||||
val extraInfo = if (paths.size > basePathCounter) {
|
||||
"/" + paths.subList(basePathCounter - 1, paths.size - 1).joinToString("/") { it.trimInfo() }
|
||||
} else {
|
||||
"/"
|
||||
}
|
||||
val size = item.size?.toLongOrNull()?.let { formatFileSize(it) }
|
||||
|
||||
episodeList.add(
|
||||
SEpisode.create().apply {
|
||||
name = if (preferences.trimEpisodeName) item.name.trimInfo() else item.name
|
||||
this.url = epUrl
|
||||
scanlator = "${if (size == null) "" else "$size"} • $seasonInfo$extraInfo"
|
||||
date_upload = -1L
|
||||
episode_number = counter.toFloat()
|
||||
},
|
||||
)
|
||||
counter++
|
||||
}
|
||||
}
|
||||
|
||||
newToken = parsed.nextPageToken
|
||||
newPageIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
traverseDirectory(newParsed.url)
|
||||
}
|
||||
|
||||
return episodeList.reversed()
|
||||
}
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> = throw UnsupportedOperationException()
|
||||
|
||||
// ============================ Video Links =============================
|
||||
|
||||
override suspend fun getVideoList(episode: SEpisode): List<Video> {
|
||||
val url = episode.url
|
||||
|
||||
val doc = client.newCall(
|
||||
GET("$url?a=view"),
|
||||
).execute().asJsoup()
|
||||
|
||||
val script = doc.selectFirst("script:containsData(videodomain)")?.data()
|
||||
?: doc.selectFirst("script:containsData(downloaddomain)")?.data()
|
||||
?: return listOf(Video(url, "Video", url))
|
||||
|
||||
if (script.contains("\"second_domain_for_dl\":false")) {
|
||||
return listOf(Video(url, "Video", url))
|
||||
}
|
||||
|
||||
val domainUrl = if (script.contains("videodomain", true)) {
|
||||
script
|
||||
.substringAfter("\"videodomain\":\"")
|
||||
.substringBefore("\"")
|
||||
} else {
|
||||
script
|
||||
.substringAfter("\"downloaddomain\":\"")
|
||||
.substringBefore("\"")
|
||||
}
|
||||
|
||||
val videoUrl = if (domainUrl.isBlank()) {
|
||||
url
|
||||
} else {
|
||||
domainUrl + url.toHttpUrl().encodedPath
|
||||
}
|
||||
|
||||
return listOf(Video(videoUrl, "Video", videoUrl))
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
private fun HttpUrl.hostAndCred(): String {
|
||||
return if (this.password.isNotBlank() && this.username.isNotBlank()) {
|
||||
"${this.username}:${this.password}@${this.host}"
|
||||
} else {
|
||||
this.host
|
||||
}
|
||||
}
|
||||
|
||||
private fun joinUrl(path1: String, path2: String): String {
|
||||
return path1.removeSuffix("/") + "/" + path2.removePrefix("/")
|
||||
}
|
||||
|
||||
private fun String.decrypt(): String {
|
||||
return Base64.decode(this.reversed().substring(24, this.length - 20), Base64.DEFAULT).toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun String.addSuffix(suffix: String): String {
|
||||
return if (this.endsWith(suffix)) {
|
||||
this
|
||||
} else {
|
||||
this.plus(suffix)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.trimInfo(): String {
|
||||
var newString = this.replaceFirst("""^\[[\w-]+\] ?""".toRegex(), "")
|
||||
val regex = """( ?\[[\s\w-]+\]| ?\([\s\w-]+\))(\.mkv|\.mp4|\.avi)?${'$'}""".toRegex()
|
||||
|
||||
while (regex.containsMatchIn(newString)) {
|
||||
newString = regex.replace(newString) { matchResult ->
|
||||
matchResult.groups[2]?.value ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
return newString.trim()
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes >= 1_000_000_000 -> "%.2f GB".format(bytes / 1_000_000_000.0)
|
||||
bytes >= 1_000_000 -> "%.2f MB".format(bytes / 1_000_000.0)
|
||||
bytes >= 1_000 -> "%.2f KB".format(bytes / 1_000.0)
|
||||
bytes > 1 -> "$bytes bytes"
|
||||
bytes == 1L -> "$bytes byte"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.asReferer(): String {
|
||||
return URLEncoder.encode(
|
||||
this.toHttpUrl().let {
|
||||
"https://${it.host}${it.encodedPath}"
|
||||
},
|
||||
"UTF-8",
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.removeName(): String = Regex("""^(\[[^\[\];]+\])""").replace(this, "")
|
||||
|
||||
private fun LinkData.toJsonString(): String {
|
||||
return json.encodeToString(this)
|
||||
}
|
||||
|
||||
private fun IdUrl.toJsonString(): String {
|
||||
return json.encodeToString(this)
|
||||
}
|
||||
|
||||
private fun parsePage(response: Response, url: String): AnimesPage {
|
||||
val parsed = json.decodeFromString<ResponseData>(response.body.string().decrypt())
|
||||
val animeList = mutableListOf<SAnime>()
|
||||
val isSearch = url.endsWith(":search")
|
||||
|
||||
parsed.data.files.forEach { item ->
|
||||
if (item.mimeType.endsWith("folder")) {
|
||||
animeList.add(
|
||||
SAnime.create().apply {
|
||||
title = if (preferences.trimAnimeName) item.name.trimInfo() else item.name
|
||||
thumbnail_url = ""
|
||||
this.url = if (isSearch) {
|
||||
LinkData(
|
||||
"search",
|
||||
IdUrl(
|
||||
item.id,
|
||||
url.substringBeforeLast("search"),
|
||||
response.request.header("Referer")!!,
|
||||
"multi",
|
||||
).toJsonString(),
|
||||
).toJsonString()
|
||||
} else {
|
||||
LinkData(
|
||||
"multi",
|
||||
joinUrl(URL_REGEX.matchEntire(url)!!.groups["url"]!!.value, item.name).addSuffix("/"),
|
||||
fragment = url.toHttpUrl().encodedFragment,
|
||||
).toJsonString()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (
|
||||
item.mimeType.startsWith("video/") &&
|
||||
!(preferences.ignoreFolder && isSearch)
|
||||
) {
|
||||
animeList.add(
|
||||
SAnime.create().apply {
|
||||
title = if (preferences.trimAnimeName) item.name.trimInfo() else item.name
|
||||
thumbnail_url = ""
|
||||
this.url = if (isSearch) {
|
||||
LinkData(
|
||||
"search",
|
||||
IdUrl(
|
||||
item.id,
|
||||
url.substringBeforeLast("search"),
|
||||
response.request.header("Referer")!!,
|
||||
"single",
|
||||
).toJsonString(),
|
||||
item.size?.toLongOrNull()?.let { formatFileSize(it) },
|
||||
).toJsonString()
|
||||
} else {
|
||||
LinkData(
|
||||
"single",
|
||||
joinUrl(URL_REGEX.matchEntire(url)!!.groups["url"]!!.value, item.name),
|
||||
item.size?.toLongOrNull()?.let { formatFileSize(it) },
|
||||
fragment = url.toHttpUrl().encodedFragment,
|
||||
).toJsonString()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pageToken = parsed.nextPageToken
|
||||
|
||||
return AnimesPage(animeList, parsed.nextPageToken != null)
|
||||
}
|
||||
|
||||
private fun isUrl(text: String) = URL_REGEX matches text
|
||||
|
||||
/*
|
||||
* Stolen from the MangaDex manga extension
|
||||
*
|
||||
* This will likely need to be removed or revisited when the app migrates the
|
||||
* extension preferences screen to Compose.
|
||||
*/
|
||||
private fun setupEditTextUrlValidator(editText: EditText) {
|
||||
editText.addTextChangedListener(
|
||||
object : TextWatcher {
|
||||
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun afterTextChanged(editable: Editable?) {
|
||||
requireNotNull(editable)
|
||||
|
||||
val text = editable.toString()
|
||||
|
||||
val isValid = text.isBlank() || text
|
||||
.split(",")
|
||||
.map(String::trim)
|
||||
.all(::isUrl)
|
||||
|
||||
editText.error = if (!isValid) "${text.split(",").first { !isUrl(it) }} is not a valid url" else null
|
||||
editText.rootView.findViewById<Button>(android.R.id.button1)
|
||||
?.isEnabled = editText.error == null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DOMAIN_PREF_KEY = "domain_list"
|
||||
private const val DOMAIN_PREF_DEFAULT = ""
|
||||
|
||||
private const val SEARCH_FOLDER_IGNORE_KEY = "ignore_non_folder"
|
||||
private const val SEARCH_FOLDER_IGNORE_DEFAULT = true
|
||||
|
||||
private const val TRIM_EPISODE_NAME_KEY = "trim_episode_name"
|
||||
private const val TRIM_EPISODE_NAME_DEFAULT = true
|
||||
|
||||
private const val TRIM_ANIME_NAME_KEY = "trim_anime_name"
|
||||
private const val TRIM_ANIME_NAME_DEFAULT = true
|
||||
|
||||
private val URL_REGEX = Regex("""(?<name>\[[^\[\];]+\])?(?<url>https(?:[^,#]+))(?<depth>#\d+(?<range>,\d+,\d+)?)?${'$'}""")
|
||||
}
|
||||
|
||||
private val SharedPreferences.domainList
|
||||
get() = getString(DOMAIN_PREF_KEY, DOMAIN_PREF_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.ignoreFolder
|
||||
get() = getBoolean(SEARCH_FOLDER_IGNORE_KEY, SEARCH_FOLDER_IGNORE_DEFAULT)
|
||||
|
||||
private val SharedPreferences.trimEpisodeName
|
||||
get() = getBoolean(TRIM_EPISODE_NAME_KEY, TRIM_EPISODE_NAME_DEFAULT)
|
||||
|
||||
private val SharedPreferences.trimAnimeName
|
||||
get() = getBoolean(TRIM_ANIME_NAME_KEY, TRIM_ANIME_NAME_DEFAULT)
|
||||
|
||||
// ============================== Settings ==============================
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = DOMAIN_PREF_KEY
|
||||
title = "Enter drive paths to be shown in extension"
|
||||
summary = """Enter drive paths to be shown in extension
|
||||
|Enter as comma separated list
|
||||
""".trimMargin()
|
||||
this.setDefaultValue(DOMAIN_PREF_DEFAULT)
|
||||
dialogTitle = "Path list"
|
||||
dialogMessage = """Separate paths with a comma. For password protected sites,
|
||||
|format as: "https://username:password@example.worker.dev/0:/"
|
||||
|- (optional) Add [] before url to customize name. For example: [drive 5]https://site.workers.dev/0:
|
||||
|- (optional) add #<integer> to limit the depth of recursion when loading episodes, defaults is 2. For example: https://site.workers.dev/0:#5
|
||||
|- (optional) add #depth,start,stop (all integers) to specify range when loading episodes. Only works if depth is 1. For example: https://site.workers.dev/0:#1,2,6
|
||||
""".trimMargin()
|
||||
|
||||
setOnBindEditTextListener(::setupEditTextUrlValidator)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val res = preferences.edit().putString(DOMAIN_PREF_KEY, newValue as String).commit()
|
||||
Toast.makeText(screen.context, "Restart Aniyomi to apply changes", Toast.LENGTH_LONG).show()
|
||||
res
|
||||
} catch (e: java.lang.Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = SEARCH_FOLDER_IGNORE_KEY
|
||||
title = "Only include folders on search"
|
||||
setDefaultValue(SEARCH_FOLDER_IGNORE_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = TRIM_EPISODE_NAME_KEY
|
||||
title = "Trim info from episode name"
|
||||
setDefaultValue(TRIM_EPISODE_NAME_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = TRIM_ANIME_NAME_KEY
|
||||
title = "Trim info from anime name"
|
||||
setDefaultValue(TRIM_ANIME_NAME_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putBoolean(key, newValue as Boolean).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.googledriveindex
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ResponseData(
|
||||
val nextPageToken: String? = null,
|
||||
val data: DataObject,
|
||||
) {
|
||||
@Serializable
|
||||
data class DataObject(
|
||||
val files: List<FileObject>,
|
||||
) {
|
||||
@Serializable
|
||||
data class FileObject(
|
||||
val mimeType: String,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val modifiedTime: String? = null,
|
||||
val size: String? = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LinkData(
|
||||
val type: String,
|
||||
val url: String,
|
||||
val info: String? = null,
|
||||
val fragment: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IdUrl(
|
||||
val id: String,
|
||||
val url: String,
|
||||
val referer: String,
|
||||
val type: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Details(
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val artist: String? = null,
|
||||
val description: String? = null,
|
||||
val genre: List<String>? = null,
|
||||
val status: String? = null,
|
||||
)
|
12
src/all/hikari/build.gradle
Normal file
|
@ -0,0 +1,12 @@
|
|||
ext {
|
||||
extName = 'Hikari'
|
||||
extClass = '.Hikari'
|
||||
extVersionCode = 5
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib:filemoon-extractor'))
|
||||
implementation(project(':lib:vidhide-extractor'))
|
||||
}
|
BIN
src/all/hikari/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
src/all/hikari/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
src/all/hikari/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
src/all/hikari/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
src/all/hikari/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 47 KiB |
|
@ -0,0 +1,255 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.hikari
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.Calendar
|
||||
|
||||
interface UriFilter {
|
||||
fun addToUri(url: HttpUrl.Builder)
|
||||
}
|
||||
|
||||
sealed class UriPartFilter(
|
||||
name: String,
|
||||
private val param: String,
|
||||
private val vals: Array<Pair<String, String>>,
|
||||
defaultValue: String? = null,
|
||||
) : AnimeFilter.Select<String>(
|
||||
name,
|
||||
vals.map { it.first }.toTypedArray(),
|
||||
vals.indexOfFirst { it.second == defaultValue }.takeIf { it != -1 } ?: 0,
|
||||
),
|
||||
UriFilter {
|
||||
override fun addToUri(builder: HttpUrl.Builder) {
|
||||
builder.addQueryParameter(param, vals[state].second)
|
||||
}
|
||||
}
|
||||
|
||||
class UriMultiSelectOption(name: String, val value: String) : AnimeFilter.CheckBox(name)
|
||||
|
||||
sealed class UriMultiSelectFilter(
|
||||
name: String,
|
||||
private val param: String,
|
||||
private val vals: Array<Pair<String, String>>,
|
||||
) : AnimeFilter.Group<UriMultiSelectOption>(name, vals.map { UriMultiSelectOption(it.first, it.second) }), UriFilter {
|
||||
override fun addToUri(builder: HttpUrl.Builder) {
|
||||
val checked = state.filter { it.state }
|
||||
builder.addQueryParameter(param, checked.joinToString(",") { it.value })
|
||||
}
|
||||
}
|
||||
|
||||
class TypeFilter : UriPartFilter(
|
||||
"Type",
|
||||
"type",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("TV", "1"),
|
||||
Pair("Movie", "2"),
|
||||
Pair("OVA", "3"),
|
||||
Pair("ONA", "4"),
|
||||
Pair("Special", "5"),
|
||||
),
|
||||
)
|
||||
|
||||
class CountryFilter : UriPartFilter(
|
||||
"Country",
|
||||
"country",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("Japanese", "1"),
|
||||
Pair("Chinese", "2"),
|
||||
),
|
||||
)
|
||||
|
||||
class StatusFilter : UriPartFilter(
|
||||
"Status",
|
||||
"stats",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("Currently Airing", "1"),
|
||||
Pair("Finished Airing", "2"),
|
||||
Pair("Not yet Aired", "3"),
|
||||
),
|
||||
)
|
||||
|
||||
class RatingFilter : UriPartFilter(
|
||||
"Rating",
|
||||
"rate",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("G", "1"),
|
||||
Pair("PG", "2"),
|
||||
Pair("PG-13", "3"),
|
||||
Pair("R-17+", "4"),
|
||||
Pair("R+", "5"),
|
||||
Pair("Rx", "6"),
|
||||
),
|
||||
)
|
||||
|
||||
class SourceFilter : UriPartFilter(
|
||||
"Source",
|
||||
"source",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("LightNovel", "1"),
|
||||
Pair("Manga", "2"),
|
||||
Pair("Original", "3"),
|
||||
),
|
||||
)
|
||||
|
||||
class SeasonFilter : UriPartFilter(
|
||||
"Season",
|
||||
"season",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("Spring", "1"),
|
||||
Pair("Summer", "2"),
|
||||
Pair("Fall", "3"),
|
||||
Pair("Winter", "4"),
|
||||
),
|
||||
)
|
||||
|
||||
class LanguageFilter : UriPartFilter(
|
||||
"Language",
|
||||
"language",
|
||||
arrayOf(
|
||||
Pair("All", ""),
|
||||
Pair("Raw", "1"),
|
||||
Pair("Sub", "2"),
|
||||
Pair("Dub", "3"),
|
||||
Pair("Turk", "4"),
|
||||
),
|
||||
)
|
||||
|
||||
class SortFilter : UriPartFilter(
|
||||
"Sort",
|
||||
"sort",
|
||||
arrayOf(
|
||||
Pair("Default", "default"),
|
||||
Pair("Recently Added", "recently_added"),
|
||||
Pair("Recently Updated", "recently_updated"),
|
||||
Pair("Score", "score"),
|
||||
Pair("Name A-Z", "name_az"),
|
||||
Pair("Released Date", "released_date"),
|
||||
Pair("Most Watched", "most_watched"),
|
||||
),
|
||||
)
|
||||
|
||||
class YearFilter(name: String, param: String) : UriPartFilter(
|
||||
name,
|
||||
param,
|
||||
YEARS,
|
||||
) {
|
||||
companion object {
|
||||
private val NEXT_YEAR by lazy {
|
||||
Calendar.getInstance()[Calendar.YEAR] + 1
|
||||
}
|
||||
|
||||
private val YEARS = Array(NEXT_YEAR - 1917) { year ->
|
||||
if (year == 0) {
|
||||
Pair("Any", "")
|
||||
} else {
|
||||
(NEXT_YEAR - year).toString().let { Pair(it, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MonthFilter(name: String, param: String) : UriPartFilter(
|
||||
name,
|
||||
param,
|
||||
MONTHS,
|
||||
) {
|
||||
companion object {
|
||||
private val MONTHS = Array(13) { months ->
|
||||
if (months == 0) {
|
||||
Pair("Any", "")
|
||||
} else {
|
||||
val monthStr = "%02d".format(months)
|
||||
Pair(monthStr, monthStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DayFilter(name: String, param: String) : UriPartFilter(
|
||||
name,
|
||||
param,
|
||||
DAYS,
|
||||
) {
|
||||
companion object {
|
||||
private val DAYS = Array(32) { day ->
|
||||
if (day == 0) {
|
||||
Pair("Any", "")
|
||||
} else {
|
||||
val dayStr = "%02d".format(day)
|
||||
Pair(dayStr, dayStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AiringDateFilter(
|
||||
private val values: List<UriPartFilter> = PARTS,
|
||||
) : AnimeFilter.Group<UriPartFilter>("Airing Date", values), UriFilter {
|
||||
override fun addToUri(builder: HttpUrl.Builder) {
|
||||
values.forEach {
|
||||
it.addToUri(builder)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PARTS = listOf(
|
||||
YearFilter("Year", "aired_year"),
|
||||
MonthFilter("Month", "aired_month"),
|
||||
DayFilter("Day", "aired_day"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class GenreFilter : UriMultiSelectFilter(
|
||||
"Genre",
|
||||
"genres",
|
||||
arrayOf(
|
||||
Pair("Action", "Action"),
|
||||
Pair("Adventure", "Adventure"),
|
||||
Pair("Cars", "Cars"),
|
||||
Pair("Comedy", "Comedy"),
|
||||
Pair("Dementia", "Dementia"),
|
||||
Pair("Demons", "Demons"),
|
||||
Pair("Drama", "Drama"),
|
||||
Pair("Ecchi", "Ecchi"),
|
||||
Pair("Fantasy", "Fantasy"),
|
||||
Pair("Game", "Game"),
|
||||
Pair("Harem", "Harem"),
|
||||
Pair("Historical", "Historical"),
|
||||
Pair("Horror", "Horror"),
|
||||
Pair("Isekai", "Isekai"),
|
||||
Pair("Josei", "Josei"),
|
||||
Pair("Kids", "Kids"),
|
||||
Pair("Magic", "Magic"),
|
||||
Pair("Martial Arts", "Martial Arts"),
|
||||
Pair("Mecha", "Mecha"),
|
||||
Pair("Military", "Military"),
|
||||
Pair("Music", "Music"),
|
||||
Pair("Mystery", "Mystery"),
|
||||
Pair("Parody", "Parody"),
|
||||
Pair("Police", "Police"),
|
||||
Pair("Psychological", "Psychological"),
|
||||
Pair("Romance", "Romance"),
|
||||
Pair("Samurai", "Samurai"),
|
||||
Pair("School", "School"),
|
||||
Pair("Sci-Fi", "Sci-Fi"),
|
||||
Pair("Seinen", "Seinen"),
|
||||
Pair("Shoujo", "Shoujo"),
|
||||
Pair("Shoujo Ai", "Shoujo Ai"),
|
||||
Pair("Shounen", "Shounen"),
|
||||
Pair("Shounen Ai", "Shounen Ai"),
|
||||
Pair("Slice of Life", "Slice of Life"),
|
||||
Pair("Space", "Space"),
|
||||
Pair("Sports", "Sports"),
|
||||
Pair("Super Power", "Super Power"),
|
||||
Pair("Supernatural", "Supernatural"),
|
||||
Pair("Thriller", "Thriller"),
|
||||
Pair("Vampire", "Vampire"),
|
||||
),
|
||||
)
|
|
@ -0,0 +1,332 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.hikari
|
||||
|
||||
import android.app.Application
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
|
||||
import eu.kanade.tachiyomi.lib.filemoonextractor.FilemoonExtractor
|
||||
import eu.kanade.tachiyomi.lib.vidhideextractor.VidHideExtractor
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class Hikari : ParsedAnimeHttpSource(), ConfigurableAnimeSource {
|
||||
|
||||
override val name = "Hikari"
|
||||
|
||||
override val baseUrl = "https://watch.hikaritv.xyz"
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder().apply {
|
||||
add("Origin", baseUrl)
|
||||
add("Referer", "$baseUrl/")
|
||||
}
|
||||
|
||||
private val preferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override fun popularAnimeRequest(page: Int): Request {
|
||||
val url = "$baseUrl/ajax/getfilter?type=&country=&stats=&rate=&source=&season=&language=&aired_year=&aired_month=&aired_day=&sort=score&genres=&page=$page"
|
||||
val headers = headersBuilder().set("Referer", "$baseUrl/filter").build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||
val parsed = response.parseAs<HtmlResponseDto>()
|
||||
|
||||
val hasNextPage = response.request.url.queryParameter("page")!!.toInt() < parsed.page!!.totalPages
|
||||
val animeList = parsed.toHtml(baseUrl).select(popularAnimeSelector())
|
||||
.map(::popularAnimeFromElement)
|
||||
|
||||
return AnimesPage(animeList, hasNextPage)
|
||||
}
|
||||
|
||||
override fun popularAnimeSelector(): String = ".flw-item"
|
||||
|
||||
override fun popularAnimeFromElement(element: Element): SAnime = SAnime.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a[data-id]")!!.attr("abs:href"))
|
||||
thumbnail_url = element.selectFirst("img")!!.attr("abs:src")
|
||||
title = element.selectFirst(".film-name")!!.text()
|
||||
}
|
||||
|
||||
override fun popularAnimeNextPageSelector(): String? = null
|
||||
|
||||
// =============================== Latest ===============================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$baseUrl/ajax/getfilter?type=&country=&stats=&rate=&source=&season=&language=&aired_year=&aired_month=&aired_day=&sort=recently_updated&genres=&page=$page"
|
||||
val headers = headersBuilder().set("Referer", "$baseUrl/filter").build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage =
|
||||
popularAnimeParse(response)
|
||||
|
||||
override fun latestUpdatesSelector(): String =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SAnime =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
// =============================== Search ===============================
|
||||
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
if (query.isNotEmpty()) {
|
||||
addPathSegment("search")
|
||||
addQueryParameter("keyword", query)
|
||||
addQueryParameter("page", page.toString())
|
||||
} else {
|
||||
addPathSegment("ajax")
|
||||
addPathSegment("getfilter")
|
||||
filters.filterIsInstance<UriFilter>().forEach {
|
||||
it.addToUri(this)
|
||||
}
|
||||
addQueryParameter("page", page.toString())
|
||||
}
|
||||
}.build()
|
||||
|
||||
val headers = headersBuilder().apply {
|
||||
if (query.isNotEmpty()) {
|
||||
set("Referer", url.toString().substringBeforeLast("&page"))
|
||||
} else {
|
||||
set("Referer", "$baseUrl/filter")
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchAnimeParse(response: Response): AnimesPage {
|
||||
return if (response.request.url.encodedPath.startsWith("/search")) {
|
||||
super.searchAnimeParse(response)
|
||||
} else {
|
||||
popularAnimeParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchAnimeSelector(): String = popularAnimeSelector()
|
||||
|
||||
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
|
||||
|
||||
override fun searchAnimeNextPageSelector(): String = "ul.pagination > li.active + li"
|
||||
|
||||
// ============================== Filters ===============================
|
||||
|
||||
override fun getFilterList(): AnimeFilterList = AnimeFilterList(
|
||||
AnimeFilter.Header("Note: text search ignores filters"),
|
||||
AnimeFilter.Separator(),
|
||||
TypeFilter(),
|
||||
CountryFilter(),
|
||||
StatusFilter(),
|
||||
RatingFilter(),
|
||||
SourceFilter(),
|
||||
SeasonFilter(),
|
||||
LanguageFilter(),
|
||||
SortFilter(),
|
||||
AiringDateFilter(),
|
||||
GenreFilter(),
|
||||
)
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
|
||||
override fun animeDetailsParse(document: Document): SAnime = SAnime.create().apply {
|
||||
with(document.selectFirst("#ani_detail")!!) {
|
||||
title = selectFirst(".film-name")!!.text()
|
||||
thumbnail_url = selectFirst(".film-poster img")!!.attr("abs:src")
|
||||
description = selectFirst(".film-description > .text")?.text()
|
||||
genre = select(".item-list:has(span:contains(Genres)) > a").joinToString { it.text() }
|
||||
author = select(".item:has(span:contains(Studio)) > a").joinToString { it.text() }
|
||||
status = selectFirst(".item:has(span:contains(Status)) > .name").parseStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Element?.parseStatus(): Int = when (this?.text()?.lowercase()) {
|
||||
"currently airing" -> SAnime.ONGOING
|
||||
"finished" -> SAnime.COMPLETED
|
||||
else -> SAnime.UNKNOWN
|
||||
}
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
|
||||
private val specialCharRegex = Regex("""(?![\-_])\W{1,}""")
|
||||
|
||||
override fun episodeListRequest(anime: SAnime): Request {
|
||||
val animeId = anime.url.split("/")[2]
|
||||
|
||||
val sanitized = anime.title.replace(" ", "_")
|
||||
|
||||
val refererUrl = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("watch")
|
||||
addQueryParameter("anime", specialCharRegex.replace(sanitized, ""))
|
||||
addQueryParameter("uid", animeId)
|
||||
addQueryParameter("eps", "1")
|
||||
}.build()
|
||||
|
||||
val headers = headersBuilder()
|
||||
.set("Referer", refererUrl.toString())
|
||||
.build()
|
||||
|
||||
return GET("$baseUrl/ajax/episodelist/$animeId", headers)
|
||||
}
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||
return response.parseAs<HtmlResponseDto>().toHtml(baseUrl)
|
||||
.select(episodeListSelector())
|
||||
.map(::episodeFromElement)
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun episodeListSelector() = "a[class~=ep-item]"
|
||||
|
||||
override fun episodeFromElement(element: Element): SEpisode {
|
||||
val ep = element.selectFirst(".ssli-order")!!.text()
|
||||
return SEpisode.create().apply {
|
||||
setUrlWithoutDomain(element.attr("abs:href"))
|
||||
episode_number = ep.toFloat()
|
||||
name = "Ep. $ep - ${element.selectFirst(".ep-name")?.text() ?: ""}"
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Video Links =============================
|
||||
|
||||
private val filemoonExtractor by lazy { FilemoonExtractor(client) }
|
||||
private val vidHideExtractor by lazy { VidHideExtractor(client, headers) }
|
||||
private val embedRegex = Regex("""getEmbed\(\s*(\d+)\s*,\s*(\d+)\s*,\s*'(\d+)'""")
|
||||
|
||||
override fun videoListRequest(episode: SEpisode): Request {
|
||||
val url = (baseUrl + episode.url).toHttpUrl()
|
||||
val animeId = url.queryParameter("uid")!!
|
||||
val episodeNum = url.queryParameter("eps")!!
|
||||
|
||||
val headers = headersBuilder()
|
||||
.set("Referer", baseUrl + episode.url)
|
||||
.build()
|
||||
|
||||
return GET("$baseUrl/ajax/embedserver/$animeId/$episodeNum", headers)
|
||||
}
|
||||
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val html = response.parseAs<HtmlResponseDto>().toHtml(baseUrl)
|
||||
|
||||
val headers = headersBuilder()
|
||||
.set("Referer", response.request.url.toString())
|
||||
.build()
|
||||
|
||||
val embedUrls = html.select(videoListSelector()).flatMap {
|
||||
val name = it.text()
|
||||
val onClick = it.selectFirst("a")!!.attr("onclick")
|
||||
val match = embedRegex.find(onClick)!!.groupValues
|
||||
val url = "$baseUrl/ajax/embed/${match[1]}/${match[2]}/${match[3]}"
|
||||
val iframeList = client.newCall(
|
||||
GET(url, headers),
|
||||
).execute().parseAs<List<String>>()
|
||||
|
||||
iframeList.map {
|
||||
Pair(Jsoup.parseBodyFragment(it).selectFirst("iframe")!!.attr("src"), name)
|
||||
}
|
||||
}
|
||||
|
||||
return embedUrls.parallelCatchingFlatMapBlocking {
|
||||
getVideosFromEmbed(it.first, it.second)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getVideosFromEmbed(embedUrl: String, name: String): List<Video> = when {
|
||||
name.contains("vidhide", true) -> vidHideExtractor.videosFromUrl(embedUrl)
|
||||
embedUrl.contains("filemoon", true) -> {
|
||||
filemoonExtractor.videosFromUrl(embedUrl, prefix = "$name - ", headers = headers)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
override fun videoListSelector() = ".server-item:has(a[onclick~=getEmbed])"
|
||||
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
|
||||
|
||||
return sortedWith(
|
||||
compareBy(
|
||||
{ it.quality.contains(quality) },
|
||||
{ QUALITY_REGEX.find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
|
||||
),
|
||||
).reversed()
|
||||
}
|
||||
|
||||
override fun videoFromElement(element: Element): Video =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun videoUrlParse(document: Document): String =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
|
||||
@Serializable
|
||||
class HtmlResponseDto(
|
||||
val html: String,
|
||||
val page: PageDto? = null,
|
||||
) {
|
||||
fun toHtml(baseUrl: String): Document = Jsoup.parseBodyFragment(html, baseUrl)
|
||||
|
||||
@Serializable
|
||||
class PageDto(
|
||||
val totalPages: Int,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val QUALITY_REGEX = Regex("""(\d+)p""")
|
||||
|
||||
private const val PREF_QUALITY_KEY = "preferred_quality"
|
||||
private const val PREF_QUALITY_DEFAULT = "1080"
|
||||
private val PREF_QUALITY_VALUES = arrayOf("1080", "720", "480", "360")
|
||||
private val PREF_QUALITY_ENTRIES = PREF_QUALITY_VALUES.map {
|
||||
"${it}p"
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
// ============================== Settings ==============================
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_QUALITY_KEY
|
||||
title = "Preferred quality"
|
||||
entries = PREF_QUALITY_ENTRIES
|
||||
entryValues = PREF_QUALITY_VALUES
|
||||
setDefaultValue(PREF_QUALITY_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
}
|
22
src/all/javguru/AndroidManifest.xml
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name=".all.javguru.JavGuruUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="jav.guru"
|
||||
android:pathPattern="/.*/..*"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
18
src/all/javguru/build.gradle
Normal file
|
@ -0,0 +1,18 @@
|
|||
ext {
|
||||
extName = 'Jav Guru'
|
||||
extClass = '.JavGuru'
|
||||
extVersionCode = 15
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib:streamwish-extractor'))
|
||||
implementation(project(':lib:streamtape-extractor'))
|
||||
implementation(project(':lib:dood-extractor'))
|
||||
implementation(project(':lib:mixdrop-extractor'))
|
||||
implementation "dev.datlag.jsunpacker:jsunpacker:1.0.1"
|
||||
implementation(project(':lib:playlist-utils'))
|
||||
implementation(project(':lib:javcoverfetcher'))
|
||||
}
|
BIN
src/all/javguru/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
src/all/javguru/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
src/all/javguru/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
src/all/javguru/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
src/all/javguru/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
src/all/javguru/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 51 KiB |
|
@ -0,0 +1,381 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.javguru
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Base64
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animeextension.all.javguru.extractors.EmTurboExtractor
|
||||
import eu.kanade.tachiyomi.animeextension.all.javguru.extractors.MaxStreamExtractor
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||
import eu.kanade.tachiyomi.lib.doodextractor.DoodExtractor
|
||||
import eu.kanade.tachiyomi.lib.javcoverfetcher.JavCoverFetcher
|
||||
import eu.kanade.tachiyomi.lib.javcoverfetcher.JavCoverFetcher.fetchHDCovers
|
||||
import eu.kanade.tachiyomi.lib.mixdropextractor.MixDropExtractor
|
||||
import eu.kanade.tachiyomi.lib.streamtapeextractor.StreamTapeExtractor
|
||||
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking
|
||||
import okhttp3.Call
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.select.Elements
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.math.min
|
||||
|
||||
class JavGuru : AnimeHttpSource(), ConfigurableAnimeSource {
|
||||
|
||||
override val name = "Jav Guru"
|
||||
|
||||
override val baseUrl = "https://jav.guru"
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val noRedirectClient = client.newBuilder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
|
||||
private val preference by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
private lateinit var popularElements: Elements
|
||||
|
||||
override suspend fun getPopularAnime(page: Int): AnimesPage {
|
||||
return if (page == 1) {
|
||||
client.newCall(popularAnimeRequest(page))
|
||||
.awaitSuccess()
|
||||
.use(::popularAnimeParse)
|
||||
} else {
|
||||
cachedPopularAnimeParse(page)
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularAnimeRequest(page: Int) =
|
||||
GET("$baseUrl/most-watched-rank/", headers)
|
||||
|
||||
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||
popularElements = response.asJsoup().select(".tabcontent li")
|
||||
|
||||
return cachedPopularAnimeParse(1)
|
||||
}
|
||||
|
||||
private fun cachedPopularAnimeParse(page: Int): AnimesPage {
|
||||
val end = min(page * 20, popularElements.size)
|
||||
val entries = popularElements.subList((page - 1) * 20, end).map { element ->
|
||||
SAnime.create().apply {
|
||||
element.select("a").let { a ->
|
||||
getIDFromUrl(a)?.let { url = it }
|
||||
?: setUrlWithoutDomain(a.attr("href"))
|
||||
|
||||
title = a.text()
|
||||
thumbnail_url = a.select("img").attr("abs:src")
|
||||
}
|
||||
}
|
||||
}
|
||||
return AnimesPage(entries, end < popularElements.size)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = baseUrl + if (page > 1) "/page/$page/" else ""
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val entries = document.select("div.site-content div.inside-article:not(:contains(nothing))").map { element ->
|
||||
SAnime.create().apply {
|
||||
element.select("a").let { a ->
|
||||
getIDFromUrl(a)?.let { url = it }
|
||||
?: setUrlWithoutDomain(a.attr("href"))
|
||||
}
|
||||
thumbnail_url = element.select("img").attr("abs:src")
|
||||
title = element.select("h2 > a").text()
|
||||
}
|
||||
}
|
||||
|
||||
val page = document.location()
|
||||
.pageNumberFromUrlOrNull() ?: 1
|
||||
|
||||
val lastPage = document.select("div.wp-pagenavi a")
|
||||
.last()
|
||||
?.attr("href")
|
||||
.pageNumberFromUrlOrNull() ?: 1
|
||||
|
||||
return AnimesPage(entries, page < lastPage)
|
||||
}
|
||||
|
||||
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
|
||||
if (query.startsWith(PREFIX_ID)) {
|
||||
val id = query.substringAfter(PREFIX_ID)
|
||||
if (id.toIntOrNull() == null) {
|
||||
return AnimesPage(emptyList(), false)
|
||||
}
|
||||
val url = "/$id/"
|
||||
val tempAnime = SAnime.create().apply { this.url = url }
|
||||
return getAnimeDetails(tempAnime).let {
|
||||
val anime = it.apply { this.url = url }
|
||||
AnimesPage(listOf(anime), false)
|
||||
}
|
||||
} else if (query.isNotEmpty()) {
|
||||
return client.newCall(searchAnimeRequest(page, query, filters))
|
||||
.awaitSuccess()
|
||||
.use(::searchAnimeParse)
|
||||
} else {
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is TagFilter,
|
||||
is CategoryFilter,
|
||||
-> {
|
||||
if (filter.state != 0) {
|
||||
val url = "$baseUrl${filter.toUrlPart()}" + if (page > 1) "page/$page/" else ""
|
||||
val request = GET(url, headers)
|
||||
return client.newCall(request)
|
||||
.awaitSuccess()
|
||||
.use(::searchAnimeParse)
|
||||
}
|
||||
}
|
||||
is ActressFilter,
|
||||
is ActorFilter,
|
||||
is StudioFilter,
|
||||
is MakerFilter,
|
||||
-> {
|
||||
if ((filter.state as String).isNotEmpty()) {
|
||||
val url = "$baseUrl${filter.toUrlPart()}" + if (page > 1) "page/$page/" else ""
|
||||
val request = GET(url, headers)
|
||||
return client.newCall(request)
|
||||
.awaitIgnoreCode(404)
|
||||
.use(::searchAnimeParse)
|
||||
}
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("Select at least one Filter")
|
||||
}
|
||||
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
if (page > 1) addPathSegments("page/$page/")
|
||||
addQueryParameter("s", query)
|
||||
}.build().toString()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun getFilterList() = getFilters()
|
||||
|
||||
override fun searchAnimeParse(response: Response) = latestUpdatesParse(response)
|
||||
|
||||
override fun animeDetailsParse(response: Response): SAnime {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val javId = document.selectFirst(".infoleft li:contains(code)")?.ownText()
|
||||
val siteCover = document.select(".large-screenshot img").attr("abs:src")
|
||||
|
||||
return SAnime.create().apply {
|
||||
title = document.select(".titl").text()
|
||||
genre = document.select(".infoleft a[rel*=tag]").joinToString { it.text() }
|
||||
author = document.selectFirst(".infoleft li:contains(studio) a")?.text()
|
||||
artist = document.selectFirst(".infoleft li:contains(label) a")?.text()
|
||||
status = SAnime.COMPLETED
|
||||
description = buildString {
|
||||
document.selectFirst(".infoleft li:contains(code)")?.text()?.let { append("$it\n") }
|
||||
document.selectFirst(".infoleft li:contains(director)")?.text()?.let { append("$it\n") }
|
||||
document.selectFirst(".infoleft li:contains(studio)")?.text()?.let { append("$it\n") }
|
||||
document.selectFirst(".infoleft li:contains(label)")?.text()?.let { append("$it\n") }
|
||||
document.selectFirst(".infoleft li:contains(actor)")?.text()?.let { append("$it\n") }
|
||||
document.selectFirst(".infoleft li:contains(actress)")?.text()?.let { append("$it\n") }
|
||||
}
|
||||
thumbnail_url = if (preference.fetchHDCovers) {
|
||||
javId?.let { JavCoverFetcher.getCoverById(it) } ?: siteCover
|
||||
} else {
|
||||
siteCover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
|
||||
return listOf(
|
||||
SEpisode.create().apply {
|
||||
url = anime.url
|
||||
name = "Episode"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val iframeData = document.selectFirst("script:containsData(iframe_url)")?.html()
|
||||
?: return emptyList()
|
||||
|
||||
val iframeUrls = IFRAME_B64_REGEX.findAll(iframeData)
|
||||
.map { it.groupValues[1] }
|
||||
.map { Base64.decode(it, Base64.DEFAULT).let(::String) }
|
||||
.toList()
|
||||
|
||||
return iframeUrls
|
||||
.mapNotNull(::resolveHosterUrl)
|
||||
.parallelCatchingFlatMapBlocking(::getVideos)
|
||||
}
|
||||
|
||||
private fun resolveHosterUrl(iframeUrl: String): String? {
|
||||
val iframeResponse = client.newCall(GET(iframeUrl, headers)).execute()
|
||||
|
||||
if (iframeResponse.isSuccessful.not()) {
|
||||
iframeResponse.close()
|
||||
return null
|
||||
}
|
||||
|
||||
val iframeDocument = iframeResponse.asJsoup()
|
||||
|
||||
val script = iframeDocument.selectFirst("script:containsData(start_player)")
|
||||
?.html() ?: return null
|
||||
|
||||
val olid = IFRAME_OLID_REGEX.find(script)?.groupValues?.get(1)?.reversed()
|
||||
?: return null
|
||||
|
||||
val olidUrl = IFRAME_OLID_URL.find(script)?.groupValues?.get(1)
|
||||
?.substringBeforeLast("=")?.let { "$it=$olid" }
|
||||
?: return null
|
||||
|
||||
val newHeaders = headersBuilder()
|
||||
.set("Referer", iframeUrl)
|
||||
.build()
|
||||
|
||||
val redirectUrl = noRedirectClient.newCall(GET(olidUrl, newHeaders))
|
||||
.execute().use { it.header("location") }
|
||||
?: return null
|
||||
|
||||
if (redirectUrl.toHttpUrlOrNull() == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return redirectUrl
|
||||
}
|
||||
|
||||
private val streamWishExtractor by lazy {
|
||||
val swHeaders = headersBuilder()
|
||||
.set("Referer", "$baseUrl/")
|
||||
.build()
|
||||
|
||||
StreamWishExtractor(client, swHeaders)
|
||||
}
|
||||
private val streamTapeExtractor by lazy { StreamTapeExtractor(client) }
|
||||
private val doodExtractor by lazy { DoodExtractor(client) }
|
||||
private val mixDropExtractor by lazy { MixDropExtractor(client) }
|
||||
private val maxStreamExtractor by lazy { MaxStreamExtractor(client, headers) }
|
||||
private val emTurboExtractor by lazy { EmTurboExtractor(client, headers) }
|
||||
|
||||
private fun getVideos(hosterUrl: String): List<Video> {
|
||||
return when {
|
||||
listOf("javplaya", "javclan").any { it in hosterUrl } -> {
|
||||
streamWishExtractor.videosFromUrl(hosterUrl)
|
||||
}
|
||||
|
||||
hosterUrl.contains("streamtape") -> {
|
||||
streamTapeExtractor.videoFromUrl(hosterUrl).let(::listOfNotNull)
|
||||
}
|
||||
|
||||
listOf("dood", "ds2play").any { it in hosterUrl } -> {
|
||||
doodExtractor.videosFromUrl(hosterUrl)
|
||||
}
|
||||
|
||||
listOf("mixdrop", "mixdroop").any { it in hosterUrl } -> {
|
||||
mixDropExtractor.videoFromUrl(hosterUrl)
|
||||
}
|
||||
|
||||
hosterUrl.contains("maxstream") -> {
|
||||
maxStreamExtractor.videoFromUrl(hosterUrl)
|
||||
}
|
||||
|
||||
hosterUrl.contains("emturbovid") -> {
|
||||
emTurboExtractor.getVideos(hosterUrl)
|
||||
}
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preference.getString(PREF_QUALITY, PREF_QUALITY_DEFAULT)!!
|
||||
|
||||
return sortedWith(
|
||||
compareBy { it.quality.contains(quality) },
|
||||
).reversed()
|
||||
}
|
||||
|
||||
private fun getIDFromUrl(element: Elements): String? {
|
||||
return element.attr("abs:href")
|
||||
.toHttpUrlOrNull()
|
||||
?.pathSegments
|
||||
?.firstOrNull()
|
||||
?.toIntOrNull()
|
||||
?.toString()
|
||||
?.let { "/$it/" }
|
||||
}
|
||||
|
||||
private fun String?.pageNumberFromUrlOrNull() =
|
||||
this
|
||||
?.substringBeforeLast("/")
|
||||
?.toHttpUrlOrNull()
|
||||
?.pathSegments
|
||||
?.last()
|
||||
?.toIntOrNull()
|
||||
|
||||
private suspend fun Call.awaitIgnoreCode(code: Int): Response {
|
||||
return await().also { response ->
|
||||
if (!response.isSuccessful && response.code != code) {
|
||||
response.close()
|
||||
throw Exception("HTTP error ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_QUALITY
|
||||
title = PREF_QUALITY_TITLE
|
||||
entries = arrayOf("1080p", "720p", "480p", "360p")
|
||||
entryValues = arrayOf("1080", "720", "480", "360")
|
||||
setDefaultValue(PREF_QUALITY_DEFAULT)
|
||||
summary = "%s"
|
||||
}.also(screen::addPreference)
|
||||
|
||||
JavCoverFetcher.addPreferenceToScreen(screen)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID = "id:"
|
||||
|
||||
private val IFRAME_B64_REGEX = Regex(""""iframe_url":"([^"]+)"""")
|
||||
private val IFRAME_OLID_REGEX = Regex("""var OLID = '([^']+)'""")
|
||||
private val IFRAME_OLID_URL = Regex("""src="([^"]+)"""")
|
||||
|
||||
private const val PREF_QUALITY = "preferred_quality"
|
||||
private const val PREF_QUALITY_TITLE = "Preferred quality"
|
||||
private const val PREF_QUALITY_DEFAULT = "720"
|
||||
}
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,335 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.javguru
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
|
||||
fun getFilters() = AnimeFilterList(
|
||||
AnimeFilter.Header("Only One Filter Works at a time!!"),
|
||||
AnimeFilter.Header("Ignored With Text Search!!"),
|
||||
TagFilter(),
|
||||
CategoryFilter(),
|
||||
AnimeFilter.Separator(),
|
||||
ActressFilter(),
|
||||
ActorFilter(),
|
||||
StudioFilter(),
|
||||
MakerFilter(),
|
||||
)
|
||||
|
||||
class UriPartFilter(val name: String, val urlPart: String)
|
||||
|
||||
abstract class UriPartFilters(name: String, private val tags: List<UriPartFilter>) :
|
||||
AnimeFilter.Select<String>(name, tags.map { it.name }.toTypedArray()) {
|
||||
fun toUrlPart() = tags[state].urlPart
|
||||
}
|
||||
|
||||
class TagFilter : UriPartFilters("Tags", TAGS)
|
||||
|
||||
class CategoryFilter : UriPartFilters("Categories", CATEGORIES)
|
||||
|
||||
abstract class TextFilter(name: String, private val urlSubDirectory: String) : AnimeFilter.Text(name) {
|
||||
fun toUrlPart() = state.trim()
|
||||
.lowercase()
|
||||
.replace(SPECIAL_CHAR_REGEX, "-")
|
||||
.replace(TRAILING_HIPHEN_REGEX, "")
|
||||
.let { "/$urlSubDirectory/$it/" }
|
||||
|
||||
companion object {
|
||||
private val SPECIAL_CHAR_REGEX = "[^a-z0-9]+".toRegex()
|
||||
private val TRAILING_HIPHEN_REGEX = "-+$".toRegex()
|
||||
}
|
||||
}
|
||||
|
||||
class ActressFilter : TextFilter("Actress", "actress")
|
||||
|
||||
class ActorFilter : TextFilter("Actor", "actor")
|
||||
|
||||
class StudioFilter : TextFilter("Studio", "studio")
|
||||
|
||||
class MakerFilter : TextFilter("Maker", "maker")
|
||||
|
||||
fun <T> AnimeFilter<T>.toUrlPart(): String? {
|
||||
return when (this) {
|
||||
is TagFilter -> this.toUrlPart()
|
||||
is CategoryFilter -> this.toUrlPart()
|
||||
is ActressFilter -> this.toUrlPart()
|
||||
is ActorFilter -> this.toUrlPart()
|
||||
is StudioFilter -> this.toUrlPart()
|
||||
is MakerFilter -> this.toUrlPart()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val TAGS = listOf(
|
||||
UriPartFilter("", "/"),
|
||||
UriPartFilter("Solowork", "/tag/solowork/"),
|
||||
UriPartFilter("Creampie", "/tag/creampie/"),
|
||||
UriPartFilter("Big tits", "/tag/big-tits/"),
|
||||
UriPartFilter("Beautiful Girl", "/tag/beautiful-girl/"),
|
||||
UriPartFilter("Married Woman", "/tag/married-woman/"),
|
||||
UriPartFilter("Amateur", "/tag/amateur/"),
|
||||
UriPartFilter("Digital Mosaic", "/tag/digital-mosaic/"),
|
||||
UriPartFilter("Slut", "/tag/slut/"),
|
||||
UriPartFilter("Mature Woman", "/tag/mature-woman/"),
|
||||
UriPartFilter("Cuckold", "/tag/cuckold/"),
|
||||
UriPartFilter("3P", "/tag/3p/"),
|
||||
UriPartFilter("Slender", "/tag/slender/"),
|
||||
UriPartFilter("Blow", "/tag/blow/"),
|
||||
UriPartFilter("Squirting", "/tag/squirting/"),
|
||||
UriPartFilter("Drama", "/tag/drama/"),
|
||||
UriPartFilter("Nasty", "/tag/nasty/"),
|
||||
UriPartFilter("Hardcore", "/tag/hardcore/"),
|
||||
UriPartFilter("School Girls", "/tag/school-girls/"),
|
||||
UriPartFilter("4P", "/tag/4p/"),
|
||||
UriPartFilter("Titty fuck", "/tag/titty-fuck/"),
|
||||
UriPartFilter("Cowgirl", "/tag/cowgirl/"),
|
||||
UriPartFilter("Incest", "/tag/incest/"),
|
||||
UriPartFilter("Facials", "/tag/facials/"),
|
||||
UriPartFilter("breasts", "/tag/breasts/"),
|
||||
UriPartFilter("abuse", "/tag/abuse/"),
|
||||
UriPartFilter("Risky Mosaic", "/tag/risky-mosaic/"),
|
||||
UriPartFilter("Debut Production", "/tag/debut-production/"),
|
||||
UriPartFilter("Older sister", "/tag/older-sister/"),
|
||||
UriPartFilter("Huge Butt", "/tag/huge-butt/"),
|
||||
UriPartFilter("4HR+", "/tag/4hr/"),
|
||||
UriPartFilter("Affair", "/tag/affair/"),
|
||||
UriPartFilter("Kiss", "/tag/kiss/"),
|
||||
UriPartFilter("Deep Throating", "/tag/deep-throating/"),
|
||||
UriPartFilter("Documentary", "/tag/documentary/"),
|
||||
UriPartFilter("Mini", "/tag/mini/"),
|
||||
UriPartFilter("Entertainer", "/tag/entertainer/"),
|
||||
UriPartFilter("Dirty Words", "/tag/dirty-words/"),
|
||||
UriPartFilter("Cosplay", "/tag/cosplay/"),
|
||||
UriPartFilter("POV", "/tag/pov/"),
|
||||
UriPartFilter("Shaved", "/tag/shaved/"),
|
||||
UriPartFilter("butt", "/tag/butt/"),
|
||||
UriPartFilter("OL", "/tag/ol/"),
|
||||
UriPartFilter("Tits", "/tag/tits/"),
|
||||
UriPartFilter("Promiscuity", "/tag/promiscuity/"),
|
||||
UriPartFilter("Restraint", "/tag/restraint/"),
|
||||
UriPartFilter("Gal", "/tag/gal/"),
|
||||
UriPartFilter("planning", "/tag/planning/"),
|
||||
UriPartFilter("Subjectivity", "/tag/subjectivity/"),
|
||||
UriPartFilter("Handjob", "/tag/handjob/"),
|
||||
UriPartFilter("Uniform", "/tag/uniform/"),
|
||||
UriPartFilter("Sister", "/tag/sister/"),
|
||||
UriPartFilter("Humiliation", "/tag/humiliation/"),
|
||||
UriPartFilter("Prostitutes", "/tag/prostitutes/"),
|
||||
UriPartFilter("School Uniform", "/tag/school-uniform/"),
|
||||
UriPartFilter("Rape", "/tag/rape/"),
|
||||
UriPartFilter("Lesbian", "/tag/lesbian/"),
|
||||
UriPartFilter("Anal", "/tag/anal/"),
|
||||
UriPartFilter("Image video", "/tag/image-video/"),
|
||||
UriPartFilter("Pantyhose", "/tag/pantyhose/"),
|
||||
UriPartFilter("Other fetish", "/tag/other-fetish/"),
|
||||
UriPartFilter("Female College Student", "/tag/female-college-student/"),
|
||||
UriPartFilter("Female teacher", "/tag/female-teacher/"),
|
||||
UriPartFilter("Bukkake", "/tag/bukkake/"),
|
||||
UriPartFilter("Training", "/tag/training/"),
|
||||
UriPartFilter("Cum", "/tag/cum/"),
|
||||
UriPartFilter("Masturbation", "/tag/masturbation/"),
|
||||
UriPartFilter("Sweat", "/tag/sweat/"),
|
||||
UriPartFilter("Omnibus", "/tag/omnibus/"),
|
||||
UriPartFilter("Best", "/tag/best/"),
|
||||
UriPartFilter("Lotion", "/tag/lotion/"),
|
||||
UriPartFilter("Girl", "/tag/girl/"),
|
||||
UriPartFilter("Submissive Men", "/tag/submissive-men/"),
|
||||
UriPartFilter("Outdoors", "/tag/outdoors/"),
|
||||
UriPartFilter("Beauty Shop", "/tag/beauty-shop/"),
|
||||
UriPartFilter("Busty fetish", "/tag/busty-fetish/"),
|
||||
UriPartFilter("Toy", "/tag/toy/"),
|
||||
UriPartFilter("Urination", "/tag/urination/"),
|
||||
UriPartFilter("huge cock", "/tag/huge-cock/"),
|
||||
UriPartFilter("Gangbang", "/tag/gangbang/"),
|
||||
UriPartFilter("Massage", "/tag/massage/"),
|
||||
UriPartFilter("Tall", "/tag/tall/"),
|
||||
UriPartFilter("Hot Spring", "/tag/hot-spring/"),
|
||||
UriPartFilter("virgin man", "/tag/virgin-man/"),
|
||||
UriPartFilter("Various Professions", "/tag/various-professions/"),
|
||||
UriPartFilter("Bride", "/tag/bride/"),
|
||||
UriPartFilter("Leg Fetish", "/tag/leg-fetish/"),
|
||||
UriPartFilter("Young wife", "/tag/young-wife/"),
|
||||
UriPartFilter("Maid", "/tag/maid/"),
|
||||
UriPartFilter("BBW", "/tag/bbw/"),
|
||||
UriPartFilter("SM", "/tag/sm/"),
|
||||
UriPartFilter("Restraints", "/tag/restraints/"),
|
||||
UriPartFilter("Lesbian Kiss", "/tag/lesbian-kiss/"),
|
||||
UriPartFilter("Voyeur", "/tag/voyeur/"),
|
||||
UriPartFilter("Mother", "/tag/mother/"),
|
||||
UriPartFilter("Evil", "/tag/evil/"),
|
||||
UriPartFilter("Underwear", "/tag/underwear/"),
|
||||
UriPartFilter("Nurse", "/tag/nurse/"),
|
||||
UriPartFilter("Glasses", "/tag/glasses/"),
|
||||
UriPartFilter("Lingerie", "/tag/lingerie/"),
|
||||
UriPartFilter("Drug", "/tag/drug/"),
|
||||
UriPartFilter("Nampa", "/tag/nampa/"),
|
||||
UriPartFilter("School Swimsuit", "/tag/school-swimsuit/"),
|
||||
UriPartFilter("Stepmother", "/tag/stepmother/"),
|
||||
UriPartFilter("Sailor suit", "/tag/sailor-suit/"),
|
||||
UriPartFilter("Prank", "/tag/prank/"),
|
||||
UriPartFilter("Cunnilingus", "/tag/cunnilingus/"),
|
||||
UriPartFilter("Electric Massager", "/tag/electric-massager/"),
|
||||
UriPartFilter("Molester", "/tag/molester/"),
|
||||
UriPartFilter("Black Actor", "/tag/black-actor/"),
|
||||
UriPartFilter("Ultra-Huge Tits", "/tag/ultra-huge-tits/"),
|
||||
UriPartFilter("Original Collaboration", "/tag/original-collaboration/"),
|
||||
UriPartFilter("Confinement", "/tag/confinement/"),
|
||||
UriPartFilter("Shotacon", "/tag/shotacon/"),
|
||||
UriPartFilter("Footjob", "/tag/footjob/"),
|
||||
UriPartFilter("Female Boss", "/tag/female-boss/"),
|
||||
UriPartFilter("Female investigator", "/tag/female-investigator/"),
|
||||
UriPartFilter("Swimsuit", "/tag/swimsuit/"),
|
||||
UriPartFilter("Bloomers", "/tag/bloomers/"),
|
||||
UriPartFilter("Facesitting", "/tag/facesitting/"),
|
||||
UriPartFilter("Kimono", "/tag/kimono/"),
|
||||
UriPartFilter("Mourning", "/tag/mourning/"),
|
||||
UriPartFilter("White Actress", "/tag/white-actress/"),
|
||||
UriPartFilter("Acme · Orgasm", "/tag/acme-%c2%b7-orgasm/"),
|
||||
UriPartFilter("Sun tan", "/tag/sun-tan/"),
|
||||
UriPartFilter("Finger Fuck", "/tag/finger-fuck/"),
|
||||
UriPartFilter("Transsexual", "/tag/transsexual/"),
|
||||
UriPartFilter("Blu-ray", "/tag/blu-ray/"),
|
||||
UriPartFilter("VR", "/tag/vr/"),
|
||||
UriPartFilter("Cross Dressing", "/tag/cross-dressing/"),
|
||||
UriPartFilter("Soapland", "/tag/soapland/"),
|
||||
UriPartFilter("Fan Appreciation", "/tag/fan-appreciation/"),
|
||||
UriPartFilter("AV Actress", "/tag/av-actress/"),
|
||||
UriPartFilter("School Stuff", "/tag/school-stuff/"),
|
||||
UriPartFilter("Love", "/tag/love/"),
|
||||
UriPartFilter("Close Up", "/tag/close-up/"),
|
||||
UriPartFilter("Submissive Woman", "/tag/submissive-woman/"),
|
||||
UriPartFilter("Mini Skirt", "/tag/mini-skirt/"),
|
||||
UriPartFilter("Impromptu Sex", "/tag/impromptu-sex/"),
|
||||
UriPartFilter("Vibe", "/tag/vibe/"),
|
||||
UriPartFilter("Bitch", "/tag/bitch/"),
|
||||
UriPartFilter("Enema", "/tag/enema/"),
|
||||
UriPartFilter("Hypnosis", "/tag/hypnosis/"),
|
||||
UriPartFilter("Childhood Friend", "/tag/childhood-friend/"),
|
||||
UriPartFilter("Erotic Wear", "/tag/erotic-wear/"),
|
||||
UriPartFilter("Tutor", "/tag/tutor/"),
|
||||
UriPartFilter("Male Squirting", "/tag/male-squirting/"),
|
||||
UriPartFilter("Bath", "/tag/bath/"),
|
||||
UriPartFilter("Conceived", "/tag/conceived/"),
|
||||
UriPartFilter("Stewardess", "/tag/stewardess/"),
|
||||
UriPartFilter("Sport", "/tag/sport/"),
|
||||
UriPartFilter("Bunny Girl", "/tag/bunny-girl/"),
|
||||
UriPartFilter("Piss Drinking", "/tag/piss-drinking/"),
|
||||
UriPartFilter("Shibari", "/tag/shibari/"),
|
||||
UriPartFilter("Couple", "/tag/couple/"),
|
||||
UriPartFilter("Anchorwoman", "/tag/anchorwoman/"),
|
||||
UriPartFilter("Delusion", "/tag/delusion/"),
|
||||
UriPartFilter("69", "/tag/69/"),
|
||||
UriPartFilter("Secretary", "/tag/secretary/"),
|
||||
UriPartFilter("Idol", "/tag/idol/"),
|
||||
UriPartFilter("Elder Male", "/tag/elder-male/"),
|
||||
UriPartFilter("Cervix", "/tag/cervix/"),
|
||||
UriPartFilter("Leotard", "/tag/leotard/"),
|
||||
UriPartFilter("Miss", "/tag/miss/"),
|
||||
UriPartFilter("Back", "/tag/back/"),
|
||||
UriPartFilter("blog", "/tag/blog/"),
|
||||
UriPartFilter("virgin", "/tag/virgin/"),
|
||||
UriPartFilter("Female Doctor", "/tag/female-doctor/"),
|
||||
UriPartFilter("No Bra", "/tag/no-bra/"),
|
||||
UriPartFilter("Tsundere", "/tag/tsundere/"),
|
||||
UriPartFilter("Race Queen", "/tag/race-queen/"),
|
||||
UriPartFilter("Multiple Story", "/tag/multiple-story/"),
|
||||
UriPartFilter("Widow", "/tag/widow/"),
|
||||
UriPartFilter("Actress Best", "/tag/actress-best/"),
|
||||
UriPartFilter("Bondage", "/tag/bondage/"),
|
||||
UriPartFilter("Muscle", "/tag/muscle/"),
|
||||
UriPartFilter("User Submission", "/tag/user-submission/"),
|
||||
UriPartFilter("Breast Milk", "/tag/breast-milk/"),
|
||||
UriPartFilter("Sexy", "/tag/sexy/"),
|
||||
UriPartFilter("Travel", "/tag/travel/"),
|
||||
UriPartFilter("Knee Socks", "/tag/knee-socks/"),
|
||||
UriPartFilter("Date", "/tag/date/"),
|
||||
UriPartFilter("For Women", "/tag/for-women/"),
|
||||
UriPartFilter("Premature Ejaculation", "/tag/premature-ejaculation/"),
|
||||
UriPartFilter("Hi-Def", "/tag/hi-def/"),
|
||||
UriPartFilter("Time Stop", "/tag/time-stop/"),
|
||||
UriPartFilter("Subordinates / Colleagues", "/tag/subordinates-colleagues/"),
|
||||
UriPartFilter("Adopted Daughter", "/tag/adopted-daughter/"),
|
||||
UriPartFilter("Instructor", "/tag/instructor/"),
|
||||
UriPartFilter("Catgirl", "/tag/catgirl/"),
|
||||
UriPartFilter("Body Conscious", "/tag/body-conscious/"),
|
||||
UriPartFilter("Fighting Action", "/tag/fighting-action/"),
|
||||
UriPartFilter("Featured Actress", "/tag/featured-actress/"),
|
||||
UriPartFilter("Hostess", "/tag/hostess/"),
|
||||
UriPartFilter("Dead Drunk", "/tag/dead-drunk/"),
|
||||
UriPartFilter("Landlady", "/tag/landlady/"),
|
||||
UriPartFilter("Business Attire", "/tag/business-attire/"),
|
||||
UriPartFilter("Dildo", "/tag/dildo/"),
|
||||
UriPartFilter("Reversed Role", "/tag/reversed-role/"),
|
||||
UriPartFilter("Foreign Objects", "/tag/foreign-objects/"),
|
||||
UriPartFilter("Athlete", "/tag/athlete/"),
|
||||
UriPartFilter("Aunt", "/tag/aunt/"),
|
||||
UriPartFilter("Model", "/tag/model/"),
|
||||
UriPartFilter("Big Breasts", "/tag/big-breasts/"),
|
||||
UriPartFilter("Oversea Import", "/tag/oversea-import/"),
|
||||
UriPartFilter("Drinking Party", "/tag/drinking-party/"),
|
||||
UriPartFilter("Booth Girl", "/tag/booth-girl/"),
|
||||
UriPartFilter("Car Sex", "/tag/car-sex/"),
|
||||
UriPartFilter("Blowjob", "/tag/blowjob/"),
|
||||
UriPartFilter("Other Asian", "/tag/other-asian/"),
|
||||
UriPartFilter("Special Effects", "/tag/special-effects/"),
|
||||
UriPartFilter("Spanking", "/tag/spanking/"),
|
||||
UriPartFilter("Club Activities / Manager", "/tag/club-activities-manager/"),
|
||||
UriPartFilter("Naked Apron", "/tag/naked-apron/"),
|
||||
UriPartFilter("Fantasy", "/tag/fantasy/"),
|
||||
UriPartFilter("Female Warrior", "/tag/female-warrior/"),
|
||||
UriPartFilter("Anime Characters", "/tag/anime-characters/"),
|
||||
UriPartFilter("Sex Conversion / Feminized", "/tag/sex-conversion-feminized/"),
|
||||
UriPartFilter("Flexible", "/tag/flexible/"),
|
||||
UriPartFilter("Schoolgirl", "/tag/schoolgirl/"),
|
||||
UriPartFilter("Long Boots", "/tag/long-boots/"),
|
||||
UriPartFilter("No Undies", "/tag/no-undies/"),
|
||||
UriPartFilter("Immediate Oral", "/tag/immediate-oral/"),
|
||||
UriPartFilter("Hospital / Clinic", "/tag/hospital-clinic/"),
|
||||
UriPartFilter("Dance", "/tag/dance/"),
|
||||
UriPartFilter("Breast Peeker", "/tag/breast-peeker/"),
|
||||
UriPartFilter("Waitress", "/tag/waitress/"),
|
||||
UriPartFilter("Futanari", "/tag/futanari/"),
|
||||
UriPartFilter("Rolling Back Eyes / Fainting", "/tag/rolling-back-eyes-fainting/"),
|
||||
UriPartFilter("Hotel", "/tag/hotel/"),
|
||||
UriPartFilter("Exposure", "/tag/exposure/"),
|
||||
UriPartFilter("Torture", "/tag/torture/"),
|
||||
UriPartFilter("Office Lady", "/tag/office-lady/"),
|
||||
UriPartFilter("Masturbation Support", "/tag/masturbation-support/"),
|
||||
UriPartFilter("facial", "/tag/facial/"),
|
||||
UriPartFilter("Egg Vibrator", "/tag/egg-vibrator/"),
|
||||
UriPartFilter("Fisting", "/tag/fisting/"),
|
||||
UriPartFilter("Vomit", "/tag/vomit/"),
|
||||
UriPartFilter("Orgy", "/tag/orgy/"),
|
||||
UriPartFilter("Cruel Expression", "/tag/cruel-expression/"),
|
||||
UriPartFilter("Doll", "/tag/doll/"),
|
||||
UriPartFilter("Loose Socks", "/tag/loose-socks/"),
|
||||
UriPartFilter("Best of 2021", "/tag/best-of-2021/"),
|
||||
UriPartFilter("Reserved Role", "/tag/reserved-role/"),
|
||||
UriPartFilter("Best of 2019", "/tag/best-of-2019/"),
|
||||
UriPartFilter("Mother-in-law", "/tag/mother-in-law/"),
|
||||
UriPartFilter("Gay", "/tag/gay/"),
|
||||
UriPartFilter("Swingers", "/tag/swingers/"),
|
||||
UriPartFilter("Best of 2020", "/tag/best-of-2020/"),
|
||||
UriPartFilter("Mistress", "/tag/mistress/"),
|
||||
UriPartFilter("Shame", "/tag/shame/"),
|
||||
UriPartFilter("Yukata", "/tag/yukata/"),
|
||||
UriPartFilter("Best of 2017", "/tag/best-of-2017/"),
|
||||
UriPartFilter("Best of 2018", "/tag/best-of-2018/"),
|
||||
UriPartFilter("Nose Hook", "/tag/nose-hook/"),
|
||||
)
|
||||
|
||||
val CATEGORIES = listOf(
|
||||
UriPartFilter("", "/"),
|
||||
UriPartFilter("1080p", "/category/1080p/"),
|
||||
UriPartFilter("4K", "/category/4k/"),
|
||||
UriPartFilter("Amateur", "/category/amateur/"),
|
||||
UriPartFilter("Blog", "/category/blog/"),
|
||||
UriPartFilter("Decensored", "/category/decensored/"),
|
||||
UriPartFilter("English subbed JAV", "/category/english-subbed/"),
|
||||
UriPartFilter("FC2", "/category/fc2/"),
|
||||
UriPartFilter("HD", "/category/hd/"),
|
||||
UriPartFilter("Idol", "/category/idol/"),
|
||||
UriPartFilter("JAV", "/category/jav/"),
|
||||
UriPartFilter("LEGACY", "/category/legacy/"),
|
||||
UriPartFilter("UNCENSORED", "/category/jav-uncensored/"),
|
||||
UriPartFilter("VR AV", "/category/vr-av/"),
|
||||
)
|
|
@ -0,0 +1,34 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.javguru
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class JavGuruUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
if (pathSegments != null && pathSegments.size > 1) {
|
||||
val id = pathSegments[0]
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.ANIMESEARCH"
|
||||
putExtra("query", "${JavGuru.PREFIX_ID}$id")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("JavGuruUrlActivity", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("JavGuruUrlActivity", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.javguru.extractors
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class EmTurboExtractor(private val client: OkHttpClient, private val headers: Headers) {
|
||||
|
||||
private val playlistExtractor by lazy { PlaylistUtils(client, headers) }
|
||||
|
||||
fun getVideos(url: String): List<Video> {
|
||||
val document = client.newCall(GET(url, headers)).execute().asJsoup()
|
||||
|
||||
val script = document.selectFirst("script:containsData(urlplay)")
|
||||
?.data()
|
||||
?: return emptyList()
|
||||
|
||||
val urlPlay = URLPLAY.find(script)?.groupValues?.get(1)
|
||||
?: return emptyList()
|
||||
|
||||
if (urlPlay.toHttpUrlOrNull() == null) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return playlistExtractor.extractFromHls(urlPlay, url, videoNameGen = { quality -> "EmTurboVid: $quality" })
|
||||
.distinctBy { it.url } // they have the same stream repeated twice in the playlist file
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val URLPLAY = Regex("""urlPlay\s*=\s*\'([^\']+)""")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.javguru.extractors
|
||||
|
||||
import dev.datlag.jsunpacker.JsUnpacker
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MaxStreamExtractor(private val client: OkHttpClient, private val headers: Headers) {
|
||||
|
||||
private val playListUtils by lazy { PlaylistUtils(client, headers) }
|
||||
|
||||
fun videoFromUrl(url: String): List<Video> {
|
||||
val document = client.newCall(GET(url, headers)).execute().asJsoup()
|
||||
|
||||
val script = document.selectFirst("script:containsData(function(p,a,c,k,e,d))")
|
||||
?.data()
|
||||
?.let(JsUnpacker::unpackAndCombine)
|
||||
?: return emptyList()
|
||||
|
||||
val videoUrl = script.substringAfter("file:\"").substringBefore("\"")
|
||||
|
||||
if (videoUrl.toHttpUrlOrNull() == null) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return playListUtils.extractFromHls(videoUrl, url, videoNameGen = { quality -> "MaxStream: $quality" })
|
||||
}
|
||||
}
|
7
src/all/jellyfin/build.gradle
Normal file
|
@ -0,0 +1,7 @@
|
|||
ext {
|
||||
extName = 'Jellyfin'
|
||||
extClass = '.JellyfinFactory'
|
||||
extVersionCode = 15
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
BIN
src/all/jellyfin/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
src/all/jellyfin/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
src/all/jellyfin/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
src/all/jellyfin/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 9.5 KiB |
BIN
src/all/jellyfin/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
src/all/jellyfin/res/web_hi_res_512.png
Normal file
After Width: | Height: | Size: 57 KiB |
|
@ -0,0 +1,803 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.text.InputType
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.UnmeteredSource
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
|
||||
import eu.kanade.tachiyomi.animesource.model.AnimesPage
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import eu.kanade.tachiyomi.animesource.model.Track
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class Jellyfin(private val suffix: String) : ConfigurableAnimeSource, AnimeHttpSource(), UnmeteredSource {
|
||||
override val baseUrl by lazy { getPrefBaseUrl() }
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val name by lazy { "Jellyfin (${getCustomLabel()})" }
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private fun getUnsafeOkHttpClient(): OkHttpClient {
|
||||
// Create a trust manager that does not validate certificate chains
|
||||
val trustAllCerts = arrayOf<TrustManager>(
|
||||
@SuppressLint("CustomX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
}
|
||||
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
|
||||
},
|
||||
)
|
||||
|
||||
// Install the all-trusting trust manager
|
||||
val sslContext = SSLContext.getInstance("SSL")
|
||||
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
// Create an ssl socket factory with our all-trusting manager
|
||||
val sslSocketFactory = sslContext.socketFactory
|
||||
|
||||
return network.client.newBuilder()
|
||||
.sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager)
|
||||
.hostnameVerifier { _, _ -> true }.build()
|
||||
}
|
||||
|
||||
override val client by lazy {
|
||||
if (preferences.getTrustCert) {
|
||||
getUnsafeOkHttpClient()
|
||||
} else {
|
||||
network.client
|
||||
}.newBuilder()
|
||||
.dns(Dns.SYSTEM)
|
||||
.build()
|
||||
}
|
||||
|
||||
override val id by lazy {
|
||||
val key = "jellyfin" + (if (suffix == "1") "" else " ($suffix)") + "/all/$versionId"
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
||||
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||
}
|
||||
|
||||
internal val preferences: SharedPreferences by lazy {
|
||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||
}
|
||||
|
||||
private var username = preferences.getUserName
|
||||
private var password = preferences.getPassword
|
||||
private var parentId = preferences.getMediaLibId
|
||||
private var apiKey = preferences.getApiKey
|
||||
private var userId = preferences.getUserId
|
||||
|
||||
init {
|
||||
login(false)
|
||||
}
|
||||
|
||||
private fun login(new: Boolean, context: Context? = null): Boolean? {
|
||||
if (apiKey == null || userId == null || new) {
|
||||
username = preferences.getUserName
|
||||
password = preferences.getPassword
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
if (username != "demo") return null
|
||||
}
|
||||
val (newKey, newUid) = runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
JellyfinAuthenticator(preferences, getPrefBaseUrl(), client)
|
||||
.login(username, password)
|
||||
}
|
||||
}
|
||||
if (newKey != null && newUid != null) {
|
||||
apiKey = newKey
|
||||
userId = newUid
|
||||
} else {
|
||||
context?.let { Toast.makeText(it, "Login failed.", Toast.LENGTH_LONG).show() }
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override fun popularAnimeRequest(page: Int): Request {
|
||||
require(parentId.isNotEmpty()) { "Select library in the extension settings." }
|
||||
val startIndex = (page - 1) * SEASONS_LIMIT
|
||||
|
||||
val url = "$baseUrl/Users/$userId/Items".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("api_key", apiKey)
|
||||
addQueryParameter("StartIndex", startIndex.toString())
|
||||
addQueryParameter("Limit", SEASONS_LIMIT.toString())
|
||||
addQueryParameter("Recursive", "true")
|
||||
addQueryParameter("SortBy", "SortName")
|
||||
addQueryParameter("SortOrder", "Ascending")
|
||||
addQueryParameter("IncludeItemTypes", "Movie,Season,BoxSet")
|
||||
addQueryParameter("ImageTypeLimit", "1")
|
||||
addQueryParameter("ParentId", parentId)
|
||||
addQueryParameter("EnableImageTypes", "Primary")
|
||||
}.build()
|
||||
|
||||
return GET(url)
|
||||
}
|
||||
|
||||
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||
val splitCollections = preferences.getSplitCol
|
||||
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SEASONS_LIMIT + 1
|
||||
val data = response.parseAs<ItemsDto>()
|
||||
val animeList = data.items.flatMap {
|
||||
if (it.type == "BoxSet" && splitCollections) {
|
||||
val url = popularAnimeRequest(page).url.newBuilder().apply {
|
||||
setQueryParameter("ParentId", it.id)
|
||||
}.build()
|
||||
|
||||
popularAnimeParse(
|
||||
client.newCall(GET(url)).execute(),
|
||||
).animes
|
||||
} else {
|
||||
listOf(it.toSAnime(baseUrl, userId!!, apiKey!!))
|
||||
}
|
||||
}
|
||||
return AnimesPage(animeList, SEASONS_LIMIT * page < data.itemCount)
|
||||
}
|
||||
|
||||
// =============================== Latest ===============================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = popularAnimeRequest(page).url.newBuilder().apply {
|
||||
setQueryParameter("SortBy", "DateCreated,SortName")
|
||||
setQueryParameter("SortOrder", "Descending")
|
||||
}.build()
|
||||
|
||||
return GET(url)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage =
|
||||
popularAnimeParse(response)
|
||||
|
||||
// =============================== Search ===============================
|
||||
|
||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||
val url = popularAnimeRequest(page).url.newBuilder().apply {
|
||||
// Search for series, rather than seasons, since season names can just be "Season 1"
|
||||
setQueryParameter("IncludeItemTypes", "Movie,Series")
|
||||
setQueryParameter("Limit", SERIES_LIMIT.toString())
|
||||
setQueryParameter("SearchTerm", query)
|
||||
}.build()
|
||||
|
||||
return GET(url)
|
||||
}
|
||||
|
||||
override fun searchAnimeParse(response: Response): AnimesPage {
|
||||
val page = response.request.url.queryParameter("StartIndex")!!.toInt() / SERIES_LIMIT + 1
|
||||
val data = response.parseAs<ItemsDto>()
|
||||
|
||||
// Get all seasons from series
|
||||
val animeList = data.items.flatMap { series ->
|
||||
val seasonsUrl = popularAnimeRequest(1).url.newBuilder().apply {
|
||||
setQueryParameter("ParentId", series.id)
|
||||
removeAllQueryParameters("StartIndex")
|
||||
removeAllQueryParameters("Limit")
|
||||
}.build()
|
||||
|
||||
val seasonsData = client.newCall(
|
||||
GET(seasonsUrl),
|
||||
).execute().parseAs<ItemsDto>()
|
||||
|
||||
seasonsData.items.map { it.toSAnime(baseUrl, userId!!, apiKey!!) }
|
||||
}
|
||||
|
||||
return AnimesPage(animeList, SERIES_LIMIT * page < data.itemCount)
|
||||
}
|
||||
|
||||
// =========================== Anime Details ============================
|
||||
|
||||
override fun animeDetailsRequest(anime: SAnime): Request {
|
||||
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
|
||||
return GET(anime.url)
|
||||
}
|
||||
|
||||
override fun animeDetailsParse(response: Response): SAnime {
|
||||
val data = response.parseAs<ItemDto>()
|
||||
val infoData = if (preferences.useSeriesData && data.seriesId != null) {
|
||||
val url = response.request.url.let { url ->
|
||||
url.newBuilder().apply {
|
||||
removePathSegment(url.pathSize - 1)
|
||||
addPathSegment(data.seriesId)
|
||||
}.build()
|
||||
}
|
||||
|
||||
client.newCall(
|
||||
GET(url),
|
||||
).execute().parseAs<ItemDto>()
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
return infoData.toSAnime(baseUrl, userId!!, apiKey!!)
|
||||
}
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
|
||||
override fun episodeListRequest(anime: SAnime): Request {
|
||||
if (!anime.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
|
||||
val httpUrl = anime.url.toHttpUrl()
|
||||
val fragment = httpUrl.fragment!!
|
||||
|
||||
// Get episodes of season
|
||||
val url = if (fragment.startsWith("seriesId")) {
|
||||
httpUrl.newBuilder().apply {
|
||||
encodedPath("/")
|
||||
encodedQuery(null)
|
||||
fragment(null)
|
||||
|
||||
addPathSegment("Shows")
|
||||
addPathSegment(fragment.split(",").last())
|
||||
addPathSegment("Episodes")
|
||||
addQueryParameter("api_key", apiKey)
|
||||
addQueryParameter("seasonId", httpUrl.pathSegments.last())
|
||||
addQueryParameter("userId", userId)
|
||||
addQueryParameter("Fields", "Overview,MediaSources")
|
||||
}.build()
|
||||
} else if (fragment.startsWith("movie")) {
|
||||
httpUrl.newBuilder().fragment(null).build()
|
||||
} else if (fragment.startsWith("boxSet")) {
|
||||
val itemId = httpUrl.pathSegments[3]
|
||||
httpUrl.newBuilder().apply {
|
||||
removePathSegment(3)
|
||||
addQueryParameter("Recursive", "true")
|
||||
addQueryParameter("SortBy", "SortName")
|
||||
addQueryParameter("SortOrder", "Ascending")
|
||||
addQueryParameter("IncludeItemTypes", "Movie,Season,BoxSet,Series")
|
||||
addQueryParameter("ParentId", itemId)
|
||||
}.build()
|
||||
} else if (fragment.startsWith("series")) {
|
||||
val itemId = httpUrl.pathSegments[3]
|
||||
httpUrl.newBuilder().apply {
|
||||
encodedPath("/")
|
||||
encodedQuery(null)
|
||||
addPathSegment("Shows")
|
||||
addPathSegment(itemId)
|
||||
addPathSegment("Episodes")
|
||||
addQueryParameter("api_key", apiKey)
|
||||
}.build()
|
||||
} else {
|
||||
httpUrl
|
||||
}
|
||||
|
||||
return GET(url)
|
||||
}
|
||||
|
||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||
val httpUrl = response.request.url
|
||||
val episodeList = if (httpUrl.fragment == "boxSet") {
|
||||
val data = response.parseAs<ItemsDto>()
|
||||
val animeList = data.items.map {
|
||||
it.toSAnime(baseUrl, userId!!, apiKey!!)
|
||||
}.sortedByDescending { it.title }
|
||||
animeList.flatMap {
|
||||
client.newCall(episodeListRequest(it))
|
||||
.execute()
|
||||
.let { res ->
|
||||
episodeListParse(res, "${it.title} - ")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
episodeListParse(response, "")
|
||||
}
|
||||
|
||||
return if (preferences.sortEp) {
|
||||
episodeList.sortedByDescending { it.date_upload }
|
||||
} else {
|
||||
episodeList
|
||||
}
|
||||
}
|
||||
|
||||
private fun episodeListParse(response: Response, prefix: String): List<SEpisode> {
|
||||
val httpUrl = response.request.url
|
||||
val epDetails = preferences.getEpDetails
|
||||
return if (response.request.url.toString().startsWith("$baseUrl/Users/")) {
|
||||
val data = response.parseAs<ItemDto>()
|
||||
listOf(data.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.MOVIE, prefix))
|
||||
} else if (httpUrl.fragment == "series") {
|
||||
val data = response.parseAs<ItemsDto>()
|
||||
data.items.map {
|
||||
val name = prefix + (it.seasonName?.let { "$it - " } ?: "")
|
||||
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE, name)
|
||||
}
|
||||
} else {
|
||||
val data = response.parseAs<ItemsDto>()
|
||||
data.items.map {
|
||||
it.toSEpisode(baseUrl, userId!!, apiKey!!, epDetails, EpisodeType.EPISODE, prefix)
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
enum class EpisodeType {
|
||||
EPISODE,
|
||||
MOVIE,
|
||||
}
|
||||
|
||||
// ============================ Video Links =============================
|
||||
|
||||
override fun videoListRequest(episode: SEpisode): Request {
|
||||
if (!episode.url.startsWith("http")) throw Exception("Migrate from jellyfin to jellyfin")
|
||||
return GET(episode.url)
|
||||
}
|
||||
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val id = response.parseAs<ItemDto>().id
|
||||
|
||||
val sessionData = client.newCall(
|
||||
GET("$baseUrl/Items/$id/PlaybackInfo?userId=$userId&api_key=$apiKey"),
|
||||
).execute().parseAs<SessionDto>()
|
||||
|
||||
val videoList = mutableListOf<Video>()
|
||||
val subtitleList = mutableListOf<Track>()
|
||||
val externalSubtitleList = mutableListOf<Track>()
|
||||
|
||||
val prefSub = preferences.getSubPref
|
||||
val prefAudio = preferences.getAudioPref
|
||||
|
||||
var audioIndex = 1
|
||||
var subIndex: Int? = null
|
||||
var width = 1920
|
||||
var height = 1080
|
||||
|
||||
sessionData.mediaSources.first().mediaStreams.forEach { media ->
|
||||
when (media.type) {
|
||||
"Video" -> {
|
||||
width = media.width!!
|
||||
height = media.height!!
|
||||
}
|
||||
"Audio" -> {
|
||||
if (media.lang != null && media.lang == prefAudio) {
|
||||
audioIndex = media.index
|
||||
}
|
||||
}
|
||||
"Subtitle" -> {
|
||||
if (media.supportsExternalStream) {
|
||||
val subtitleUrl = "$baseUrl/Videos/$id/$id/Subtitles/${media.index}/0/Stream.${media.codec}?api_key=$apiKey"
|
||||
if (media.lang != null) {
|
||||
if (media.lang == prefSub) {
|
||||
try {
|
||||
if (media.isExternal) {
|
||||
externalSubtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
|
||||
}
|
||||
subtitleList.add(0, Track(subtitleUrl, media.displayTitle!!))
|
||||
} catch (e: Exception) {
|
||||
subIndex = media.index
|
||||
}
|
||||
} else {
|
||||
if (media.isExternal) {
|
||||
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||
}
|
||||
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||
}
|
||||
} else {
|
||||
if (media.isExternal) {
|
||||
externalSubtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||
}
|
||||
subtitleList.add(Track(subtitleUrl, media.displayTitle!!))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop over qualities
|
||||
JellyfinConstants.QUALITIES_LIST.forEach { quality ->
|
||||
if (width < quality.width && height < quality.height) {
|
||||
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
|
||||
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
|
||||
|
||||
return videoList.reversed()
|
||||
} else {
|
||||
val url = "$baseUrl/videos/$id/main.m3u8".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("api_key", apiKey)
|
||||
addQueryParameter("VideoCodec", "h264")
|
||||
addQueryParameter("AudioCodec", "aac,mp3")
|
||||
addQueryParameter("AudioStreamIndex", audioIndex.toString())
|
||||
subIndex?.let { addQueryParameter("SubtitleStreamIndex", it.toString()) }
|
||||
addQueryParameter("VideoCodec", "h264")
|
||||
addQueryParameter("VideoCodec", "h264")
|
||||
addQueryParameter(
|
||||
"VideoBitrate",
|
||||
quality.videoBitrate.toString(),
|
||||
)
|
||||
addQueryParameter(
|
||||
"AudioBitrate",
|
||||
quality.audioBitrate.toString(),
|
||||
)
|
||||
addQueryParameter("PlaySessionId", sessionData.playSessionId)
|
||||
addQueryParameter("TranscodingMaxAudioChannels", "6")
|
||||
addQueryParameter("RequireAvc", "false")
|
||||
addQueryParameter("SegmentContainer", "ts")
|
||||
addQueryParameter("MinSegments", "1")
|
||||
addQueryParameter("BreakOnNonKeyFrames", "true")
|
||||
addQueryParameter("h264-profile", "high,main,baseline,constrainedbaseline")
|
||||
addQueryParameter("h264-level", "51")
|
||||
addQueryParameter("h264-deinterlace", "true")
|
||||
addQueryParameter("TranscodeReasons", "VideoCodecNotSupported,AudioCodecNotSupported,ContainerBitrateExceedsLimit")
|
||||
}
|
||||
videoList.add(Video(url.toString(), quality.description, url.toString(), subtitleTracks = subtitleList))
|
||||
}
|
||||
}
|
||||
|
||||
val url = "$baseUrl/Videos/$id/stream?static=True&api_key=$apiKey"
|
||||
videoList.add(Video(url, "Source", url, subtitleTracks = externalSubtitleList))
|
||||
|
||||
return videoList.reversed()
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
|
||||
companion object {
|
||||
const val APIKEY_KEY = "api_key"
|
||||
const val USERID_KEY = "user_id"
|
||||
|
||||
internal const val EXTRA_SOURCES_COUNT_KEY = "extraSourcesCount"
|
||||
internal const val EXTRA_SOURCES_COUNT_DEFAULT = "3"
|
||||
private val EXTRA_SOURCES_ENTRIES = (1..10).map { it.toString() }.toTypedArray()
|
||||
|
||||
private const val PREF_CUSTOM_LABEL_KEY = "pref_label"
|
||||
private const val PREF_CUSTOM_LABEL_DEFAULT = ""
|
||||
|
||||
private const val HOSTURL_KEY = "host_url"
|
||||
private const val HOSTURL_DEFAULT = "http://127.0.0.1:8096"
|
||||
|
||||
private const val USERNAME_KEY = "username"
|
||||
private const val USERNAME_DEFAULT = ""
|
||||
|
||||
private const val PASSWORD_KEY = "password"
|
||||
private const val PASSWORD_DEFAULT = ""
|
||||
|
||||
private const val MEDIALIB_KEY = "library_pref"
|
||||
private const val MEDIALIB_DEFAULT = ""
|
||||
|
||||
private const val SEASONS_LIMIT = 20
|
||||
private const val SERIES_LIMIT = 5
|
||||
|
||||
private const val PREF_EP_DETAILS_KEY = "pref_episode_details_key"
|
||||
private val PREF_EP_DETAILS = arrayOf("Overview", "Runtime", "Size")
|
||||
private val PREF_EP_DETAILS_DEFAULT = emptySet<String>()
|
||||
|
||||
private const val PREF_SUB_KEY = "preferred_subLang"
|
||||
private const val PREF_SUB_DEFAULT = "eng"
|
||||
|
||||
private const val PREF_AUDIO_KEY = "preferred_audioLang"
|
||||
private const val PREF_AUDIO_DEFAULT = "jpn"
|
||||
|
||||
private const val PREF_INFO_TYPE = "preferred_meta_type"
|
||||
private const val PREF_INFO_DEFAULT = false
|
||||
|
||||
private const val PREF_TRUST_CERT_KEY = "preferred_trust_all_certs"
|
||||
private const val PREF_TRUST_CERT_DEFAULT = false
|
||||
|
||||
private const val PREF_SPLIT_COLLECTIONS_KEY = "preferred_split_col"
|
||||
private const val PREF_SPLIT_COLLECTIONS_DEFAULT = false
|
||||
|
||||
private const val PREF_SORT_EPISODES_KEY = "preferred_sort_ep"
|
||||
private const val PREF_SORT_EPISODES_DEFAULT = false
|
||||
}
|
||||
|
||||
private fun getCustomLabel(): String =
|
||||
preferences.getString(PREF_CUSTOM_LABEL_KEY, suffix)!!.ifBlank { suffix }
|
||||
|
||||
private fun getPrefBaseUrl(): String =
|
||||
preferences.getString(HOSTURL_KEY, HOSTURL_DEFAULT)!!
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
if (suffix == "1") {
|
||||
ListPreference(screen.context).apply {
|
||||
key = EXTRA_SOURCES_COUNT_KEY
|
||||
title = "Number of sources"
|
||||
summary = "Number of jellyfin sources to create. There will always be at least one Jellyfin source."
|
||||
entries = EXTRA_SOURCES_ENTRIES
|
||||
entryValues = EXTRA_SOURCES_ENTRIES
|
||||
|
||||
setDefaultValue(EXTRA_SOURCES_COUNT_DEFAULT)
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val setting = preferences.edit().putString(EXTRA_SOURCES_COUNT_KEY, newValue as String).commit()
|
||||
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
setting
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PREF_CUSTOM_LABEL_KEY
|
||||
title = "Custom Label"
|
||||
summary = "Show the given label for the source instead of the default."
|
||||
setDefaultValue(PREF_CUSTOM_LABEL_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
runCatching {
|
||||
val value = (newValue as String).trim().ifBlank { PREF_CUSTOM_LABEL_DEFAULT }
|
||||
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||
preferences.edit().putString(key, value).commit()
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
val mediaLibPref = medialibPreference(screen)
|
||||
screen.addPreference(
|
||||
screen.editTextPreference(
|
||||
HOSTURL_KEY,
|
||||
"Host URL",
|
||||
HOSTURL_DEFAULT,
|
||||
baseUrl,
|
||||
false,
|
||||
"",
|
||||
mediaLibPref,
|
||||
),
|
||||
)
|
||||
screen.addPreference(
|
||||
screen.editTextPreference(
|
||||
USERNAME_KEY,
|
||||
"Username",
|
||||
USERNAME_DEFAULT,
|
||||
username,
|
||||
false,
|
||||
"The account username",
|
||||
mediaLibPref,
|
||||
),
|
||||
)
|
||||
screen.addPreference(
|
||||
screen.editTextPreference(
|
||||
PASSWORD_KEY,
|
||||
"Password",
|
||||
PASSWORD_DEFAULT,
|
||||
password,
|
||||
true,
|
||||
"••••••••",
|
||||
mediaLibPref,
|
||||
),
|
||||
)
|
||||
screen.addPreference(mediaLibPref)
|
||||
|
||||
MultiSelectListPreference(screen.context).apply {
|
||||
key = PREF_EP_DETAILS_KEY
|
||||
title = "Additional details for episodes"
|
||||
summary = "Show additional details about an episode in the scanlator field"
|
||||
entries = PREF_EP_DETAILS
|
||||
entryValues = PREF_EP_DETAILS
|
||||
setDefaultValue(PREF_EP_DETAILS_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_SUB_KEY
|
||||
title = "Preferred sub language"
|
||||
entries = JellyfinConstants.PREF_ENTRIES
|
||||
entryValues = JellyfinConstants.PREF_VALUES
|
||||
setDefaultValue(PREF_SUB_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_AUDIO_KEY
|
||||
title = "Preferred audio language"
|
||||
entries = JellyfinConstants.PREF_ENTRIES
|
||||
entryValues = JellyfinConstants.PREF_VALUES
|
||||
setDefaultValue(PREF_AUDIO_DEFAULT)
|
||||
summary = "%s"
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_INFO_TYPE
|
||||
title = "Retrieve metadata from series"
|
||||
summary = """Enable this to retrieve metadata from series instead of season when applicable.""".trimMargin()
|
||||
setDefaultValue(PREF_INFO_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val new = newValue as Boolean
|
||||
preferences.edit().putBoolean(key, new).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_TRUST_CERT_KEY
|
||||
title = "Trust all certificates"
|
||||
summary = "Requires app restart to take effect."
|
||||
setDefaultValue(PREF_TRUST_CERT_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val new = newValue as Boolean
|
||||
preferences.edit().putBoolean(key, new).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_SPLIT_COLLECTIONS_KEY
|
||||
title = "Split collections"
|
||||
summary = "Split each item in a collection into its own entry"
|
||||
setDefaultValue(PREF_SPLIT_COLLECTIONS_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val new = newValue as Boolean
|
||||
preferences.edit().putBoolean(key, new).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = PREF_SORT_EPISODES_KEY
|
||||
title = "Sort episodes by release date"
|
||||
summary = "Useful for collections, otherwise items in a collection are grouped by name."
|
||||
setDefaultValue(PREF_SORT_EPISODES_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val new = newValue as Boolean
|
||||
preferences.edit().putBoolean(key, new).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private val SharedPreferences.getApiKey
|
||||
get() = getString(APIKEY_KEY, null)
|
||||
|
||||
private val SharedPreferences.getUserId
|
||||
get() = getString(USERID_KEY, null)
|
||||
|
||||
private val SharedPreferences.getUserName
|
||||
get() = getString(USERNAME_KEY, USERNAME_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.getPassword
|
||||
get() = getString(PASSWORD_KEY, PASSWORD_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.getMediaLibId
|
||||
get() = getString(MEDIALIB_KEY, MEDIALIB_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.getEpDetails
|
||||
get() = getStringSet(PREF_EP_DETAILS_KEY, PREF_EP_DETAILS_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.getSubPref
|
||||
get() = getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.getAudioPref
|
||||
get() = getString(PREF_AUDIO_KEY, PREF_AUDIO_DEFAULT)!!
|
||||
|
||||
private val SharedPreferences.useSeriesData
|
||||
get() = getBoolean(PREF_INFO_TYPE, PREF_INFO_DEFAULT)
|
||||
|
||||
private val SharedPreferences.getTrustCert
|
||||
get() = getBoolean(PREF_TRUST_CERT_KEY, PREF_TRUST_CERT_DEFAULT)
|
||||
|
||||
private val SharedPreferences.getSplitCol
|
||||
get() = getBoolean(PREF_SPLIT_COLLECTIONS_KEY, PREF_SPLIT_COLLECTIONS_DEFAULT)
|
||||
|
||||
private val SharedPreferences.sortEp
|
||||
get() = getBoolean(PREF_SORT_EPISODES_KEY, PREF_SORT_EPISODES_DEFAULT)
|
||||
|
||||
private abstract class MediaLibPreference(context: Context) : ListPreference(context) {
|
||||
abstract fun reload()
|
||||
}
|
||||
|
||||
private fun medialibPreference(screen: PreferenceScreen) =
|
||||
object : MediaLibPreference(screen.context) {
|
||||
override fun reload() {
|
||||
this.apply {
|
||||
key = MEDIALIB_KEY
|
||||
title = "Select Media Library"
|
||||
summary = "%s"
|
||||
|
||||
Thread {
|
||||
try {
|
||||
val mediaLibsResponse = client.newCall(
|
||||
GET("$baseUrl/Users/$userId/Items?api_key=$apiKey"),
|
||||
).execute()
|
||||
val mediaJson = mediaLibsResponse.parseAs<ItemsDto>().items
|
||||
|
||||
val entriesArray = mediaJson.map { it.name }
|
||||
val entriesValueArray = mediaJson.map { it.id }
|
||||
|
||||
entries = entriesArray.toTypedArray()
|
||||
entryValues = entriesValueArray.toTypedArray()
|
||||
} catch (ex: Exception) {
|
||||
entries = emptyArray()
|
||||
entryValues = emptyArray()
|
||||
}
|
||||
}.start()
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
parentId = entry
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply { reload() }
|
||||
|
||||
private fun getSummary(isPassword: Boolean, value: String, placeholder: String) = when {
|
||||
isPassword && value.isNotEmpty() || !isPassword && value.isEmpty() -> placeholder
|
||||
else -> value
|
||||
}
|
||||
|
||||
private fun PreferenceScreen.editTextPreference(key: String, title: String, default: String, value: String, isPassword: Boolean = false, placeholder: String, mediaLibPref: MediaLibPreference): EditTextPreference {
|
||||
return EditTextPreference(context).apply {
|
||||
this.key = key
|
||||
this.title = title
|
||||
summary = getSummary(isPassword, value, placeholder)
|
||||
this.setDefaultValue(default)
|
||||
dialogTitle = title
|
||||
|
||||
setOnBindEditTextListener {
|
||||
it.inputType = if (isPassword) {
|
||||
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
} else {
|
||||
InputType.TYPE_CLASS_TEXT
|
||||
}
|
||||
}
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
try {
|
||||
val newValueString = newValue as String
|
||||
val res = preferences.edit().putString(key, newValueString).commit()
|
||||
summary = getSummary(isPassword, newValueString, placeholder)
|
||||
val loginRes = login(true, context)
|
||||
if (loginRes == true) {
|
||||
mediaLibPref.reload()
|
||||
}
|
||||
res
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import eu.kanade.tachiyomi.AppInfo
|
||||
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.APIKEY_KEY
|
||||
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.Companion.USERID_KEY
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.util.parseAs
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class JellyfinAuthenticator(
|
||||
private val preferences: SharedPreferences,
|
||||
private val baseUrl: String,
|
||||
private val client: OkHttpClient,
|
||||
) {
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
fun login(username: String, password: String): Pair<String?, String?> {
|
||||
return runCatching {
|
||||
val authResult = authenticateWithPassword(username, password)
|
||||
val key = authResult.accessToken
|
||||
val userId = authResult.sessionInfo.userId
|
||||
saveLogin(key, userId)
|
||||
Pair(key, userId)
|
||||
}.getOrElse {
|
||||
Log.e(LOG_TAG, it.stackTraceToString())
|
||||
Pair(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun authenticateWithPassword(username: String, password: String): LoginDto {
|
||||
var deviceId = getPrefDeviceId()
|
||||
if (deviceId.isNullOrEmpty()) {
|
||||
deviceId = getRandomString()
|
||||
setPrefDeviceId(deviceId)
|
||||
}
|
||||
val aniyomiVersion = AppInfo.getVersionName()
|
||||
val androidVersion = Build.VERSION.RELEASE
|
||||
val authHeader = Headers.headersOf(
|
||||
"X-Emby-Authorization",
|
||||
"MediaBrowser Client=\"$CLIENT\", Device=\"Android $androidVersion\", DeviceId=\"$deviceId\", Version=\"$aniyomiVersion\"",
|
||||
)
|
||||
val body = json.encodeToString(
|
||||
buildJsonObject {
|
||||
put("Username", username)
|
||||
put("Pw", password)
|
||||
},
|
||||
).toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||
|
||||
val request = POST("$baseUrl/Users/authenticatebyname", headers = authHeader, body = body)
|
||||
return client.newCall(request).execute().parseAs()
|
||||
}
|
||||
|
||||
private fun getRandomString(): String {
|
||||
val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
|
||||
return (1..172)
|
||||
.map { allowedChars.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
|
||||
private fun saveLogin(key: String, userId: String) {
|
||||
preferences.edit()
|
||||
.putString(APIKEY_KEY, key)
|
||||
.putString(USERID_KEY, userId)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getPrefDeviceId(): String? = preferences.getString(
|
||||
DEVICEID_KEY,
|
||||
null,
|
||||
)
|
||||
|
||||
private fun setPrefDeviceId(value: String) = preferences.edit().putString(
|
||||
DEVICEID_KEY,
|
||||
value,
|
||||
).apply()
|
||||
|
||||
companion object {
|
||||
private const val DEVICEID_KEY = "device_id"
|
||||
private const val CLIENT = "Aniyomi"
|
||||
private const val LOG_TAG = "JellyfinAuthenticator"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
||||
|
||||
object JellyfinConstants {
|
||||
val QUALITIES_LIST = arrayOf(
|
||||
Quality(480, 360, 292000, 128000, "360p - 420 kbps"),
|
||||
Quality(854, 480, 528000, 192000, "480p - 720 kbps"),
|
||||
Quality(854, 480, 1308000, 192000, "480p - 1.5 Mbps"),
|
||||
Quality(854, 480, 2808000, 192000, "480p - 3 Mbps"),
|
||||
Quality(1280, 720, 3808000, 192000, "720p - 4 Mbps"),
|
||||
Quality(1280, 720, 5808000, 192000, "720p - 6 Mbps"),
|
||||
Quality(1280, 720, 7808000, 192000, "720p - 8 Mbps"),
|
||||
Quality(1920, 1080, 9808000, 192000, "1080p - 10 Mbps"),
|
||||
Quality(1920, 1080, 14808000, 192000, "1080p - 15 Mbps"),
|
||||
Quality(1920, 1080, 19808000, 192000, "1080p - 20 Mbps"),
|
||||
Quality(1920, 1080, 39808000, 192000, "1080p - 40 Mbps"),
|
||||
Quality(1920, 1080, 59808000, 192000, "1080p - 60 Mbps"),
|
||||
Quality(3840, 2160, 80000000, 192000, "4K - 80 Mbps"),
|
||||
Quality(3840, 2160, 120000000, 192000, "4K - 120 Mbps"),
|
||||
)
|
||||
|
||||
data class Quality(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val videoBitrate: Int,
|
||||
val audioBitrate: Int,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
val PREF_VALUES = arrayOf(
|
||||
"aar", "abk", "ace", "ach", "ada", "ady", "afh", "afr", "ain", "aka", "akk", "ale", "alt", "amh", "ang", "anp", "apa",
|
||||
"ara", "arc", "arg", "arn", "arp", "arw", "asm", "ast", "ath", "ava", "ave", "awa", "aym", "aze", "bai", "bak", "bal",
|
||||
"bam", "ban", "bas", "bej", "bel", "bem", "ben", "ber", "bho", "bik", "bin", "bis", "bla", "bod", "bos", "bra", "bre",
|
||||
"bua", "bug", "bul", "byn", "cad", "car", "cat", "ceb", "ces", "cha", "chb", "che", "chg", "chk", "chm", "chn", "cho",
|
||||
"chp", "chr", "chu", "chv", "chy", "cnr", "cop", "cor", "cos", "cre", "crh", "csb", "cym", "dak", "dan", "dar", "del",
|
||||
"den", "deu", "dgr", "din", "div", "doi", "dsb", "dua", "dum", "dyu", "dzo", "efi", "egy", "eka", "ell", "elx", "eng",
|
||||
"enm", "epo", "est", "eus", "ewe", "ewo", "fan", "fao", "fas", "fat", "fij", "fil", "fin", "fiu", "fon", "fra", "frm",
|
||||
"fro", "frr", "frs", "fry", "ful", "fur", "gaa", "gay", "gba", "gez", "gil", "gla", "gle", "glg", "glv", "gmh", "goh",
|
||||
"gon", "gor", "got", "grb", "grc", "grn", "gsw", "guj", "gwi", "hai", "hat", "hau", "haw", "heb", "her", "hil", "hin",
|
||||
"hit", "hmn", "hmo", "hrv", "hsb", "hun", "hup", "hye", "iba", "ibo", "ido", "iii", "ijo", "iku", "ile", "ilo", "ina",
|
||||
"inc", "ind", "inh", "ipk", "isl", "ita", "jav", "jbo", "jpn", "jpr", "jrb", "kaa", "kab", "kac", "kal", "kam", "kan",
|
||||
"kar", "kas", "kat", "kau", "kaw", "kaz", "kbd", "kha", "khm", "kho", "kik", "kin", "kir", "kmb", "kok", "kom", "kon",
|
||||
"kor", "kos", "kpe", "krc", "krl", "kru", "kua", "kum", "kur", "kut", "lad", "lah", "lam", "lao", "lat", "lav", "lez",
|
||||
"lim", "lin", "lit", "lol", "loz", "ltz", "lua", "lub", "lug", "lui", "lun", "luo", "lus", "mad", "mag", "mah", "mai",
|
||||
"mak", "mal", "man", "mar", "mas", "mdf", "mdr", "men", "mga", "mic", "min", "mkd", "mkh", "mlg", "mlt", "mnc", "mni",
|
||||
"moh", "mon", "mos", "mri", "msa", "mus", "mwl", "mwr", "mya", "myv", "nah", "nap", "nau", "nav", "nbl", "nde", "ndo",
|
||||
"nds", "nep", "new", "nia", "nic", "niu", "nld", "nno", "nob", "nog", "non", "nor", "nqo", "nso", "nub", "nwc", "nya",
|
||||
"nym", "nyn", "nyo", "nzi", "oci", "oji", "ori", "orm", "osa", "oss", "ota", "oto", "pag", "pal", "pam", "pan", "pap",
|
||||
"pau", "peo", "phn", "pli", "pol", "pon", "por", "pro", "pus", "que", "raj", "rap", "rar", "roh", "rom", "ron", "run",
|
||||
"rup", "rus", "sad", "sag", "sah", "sam", "san", "sas", "sat", "scn", "sco", "sel", "sga", "shn", "sid", "sin", "slk",
|
||||
"slv", "sma", "sme", "smj", "smn", "smo", "sms", "sna", "snd", "snk", "sog", "som", "son", "sot", "spa", "sqi", "srd",
|
||||
"srn", "srp", "srr", "ssw", "suk", "sun", "sus", "sux", "swa", "swe", "syc", "syr", "tah", "tai", "tam", "tat", "tel",
|
||||
"tem", "ter", "tet", "tgk", "tgl", "tha", "tig", "tir", "tiv", "tkl", "tlh", "tli", "tmh", "tog", "ton", "tpi", "tsi",
|
||||
"tsn", "tso", "tuk", "tum", "tup", "tur", "tvl", "twi", "tyv", "udm", "uga", "uig", "ukr", "umb", "urd", "uzb", "vai",
|
||||
"ven", "vie", "vol", "vot", "wal", "war", "was", "wen", "wln", "wol", "xal", "xho", "yao", "yap", "yid", "yor", "zap",
|
||||
"zbl", "zen", "zgh", "zha", "zho", "zul", "zun", "zza",
|
||||
)
|
||||
|
||||
val PREF_ENTRIES = arrayOf(
|
||||
"Qafaraf; ’Afar Af; Afaraf; Qafar af", "Аҧсуа бызшәа Aƥsua bızšwa; Аҧсшәа Aƥsua", "بهسا اچيه", "Lwo", "Dangme",
|
||||
"Адыгабзэ; Кӏахыбзэ", "El-Afrihili", "Afrikaans", "アイヌ・イタㇰ Ainu-itak", "Akan", "𒀝𒅗𒁺𒌑", "Уна́ӈам тунуу́; Унаӈан умсуу",
|
||||
"Алтай тили", "አማርኛ Amârıñâ", "Ænglisc; Anglisc; Englisc", "Angika", "Apache languages", "العَرَبِيَّة al'Arabiyyeẗ",
|
||||
"Official Aramaic (700–300 BCE); Imperial Aramaic (700–300 BCE)", "aragonés", "Mapudungun; Mapuche", "Hinónoʼeitíít",
|
||||
"Lokono", "অসমীয়া", "Asturianu; Llïonés", "Athapascan languages", "Магӏарул мацӏ; Авар мацӏ", "Avestan", "अवधी",
|
||||
"Aymar aru", "Azərbaycan dili; آذربایجان دیلی; Азәрбајҹан дили", "Bamiléké", "Башҡорт теле; Başqort tele",
|
||||
"بلوچی", "ߓߊߡߊߣߊߣߞߊߣ", "ᬪᬵᬱᬩᬮᬶ; ᬩᬲᬩᬮᬶ; Basa Bali", "Mbene; Ɓasaá", "Bidhaawyeet", "Беларуская мова Belaruskaâ mova",
|
||||
"Chibemba", "বাংলা Bāŋlā", "Tamaziɣt; Tamazight; ⵜⴰⵎⴰⵣⵉⵖⵜ; ⵝⴰⵎⴰⵣⵉⵗⵝ; ⵜⴰⵎⴰⵣⵉⵗⵜ", "भोजपुरी", "Bikol", "Ẹ̀dó",
|
||||
"Bislama", "ᓱᖽᐧᖿ", "བོད་སྐད་ Bodskad; ལྷ་སའི་སྐད་ Lhas'iskad", "bosanski", "Braj", "Brezhoneg", "буряад хэлэн",
|
||||
"ᨅᨔ ᨕᨘᨁᨗ", "български език bălgarski ezik", "ብሊና; ብሊን", "Hasí:nay", "Kari'nja", "català,valencià", "Sinugbuanong Binisayâ",
|
||||
"čeština; český jazyk", "Finu' Chamoru", "Muysccubun", "Нохчийн мотт; نَاخچیین موٓتت; ნახჩიე მუოთთ", "جغتای",
|
||||
"Chuukese", "марий йылме", "chinuk wawa; wawa; chinook lelang; lelang", "Chahta'", "ᑌᓀᓱᒼᕄᓀ (Dënesųłiné)",
|
||||
"ᏣᎳᎩ ᎦᏬᏂᎯᏍᏗ Tsalagi gawonihisdi", "Славе́нскїй ѧ҆зы́къ", "Чӑвашла", "Tsėhésenėstsestȯtse", "crnogorski / црногорски",
|
||||
"ϯⲙⲉⲑⲣⲉⲙⲛ̀ⲭⲏⲙⲓ; ⲧⲙⲛ̄ⲧⲣⲙ̄ⲛ̄ⲕⲏⲙⲉ", "Kernowek", "Corsu; Lingua corsa", "Cree", "Къырымтатарджа; Къырымтатар тили; Ҡырымтатарҗа; Ҡырымтатар тили",
|
||||
"Kaszëbsczi jãzëk", "Cymraeg; y Gymraeg", "Dakhótiyapi; Dakȟótiyapi", "dansk", "дарган мез", "Delaware", "Dene K'e",
|
||||
"Deutsch", "Dogrib", "Thuɔŋjäŋ", "ދިވެހި; ދިވެހިބަސް Divehi", "𑠖𑠵𑠌𑠤𑠮; डोगरी; ڈوگرى", "Dolnoserbski; Dolnoserbšćina",
|
||||
"Duala", "Dutch, Middle (ca. 1050–1350)", "Julakan", "རྫོང་ཁ་ Ĵoŋkha", "Efik", "Egyptian (Ancient)", "Ekajuk",
|
||||
"Νέα Ελληνικά Néa Ellêniká", "Elamite", "English", "English, Middle (1100–1500)", "Esperanto", "eesti keel",
|
||||
"euskara", "Èʋegbe", "Ewondo", "Fang", "føroyskt", "فارسی Fārsiy", "Mfantse; Fante; Fanti", "Na Vosa Vakaviti",
|
||||
"Wikang Filipino", "suomen kieli", "Finno-Ugrian languages", "Fon gbè", "français", "françois; franceis", "Franceis; François; Romanz",
|
||||
"Frasch; Fresk; Freesk; Friisk", "Oostfreesk; Plattdüütsk", "Frysk", "Fulfulde; Pulaar; Pular", "Furlan",
|
||||
"Gã", "Basa Gayo", "Gbaya", "ግዕዝ", "Taetae ni Kiribati", "Gàidhlig", "Gaeilge", "galego", "Gaelg; Gailck", "Diutsch",
|
||||
"Diutisk", "Gondi", "Bahasa Hulontalo", "Gothic", "Grebo", "Ἑλληνική", "Avañe'ẽ", "Schwiizerdütsch", "ગુજરાતી Gujarātī",
|
||||
"Dinjii Zhu’ Ginjik", "X̱aat Kíl; X̱aadas Kíl; X̱aayda Kil; Xaad kil", "kreyòl ayisyen", "Harshen Hausa; هَرْشَن",
|
||||
"ʻŌlelo Hawaiʻi", "עברית 'Ivriyþ", "Otjiherero", "Ilonggo", "हिन्दी Hindī", "𒉈𒅆𒇷", "lus Hmoob; lug Moob; lol Hmongb; 𖬇𖬰𖬞 𖬌𖬣𖬵",
|
||||
"Hiri Motu", "hrvatski", "hornjoserbšćina", "magyar nyelv", "Na:tinixwe Mixine:whe'", "Հայերէն Hayerèn; Հայերեն Hayeren",
|
||||
"Jaku Iban", "Asụsụ Igbo", "Ido", "ꆈꌠꉙ Nuosuhxop", "Ịjọ", "ᐃᓄᒃᑎᑐᑦ Inuktitut", "Interlingue; Occidental", "Pagsasao nga Ilokano; Ilokano",
|
||||
"Interlingua (International Auxiliary Language Association)", "Indo-Aryan languages", "bahasa Indonesia",
|
||||
"ГӀалгӀай мотт", "Iñupiaq", "íslenska", "italiano; lingua italiana", "ꦧꦱꦗꦮ / Basa Jawa", "la .lojban.", "日本語 Nihongo",
|
||||
"Dzhidi", "عربية يهودية / ערבית יהודית", "Qaraqalpaq tili; Қарақалпақ тили", "Tamaziɣt Taqbaylit; Tazwawt",
|
||||
"Jingpho", "Kalaallisut; Greenlandic", "Kamba", "ಕನ್ನಡ Kannađa", "Karen languages", "कॉशुर / كأشُر", "ქართული Kharthuli",
|
||||
"Kanuri", "ꦧꦱꦗꦮ", "қазақ тілі qazaq tili; қазақша qazaqşa", "Адыгэбзэ (Къэбэрдейбзэ) Adıgăbză (Qăbărdeĭbză)",
|
||||
"কা কতিয়েন খাশি", "ភាសាខ្មែរ Phiəsaakhmær", "Khotanese; Sakan", "Gĩkũyũ", "Ikinyarwanda", "кыргызча kırgızça; кыргыз тили kırgız tili",
|
||||
"Kimbundu", "कोंकणी", "Коми кыв", "Kongo", "한국어 Han'gug'ô", "Kosraean", "Kpɛlɛwoo", "Къарачай-Малкъар тил; Таулу тил",
|
||||
"karjal; kariela; karjala", "कुड़ुख़", "Kuanyama; Kwanyama", "къумукъ тил/qumuq til", "kurdî / کوردی", "Kutenai",
|
||||
"Judeo-español", "بھارت کا", "Lamba", "ພາສາລາວ Phasalaw", "Lingua latīna", "Latviešu valoda", "Лезги чӏал",
|
||||
"Lèmburgs", "Lingala", "lietuvių kalba", "Lomongo", "Lozi", "Lëtzebuergesch", "Cilubà / Tshiluba", "Kiluba",
|
||||
"Luganda", "Cham'teela", "Chilunda", "Dholuo", "Mizo ṭawng", "Madhura", "मगही", "Kajin M̧ajeļ", "मैथिली; মৈথিলী",
|
||||
"Basa Mangkasara' / ᨅᨔ ᨆᨀᨔᨑ", "മലയാളം Malayāļã", "Mandi'nka kango", "मराठी Marāţhī", "ɔl", "мокшень кяль",
|
||||
"Mandar", "Mɛnde yia", "Gaoidhealg", "Míkmawísimk", "Baso Minang", "македонски јазик makedonski jazik", "Mon-Khmer languages",
|
||||
"Malagasy", "Malti", "ᠮᠠᠨᠵᡠ ᡤᡳᠰᡠᠨ Manju gisun", "Manipuri", "Kanien’kéha", "монгол хэл mongol xel; ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ",
|
||||
"Mooré", "Te Reo Māori", "Bahasa Melayu", "Mvskoke", "mirandés; lhéngua mirandesa", "मारवाड़ी", "မြန်မာစာ Mrãmācā; မြန်မာစကား Mrãmākā:",
|
||||
"эрзянь кель", "Nahuatl languages", "napulitano", "dorerin Naoero", "Diné bizaad; Naabeehó bizaad", "isiNdebele seSewula",
|
||||
"siNdebele saseNyakatho", "ndonga", "Plattdütsch; Plattdüütsch", "नेपाली भाषा Nepālī bhāśā", "नेपाल भाषा; नेवाः भाय्",
|
||||
"Li Niha", "Niger-Kordofanian languages", "ko e vagahau Niuē", "Nederlands; Vlaams", "norsk nynorsk", "norsk bokmål",
|
||||
"Ногай тили", "Dǫnsk tunga; Norrœnt mál", "norsk", "N'Ko", "Sesotho sa Leboa", "لغات نوبية", "पुलां भाय्; पुलाङु नेपाल भाय्",
|
||||
"Chichewa; Chinyanja", "Nyamwezi", "Nyankole", "Runyoro", "Nzima", "occitan; lenga d'òc", "Ojibwa", "ଓଡ଼ିଆ",
|
||||
"Afaan Oromoo", "Wazhazhe ie / 𐓏𐓘𐓻𐓘𐓻𐓟 𐒻𐓟", "Ирон ӕвзаг Iron ævzag", "لسان عثمانى / lisân-ı Osmânî", "Otomian languages",
|
||||
"Salitan Pangasinan", "Pārsīk; Pārsīg", "Amánung Kapampangan; Amánung Sísuan", "ਪੰਜਾਬੀ / پنجابی Pãjābī",
|
||||
"Papiamentu", "a tekoi er a Belau", "Persian, Old (ca. 600–400 B.C.)", "𐤃𐤁𐤓𐤉𐤌 𐤊𐤍𐤏𐤍𐤉𐤌 Dabariym Kana'aniym",
|
||||
"Pāli", "Język polski", "Pohnpeian", "português", "Provençal, Old (to 1500); Old Occitan (to 1500)", "پښتو Pax̌tow",
|
||||
"Runa simi; kichwa simi; Nuna shimi", "राजस्थानी", "Vananga rapa nui", "Māori Kūki 'Āirani", "Rumantsch; Rumàntsch; Romauntsch; Romontsch",
|
||||
"romani čhib", "limba română", "Ikirundi", "armãneashce; armãneashti; rrãmãneshti", "русский язык russkiĭ âzık",
|
||||
"Sandaweeki", "yângâ tî sängö", "Сахалыы", "ארמית", "संस्कृतम् Sąskŕtam; 𑌸𑌂𑌸𑍍𑌕𑍃𑌤𑌮𑍍", "Sasak", "ᱥᱟᱱᱛᱟᱲᱤ", "Sicilianu",
|
||||
"Braid Scots; Lallans", "Selkup", "Goídelc", "ၵႂၢမ်းတႆးယႂ်", "Sidaamu Afoo", "සිංහල Sĩhala", "slovenčina; slovenský jazyk",
|
||||
"slovenski jezik; slovenščina", "Åarjelsaemien gïele", "davvisámegiella", "julevsámegiella", "anarâškielâ",
|
||||
"Gagana faʻa Sāmoa", "sääʹmǩiõll", "chiShona", "سنڌي / सिन्धी / ਸਿੰਧੀ", "Sooninkanxanne", "Sogdian", "af Soomaali",
|
||||
"Songhai languages", "Sesotho [southern]", "español; castellano", "Shqip", "sardu; limba sarda; lingua sarda",
|
||||
"Sranan Tongo", "српски / srpski", "Seereer", "siSwati", "Kɪsukuma", "ᮘᮞ ᮞᮥᮔ᮪ᮓ / Basa Sunda", "Sosoxui", "𒅴𒂠",
|
||||
"Kiswahili", "svenska", "Classical Syriac", "ܠܫܢܐ ܣܘܪܝܝܐ Lešānā Suryāyā", "Reo Tahiti; Reo Mā'ohi", "ภาษาไท; ภาษาไต",
|
||||
"தமிழ் Tamił", "татар теле / tatar tele / تاتار", "తెలుగు Telugu", "KʌThemnɛ", "Terêna", "Lia-Tetun", "тоҷикӣ toçikī",
|
||||
"Wikang Tagalog", "ภาษาไทย Phasathay", "ትግረ; ትግሬ; ኻሳ; ትግራይት", "ትግርኛ", "Tiv", "Tokelau", "Klingon; tlhIngan-Hol",
|
||||
"Lingít", "Tamashek", "chiTonga", "lea faka-Tonga", "Tok Pisin", "Tsimshian", "Setswana", "Xitsonga", "Türkmençe / Түркменче / تورکمن تیلی تورکمنچ; türkmen dili / түркмен дили",
|
||||
"chiTumbuka", "Tupi languages", "Türkçe", "Te Ggana Tuuvalu; Te Gagana Tuuvalu", "Twi", "тыва дыл", "удмурт кыл",
|
||||
"Ugaritic", "ئۇيغۇرچە ; ئۇيغۇر تىلى", "Українська мова; Українська", "Úmbúndú", "اُردُو Urduw", "Oʻzbekcha / Ózbekça / ўзбекча / ئوزبېچه; oʻzbek tili / ўзбек тили / ئوبېک تیلی",
|
||||
"ꕙꔤ", "Tshivenḓa", "Tiếng Việt", "Volapük", "vađđa ceeli", "Wolaitta; Wolaytta", "Winaray; Samareño; Lineyte-Samarnon; Binisayâ nga Winaray; Binisayâ nga Samar-Leyte; “Binisayâ nga Waray”",
|
||||
"wá:šiw ʔítlu", "Serbsce / Serbski", "Walon", "Wolof", "Хальмг келн / Xaľmg keln", "isiXhosa", "Yao", "Yapese",
|
||||
"ייִדיש; יידיש; אידיש Yidiš", "èdè Yorùbá", "Diidxazá/Dizhsa", "Blissymbols; Blissymbolics; Bliss", "Tuḍḍungiyya",
|
||||
"ⵜⴰⵎⴰⵣⵉⵖⵜ ⵜⴰⵏⴰⵡⴰⵢⵜ", "Vahcuengh / 話僮", "中文 Zhōngwén; 汉语; 漢語 Hànyǔ", "isiZulu", "Shiwi'ma", "kirmanckî; dimilkî; kirdkî; zazakî",
|
||||
)
|
||||
}
|
|
@ -0,0 +1,234 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
||||
|
||||
import eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin.EpisodeType
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.animesource.model.SEpisode
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
data class LoginDto(
|
||||
@SerialName("AccessToken") val accessToken: String,
|
||||
@SerialName("SessionInfo") val sessionInfo: LoginSessionDto,
|
||||
) {
|
||||
@Serializable
|
||||
data class LoginSessionDto(
|
||||
@SerialName("UserId") val userId: String,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ItemsDto(
|
||||
@SerialName("Items") val items: List<ItemDto>,
|
||||
@SerialName("TotalRecordCount") val itemCount: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItemDto(
|
||||
@SerialName("Name") val name: String,
|
||||
@SerialName("Type") val type: String,
|
||||
@SerialName("Id") val id: String,
|
||||
@SerialName("LocationType") val locationType: String,
|
||||
@SerialName("ImageTags") val imageTags: ImageDto,
|
||||
@SerialName("SeriesId") val seriesId: String? = null,
|
||||
@SerialName("SeriesName") val seriesName: String? = null,
|
||||
|
||||
// Details
|
||||
@SerialName("Overview") val overview: String? = null,
|
||||
@SerialName("Genres") val genres: List<String>? = null,
|
||||
@SerialName("Studios") val studios: List<StudioDto>? = null,
|
||||
|
||||
// Only for series, not season
|
||||
@SerialName("Status") val seriesStatus: String? = null,
|
||||
@SerialName("SeasonName") val seasonName: String? = null,
|
||||
|
||||
// Episode
|
||||
@SerialName("PremiereDate") val premiereData: String? = null,
|
||||
@SerialName("RunTimeTicks") val runTime: Long? = null,
|
||||
@SerialName("MediaSources") val mediaSources: List<MediaDto>? = null,
|
||||
@SerialName("IndexNumber") val indexNumber: Int? = null,
|
||||
) {
|
||||
@Serializable
|
||||
data class ImageDto(
|
||||
@SerialName("Primary") val primary: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StudioDto(
|
||||
@SerialName("Name") val name: String,
|
||||
)
|
||||
|
||||
fun toSAnime(baseUrl: String, userId: String, apiKey: String): SAnime = SAnime.create().apply {
|
||||
val httpUrl = baseUrl.toHttpUrl().newBuilder().apply {
|
||||
addPathSegment("Users")
|
||||
addPathSegment(userId)
|
||||
addPathSegment("Items")
|
||||
addPathSegment(id)
|
||||
addQueryParameter("api_key", apiKey)
|
||||
}
|
||||
|
||||
thumbnail_url = "$baseUrl/Items/$id/Images/Primary?api_key=$apiKey"
|
||||
|
||||
when (type) {
|
||||
"Season" -> {
|
||||
// To prevent one extra GET request when fetching episodes
|
||||
httpUrl.fragment("seriesId,${seriesId!!}")
|
||||
|
||||
if (locationType == "Virtual") {
|
||||
title = seriesName!!
|
||||
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
|
||||
} else {
|
||||
title = "$seriesName $name"
|
||||
}
|
||||
|
||||
// Use series as fallback
|
||||
if (imageTags.primary == null) {
|
||||
thumbnail_url = "$baseUrl/Items/$seriesId/Images/Primary?api_key=$apiKey"
|
||||
}
|
||||
}
|
||||
"Movie" -> {
|
||||
httpUrl.fragment("movie")
|
||||
title = name
|
||||
}
|
||||
"BoxSet" -> {
|
||||
httpUrl.fragment("boxSet")
|
||||
title = name
|
||||
}
|
||||
"Series" -> {
|
||||
httpUrl.fragment("series")
|
||||
title = name
|
||||
}
|
||||
}
|
||||
|
||||
url = httpUrl.build().toString()
|
||||
|
||||
// Details
|
||||
description = overview?.let {
|
||||
Jsoup.parseBodyFragment(
|
||||
it.replace("<br>", "br2n"),
|
||||
).text().replace("br2n", "\n")
|
||||
}
|
||||
genre = genres?.joinToString(", ")
|
||||
author = studios?.joinToString(", ") { it.name }
|
||||
|
||||
if (type == "Movie") {
|
||||
status = SAnime.COMPLETED
|
||||
} else {
|
||||
status = seriesStatus.parseStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.parseStatus(): Int = when (this) {
|
||||
"Ended" -> SAnime.COMPLETED
|
||||
"Continuing" -> SAnime.ONGOING
|
||||
else -> SAnime.UNKNOWN
|
||||
}
|
||||
|
||||
// ============================== Episodes ==============================
|
||||
|
||||
fun toSEpisode(
|
||||
baseUrl: String,
|
||||
userId: String,
|
||||
apiKey: String,
|
||||
epDetails: Set<String>,
|
||||
epType: EpisodeType,
|
||||
prefix: String,
|
||||
): SEpisode = SEpisode.create().apply {
|
||||
when (epType) {
|
||||
EpisodeType.MOVIE -> {
|
||||
episode_number = 1F
|
||||
name = "${prefix}Movie"
|
||||
}
|
||||
EpisodeType.EPISODE -> {
|
||||
episode_number = indexNumber?.toFloat() ?: 1F
|
||||
name = "${prefix}Ep. $indexNumber - ${this@ItemDto.name}"
|
||||
}
|
||||
}
|
||||
|
||||
val extraInfo = buildList {
|
||||
if (epDetails.contains("Overview") && overview != null && epType == EpisodeType.EPISODE) {
|
||||
add(overview)
|
||||
}
|
||||
|
||||
if (epDetails.contains("Size") && mediaSources != null) {
|
||||
mediaSources.first().size?.also {
|
||||
add(it.formatBytes())
|
||||
}
|
||||
}
|
||||
|
||||
if (epDetails.contains("Runtime") && runTime != null) {
|
||||
add(runTime.formatTicks())
|
||||
}
|
||||
}
|
||||
|
||||
scanlator = extraInfo.joinToString(" • ")
|
||||
premiereData?.also {
|
||||
date_upload = parseDate(it.removeSuffix("Z"))
|
||||
}
|
||||
url = "$baseUrl/Users/$userId/Items/$id?api_key=$apiKey"
|
||||
}
|
||||
|
||||
private fun Long.formatBytes(): String = when {
|
||||
this >= 1_000_000_000 -> "%.2f GB".format(this / 1_000_000_000.0)
|
||||
this >= 1_000_000 -> "%.2f MB".format(this / 1_000_000.0)
|
||||
this >= 1_000 -> "%.2f KB".format(this / 1_000.0)
|
||||
this > 1 -> "$this bytes"
|
||||
this == 1L -> "$this byte"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun Long.formatTicks(): String {
|
||||
val seconds = this / 10_000_000
|
||||
val minutes = seconds / 60
|
||||
val hours = minutes / 60
|
||||
|
||||
val remainingSeconds = seconds % 60
|
||||
val remainingMinutes = minutes % 60
|
||||
|
||||
val formattedHours = if (hours > 0) "${hours}h " else ""
|
||||
val formattedMinutes = if (remainingMinutes > 0) "${remainingMinutes}m " else ""
|
||||
val formattedSeconds = "${remainingSeconds}s"
|
||||
|
||||
return "$formattedHours$formattedMinutes$formattedSeconds".trim()
|
||||
}
|
||||
|
||||
private fun parseDate(dateStr: String): Long {
|
||||
return runCatching { DATE_FORMATTER.parse(dateStr)?.time }
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMATTER by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS", Locale.ENGLISH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SessionDto(
|
||||
@SerialName("MediaSources") val mediaSources: List<MediaDto>,
|
||||
@SerialName("PlaySessionId") val playSessionId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaDto(
|
||||
@SerialName("Size") val size: Long? = null,
|
||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStreamDto>,
|
||||
) {
|
||||
@Serializable
|
||||
data class MediaStreamDto(
|
||||
@SerialName("Codec") val codec: String,
|
||||
@SerialName("Index") val index: Int,
|
||||
@SerialName("Type") val type: String,
|
||||
@SerialName("SupportsExternalStream") val supportsExternalStream: Boolean,
|
||||
@SerialName("IsExternal") val isExternal: Boolean,
|
||||
@SerialName("Language") val lang: String? = null,
|
||||
@SerialName("DisplayTitle") val displayTitle: String? = null,
|
||||
@SerialName("Height") val height: Int? = null,
|
||||
@SerialName("Width") val width: Int? = null,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.jellyfin
|
||||
|
||||
import eu.kanade.tachiyomi.animesource.AnimeSource
|
||||
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
|
||||
|
||||
class JellyfinFactory : AnimeSourceFactory {
|
||||
override fun createSources(): List<AnimeSource> {
|
||||
val firstJelly = Jellyfin("1")
|
||||
val extraCount = firstJelly.preferences.getString(Jellyfin.EXTRA_SOURCES_COUNT_KEY, Jellyfin.EXTRA_SOURCES_COUNT_DEFAULT)!!.toInt()
|
||||
|
||||
return buildList(extraCount) {
|
||||
add(firstJelly)
|
||||
for (i in 2..extraCount) {
|
||||
add(Jellyfin("$i"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
src/all/lmanime/build.gradle
Normal file
|
@ -0,0 +1,15 @@
|
|||
ext {
|
||||
extName = 'LMAnime'
|
||||
extClass = '.LMAnime'
|
||||
themePkg = 'animestream'
|
||||
baseUrl = 'https://lmanime.com'
|
||||
overrideVersionCode = 6
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:dailymotion-extractor"))
|
||||
implementation(project(":lib:mp4upload-extractor"))
|
||||
implementation(project(":lib:streamwish-extractor"))
|
||||
}
|
BIN
src/all/lmanime/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
src/all/lmanime/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
src/all/lmanime/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 5 KiB |
BIN
src/all/lmanime/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 9.2 KiB |
BIN
src/all/lmanime/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 13 KiB |
|
@ -0,0 +1,117 @@
|
|||
package eu.kanade.tachiyomi.animeextension.all.lmanime
|
||||
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.lib.dailymotionextractor.DailymotionExtractor
|
||||
import eu.kanade.tachiyomi.lib.mp4uploadextractor.Mp4uploadExtractor
|
||||
import eu.kanade.tachiyomi.lib.streamwishextractor.StreamWishExtractor
|
||||
import eu.kanade.tachiyomi.multisrc.animestream.AnimeStream
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking
|
||||
import okhttp3.Response
|
||||
|
||||
class LMAnime : AnimeStream(
|
||||
"all",
|
||||
"LMAnime",
|
||||
"https://lmanime.com",
|
||||
) {
|
||||
// ============================ Video Links =============================
|
||||
override val prefQualityValues = arrayOf("144p", "288p", "480p", "720p", "1080p")
|
||||
override val prefQualityEntries = prefQualityValues
|
||||
|
||||
override fun videoListParse(response: Response): List<Video> {
|
||||
val items = response.asJsoup().select(videoListSelector())
|
||||
val allowed = preferences.getStringSet(PREF_ALLOWED_LANGS_KEY, PREF_ALLOWED_LANGS_DEFAULT)!!
|
||||
return items
|
||||
.filter { element ->
|
||||
val text = element.text()
|
||||
allowed.any { it in text }
|
||||
}.parallelCatchingFlatMapBlocking {
|
||||
val language = it.text().substringBefore(" ")
|
||||
val url = getHosterUrl(it)
|
||||
getVideoList(url, language)
|
||||
}
|
||||
}
|
||||
|
||||
private val streamwishExtractor by lazy { StreamWishExtractor(client, headers) }
|
||||
private val dailyExtractor by lazy { DailymotionExtractor(client, headers) }
|
||||
private val mp4uploadExtractor by lazy { Mp4uploadExtractor(client) }
|
||||
|
||||
override fun getVideoList(url: String, name: String): List<Video> {
|
||||
val prefix = "($name) - "
|
||||
return when {
|
||||
"dailymotion" in url -> dailyExtractor.videosFromUrl(url, "Dailymotion ($name)")
|
||||
"mp4upload" in url -> mp4uploadExtractor.videosFromUrl(url, headers, "$prefix")
|
||||
"filelions" in url -> streamwishExtractor.videosFromUrl(url, "StreamWish ($name)")
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Settings ==============================
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
super.setupPreferenceScreen(screen) // Quality preferences
|
||||
|
||||
ListPreference(screen.context).apply {
|
||||
key = PREF_LANG_KEY
|
||||
title = PREF_LANG_TITLE
|
||||
entries = PREF_LANG_ENTRIES
|
||||
entryValues = PREF_LANG_ENTRIES
|
||||
setDefaultValue(PREF_LANG_DEFAULT)
|
||||
summary = "%s"
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
val selected = newValue as String
|
||||
val index = findIndexOfValue(selected)
|
||||
val entry = entryValues[index] as String
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
MultiSelectListPreference(screen.context).apply {
|
||||
key = PREF_ALLOWED_LANGS_KEY
|
||||
title = PREF_ALLOWED_LANGS_TITLE
|
||||
entries = PREF_ALLOWED_LANGS_ENTRIES
|
||||
entryValues = PREF_ALLOWED_LANGS_ENTRIES
|
||||
setDefaultValue(PREF_ALLOWED_LANGS_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString(prefQualityKey, prefQualityDefault)!!
|
||||
val lang = preferences.getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
|
||||
return sortedWith(
|
||||
compareBy(
|
||||
{ it.quality.contains(quality) },
|
||||
{ it.quality.contains(lang, true) },
|
||||
),
|
||||
).reversed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_LANG_KEY = "pref_language"
|
||||
private const val PREF_LANG_TITLE = "Preferred language"
|
||||
private const val PREF_LANG_DEFAULT = "English"
|
||||
private val PREF_LANG_ENTRIES = arrayOf(
|
||||
"English",
|
||||
"Español",
|
||||
"Indonesian",
|
||||
"Portugués",
|
||||
"Türkçe",
|
||||
"العَرَبِيَّة",
|
||||
"ไทย",
|
||||
)
|
||||
|
||||
private const val PREF_ALLOWED_LANGS_KEY = "pref_allowed_languages"
|
||||
private const val PREF_ALLOWED_LANGS_TITLE = "Allowed languages to fetch videos"
|
||||
private val PREF_ALLOWED_LANGS_ENTRIES = PREF_LANG_ENTRIES
|
||||
private val PREF_ALLOWED_LANGS_DEFAULT = PREF_ALLOWED_LANGS_ENTRIES.toSet()
|
||||
}
|
||||
}
|
14
src/all/missav/build.gradle
Normal file
|
@ -0,0 +1,14 @@
|
|||
ext {
|
||||
extName = 'MissAV'
|
||||
extClass = '.MissAV'
|
||||
extVersionCode = 11
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
||||
|
||||
dependencies {
|
||||
implementation(project(':lib:unpacker'))
|
||||
implementation(project(':lib:playlist-utils'))
|
||||
implementation(project(':lib:javcoverfetcher'))
|
||||
}
|
BIN
src/all/missav/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.4 KiB |