feat(src/de): New source: Einfach (#2595)

This commit is contained in:
Claudemirovsky 2023-12-02 07:29:54 -03:00 committed by GitHub
parent 03bba081b5
commit 7ad0d2879e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 511 additions and 0 deletions

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".de.einfach.EinfachUrlActivity"
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="einfach.to"
android:pathPattern="/filme/..*"
android:scheme="https" />
<data
android:host="einfach.to"
android:pathPattern="/series/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,25 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
ext {
extName = 'Einfach'
pkgNameSuffix = 'de.einfach'
extClass = '.Einfach'
extVersionCode = 1
libVersion = '13'
}
dependencies {
implementation(project(":lib-dood-extractor"))
implementation(project(":lib-filemoon-extractor"))
implementation(project(":lib-mixdrop-extractor"))
implementation(project(":lib-playlist-utils"))
implementation(project(":lib-streamtape-extractor"))
implementation(project(":lib-streamwish-extractor"))
implementation(project(":lib-voe-extractor"))
implementation("dev.datlag.jsunpacker:jsunpacker:1.0.1")
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,310 @@
package eu.kanade.tachiyomi.animeextension.de.einfach
import android.app.Application
import android.util.Base64
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.animeextension.de.einfach.extractors.MyStreamExtractor
import eu.kanade.tachiyomi.animeextension.de.einfach.extractors.UnpackerExtractor
import eu.kanade.tachiyomi.animeextension.de.einfach.extractors.VidozaExtractor
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.ParsedAnimeHttpSource
import eu.kanade.tachiyomi.lib.doodextractor.DoodExtractor
import eu.kanade.tachiyomi.lib.filemoonextractor.FilemoonExtractor
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.lib.voeextractor.VoeExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
class Einfach : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Einfach"
override val baseUrl = "https://einfach.to"
override val lang = "de"
override val supportsLatest = true
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
// Actually the source doesn't provide a popular entries page, and the
// "sort by views" filter isn't working, so we'll use the latest series updates instead.
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/series/page/$page")
override fun popularAnimeSelector() = "article.box > div.bx > a.tip"
override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
setUrlWithoutDomain(element.attr("href"))
title = element.attr("title")
thumbnail_url = element.selectFirst("img")?.run {
absUrl("data-lazy-src").ifEmpty { absUrl("src") }
}
}
override fun popularAnimeNextPageSelector() = "div.pagination > a.next"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/filme/page/$page")
override fun latestUpdatesSelector() = popularAnimeSelector()
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
override fun latestUpdatesNextPageSelector() = popularAnimeNextPageSelector()
// =============================== Search ===============================
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val path = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/$path"))
.asObservableSuccess()
.map(::searchAnimeByPathParse)
} else {
super.fetchSearchAnime(page, query, filters)
}
}
private fun searchAnimeByPathParse(response: Response): AnimesPage {
val details = animeDetailsParse(response.use { it.asJsoup() })
return AnimesPage(listOf(details), false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList) =
GET("$baseUrl/page/$page/?s=$query")
override fun searchAnimeSelector() = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector() = popularAnimeNextPageSelector()
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
val info = document.selectFirst("article div > div.infl")!!
title = info.selectFirst("h1.entry-title")!!.text()
thumbnail_url = info.selectFirst("img")?.run {
absUrl("data-lazy-src").ifEmpty { absUrl("src") }
}
artist = info.getInfo("Stars:")
genre = info.getInfo("Genre:")
author = info.getInfo("Network:")
status = parseStatus(info.getInfo("Status:").orEmpty())
description = info.selectFirst("div.entry-content > p")?.ownText()
}
private fun Element.getInfo(label: String) =
selectFirst("li:has(b:contains($label)) > span.colspan")?.text()?.trim()
private fun parseStatus(status: String) = when (status) {
"Ongoing" -> SAnime.ONGOING
else -> SAnime.COMPLETED
}
// ============================== Episodes ==============================
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
if (anime.url.contains("/filme/")) {
val episode = SEpisode.create().apply {
url = anime.url
name = "Movie - ${anime.title}"
episode_number = 1F
}
return Observable.just(listOf(episode))
}
return super.fetchEpisodeList(anime)
}
override fun episodeListParse(response: Response) =
super.episodeListParse(response).reversed()
override fun episodeListSelector() = "div.epsdlist > ul > li > a"
override fun episodeFromElement(element: Element) = SEpisode.create().apply {
setUrlWithoutDomain(element.attr("href"))
val eplnum = element.selectFirst(".epl-num")?.text().orEmpty().trim()
episode_number = eplnum.substringAfterLast(" ").toFloatOrNull() ?: 1F
name = eplnum.ifBlank { "S1 EP 1" } + " - " + element.selectFirst(".epl-title")?.text().orEmpty()
date_upload = element.selectFirst(".epl-date")?.text().orEmpty().toDate()
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val doc = response.use { it.asJsoup() }
val selection = preferences.getStringSet(PREF_HOSTER_SELECTION_KEY, PREF_HOSTER_SELECTION_DEFAULT)!!
val links = doc.select(videoListSelector()).asSequence()
.filter { it.text().lowercase() in selection }
.mapNotNull { element ->
val html = element.attr("data-em").let { b64encoded ->
runCatching {
String(Base64.decode(b64encoded, Base64.DEFAULT))
}.getOrNull()
}
val url = html?.let(Jsoup::parseBodyFragment)
?.selectFirst("iframe")
?.attr("src")
?: return@mapNotNull null
val fixedUrl = url.takeIf { it.startsWith("https:") } ?: "https:$url"
element.text().lowercase() to fixedUrl
}.toList()
return links.parallelCatchingFlatMap { (name, link) ->
getVideosFromUrl(name, link)
}
}
override fun videoListSelector() = "div.lserv > ul > li > a"
private val doodExtractor by lazy { DoodExtractor(client) }
private val filemoonExtractor by lazy { FilemoonExtractor(client) }
private val lulustreamExtractor by lazy { UnpackerExtractor(client, headers) }
private val mixdropExtractor by lazy { MixDropExtractor(client) }
private val mystreamExtractor by lazy { MyStreamExtractor(client, headers) }
private val streamtapeExtractor by lazy { StreamTapeExtractor(client) }
private val streamwishExtractor by lazy { StreamWishExtractor(client, headers) }
private val vidozaExtractor by lazy { VidozaExtractor(client) }
private val voeExtractor by lazy { VoeExtractor(client) }
private fun getVideosFromUrl(name: String, url: String): List<Video> {
return when (name) {
"doodstream" -> doodExtractor.videosFromUrl(url)
"filelions" -> streamwishExtractor.videosFromUrl(url, videoNameGen = { "FileLions - $it" })
"filemoon" -> filemoonExtractor.videosFromUrl(url)
"lulustream" -> lulustreamExtractor.videosFromUrl(url, "LuLuStream")
"mixdrop" -> mixdropExtractor.videosFromUrl(url)
"streamtape" -> streamtapeExtractor.videosFromUrl(url)
"streamwish" -> streamwishExtractor.videosFromUrl(url)
"vidoza" -> vidozaExtractor.videosFromUrl(url)
"voe" -> voeExtractor.videosFromUrl(url)
"stream in hd" -> mystreamExtractor.videosFromUrl(url)
else -> emptyList()
}
}
override fun videoFromElement(element: Element): Video {
throw UnsupportedOperationException("Not used.")
}
override fun videoUrlParse(document: Document): String {
throw UnsupportedOperationException("Not used.")
}
// ============================== Settings ==============================
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)
MultiSelectListPreference(screen.context).apply {
key = PREF_HOSTER_SELECTION_KEY
title = PREF_HOSTER_SELECTION_TITLE
entries = PREF_HOSTER_SELECTION_ENTRIES
entryValues = PREF_HOSTER_SELECTION_VALUES
setDefaultValue(PREF_HOSTER_SELECTION_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
@Suppress("UNCHECKED_CAST")
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
}
}.also(screen::addPreference)
}
// ============================= Utilities ==============================
private fun String.toDate(): Long {
return runCatching { DATE_FORMATTER.parse(trim())?.time }
.getOrNull() ?: 0L
}
private inline fun <A, B> Iterable<A>.parallelCatchingFlatMap(crossinline f: suspend (A) -> Iterable<B>): List<B> =
runBlocking {
map {
async(Dispatchers.Default) {
runCatching { f(it) }.getOrElse { emptyList() }
}
}.awaitAll().flatten()
}
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()
}
companion object {
const val PREFIX_SEARCH = "path:"
private val DATE_FORMATTER by lazy {
SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)
}
private const val PREF_QUALITY_KEY = "pref_quality_key"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_ENTRIES = arrayOf("240p", "360p", "480p", "720p", "1080p")
private val PREF_QUALITY_VALUES = PREF_QUALITY_ENTRIES
private const val PREF_HOSTER_SELECTION_KEY = "pref_hoster_selection"
private const val PREF_HOSTER_SELECTION_TITLE = "Enable/Disable video hosters"
private val PREF_HOSTER_SELECTION_ENTRIES = arrayOf(
"DoodStream",
"FileLions",
"Filemoon",
"LuLuStream",
"MixDrop",
"Streamtape",
"StreamWish",
"Vidoza",
"VOE",
"Stream in HD",
)
private val PREF_HOSTER_SELECTION_VALUES by lazy { PREF_HOSTER_SELECTION_ENTRIES.map(String::lowercase).toTypedArray() }
private val PREF_HOSTER_SELECTION_DEFAULT by lazy { PREF_HOSTER_SELECTION_VALUES.toSet() }
}
}

View File

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.animeextension.de.einfach
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://einfach.to/<type>/<item> intents
* and redirects them to the main Aniyomi process.
*/
class EinfachUrlActivity : Activity() {
private val tag = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 2) {
val type = pathSegments[1]
val item = pathSegments[2]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${Einfach.PREFIX_SEARCH}$type/$item")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(tag, e.toString())
}
} else {
Log.e(tag, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -0,0 +1,50 @@
package eu.kanade.tachiyomi.animeextension.de.einfach.extractors
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
// From animeworldindia
class MyStreamExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
fun videosFromUrl(url: String): List<Video> {
val host = url.substringBefore("/watch?")
return runCatching {
val response = client.newCall(GET(url, headers)).execute()
val body = response.use { it.body.string() }
val codePart = body
.substringAfter("sniff(") // Video function
.substringBefore(",[")
val streamCode = codePart
.substringBeforeLast("\",\"")
.substringAfterLast(",\"") // our beloved hash
val id = codePart.substringAfter(",\"").substringBefore('"') // required ID
val streamUrl = "$host/m3u8/$id/$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 = { "MyStream: $it" },
)
}.getOrElse { emptyList<Video>() }
}
}

View File

@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.animeextension.de.einfach.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.OkHttpClient
class UnpackerExtractor(private val client: OkHttpClient, private val headers: Headers) {
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
fun videosFromUrl(url: String, hoster: String): List<Video> {
val doc = client.newCall(GET(url, headers)).execute()
.use { it.asJsoup() }
val script = doc.selectFirst("script:containsData(eval)")
?.data()
?.let(JsUnpacker::unpackAndCombine)
?: return emptyList()
val playlistUrl = script.substringAfter("file:\"").substringBefore('"')
return playlistUtils.extractFromHls(
playlistUrl,
referer = playlistUrl,
videoNameGen = { "$hoster - $it" },
)
}
}

View File

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.animeextension.de.einfach.extractors
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.OkHttpClient
class VidozaExtractor(private val client: OkHttpClient) {
fun videosFromUrl(url: String): List<Video> {
val doc = client.newCall(GET(url)).execute()
.use { it.asJsoup() }
val script = doc.selectFirst("script:containsData(sourcesCode: [)")
?.data()
?: return emptyList()
return script.substringAfter("sourcesCode: [").substringBefore("],")
.split('{')
.drop(1)
.mapNotNull {
val videoUrl = it.substringAfter("src: \"").substringBefore('"')
val resolution = it.substringAfter("res:\"").substringBefore('"') + "p"
Video(videoUrl, "Vidoza - $resolution", videoUrl)
}
}
}