diff --git a/src/en/holamovies/AndroidManifest.xml b/src/en/holamovies/AndroidManifest.xml new file mode 100644 index 000000000..568741e54 --- /dev/null +++ b/src/en/holamovies/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/en/holamovies/build.gradle b/src/en/holamovies/build.gradle new file mode 100644 index 000000000..aab09a7f5 --- /dev/null +++ b/src/en/holamovies/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'HolaMovies' + pkgNameSuffix = 'en.holamovies' + extClass = '.HolaMovies' + extVersionCode = 1 + libVersion = '13' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/holamovies/res/mipmap-hdpi/ic_launcher.png b/src/en/holamovies/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..d623e2cfe Binary files /dev/null and b/src/en/holamovies/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/holamovies/res/mipmap-mdpi/ic_launcher.png b/src/en/holamovies/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..e1aad6686 Binary files /dev/null and b/src/en/holamovies/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/holamovies/res/mipmap-xhdpi/ic_launcher.png b/src/en/holamovies/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f8641aaff Binary files /dev/null and b/src/en/holamovies/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/holamovies/res/mipmap-xxhdpi/ic_launcher.png b/src/en/holamovies/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..0a287baf1 Binary files /dev/null and b/src/en/holamovies/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/holamovies/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/holamovies/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..f575d8818 Binary files /dev/null and b/src/en/holamovies/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/holamovies/res/web_hi_res_512.png b/src/en/holamovies/res/web_hi_res_512.png new file mode 100644 index 000000000..a8c1fdb5c Binary files /dev/null and b/src/en/holamovies/res/web_hi_res_512.png differ diff --git a/src/en/holamovies/src/eu/kanade/tachiyomi/animeextension/en/holamovies/HolaMovies.kt b/src/en/holamovies/src/eu/kanade/tachiyomi/animeextension/en/holamovies/HolaMovies.kt new file mode 100644 index 000000000..537a72211 --- /dev/null +++ b/src/en/holamovies/src/eu/kanade/tachiyomi/animeextension/en/holamovies/HolaMovies.kt @@ -0,0 +1,351 @@ +package eu.kanade.tachiyomi.animeextension.en.holamovies + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.animeextension.en.holamovies.extractors.GDBotExtractor +import eu.kanade.tachiyomi.animeextension.en.holamovies.extractors.GDFlixExtractor +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.network.asObservableSuccess +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +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 uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale + +class HolaMovies : ConfigurableAnimeSource, ParsedAnimeHttpSource() { + + override val name = "HolaMovies" + + override val baseUrl by lazy { preferences.getString("preferred_domain", "https://holamovies.org")!! } + + override val lang = "en" + + override val supportsLatest = false + + override val client: OkHttpClient = network.cloudflareClient + + private val json: Json by injectLazy() + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + companion object { + private val DateFormatter by lazy { + SimpleDateFormat("d MMMM yyyy", Locale.ENGLISH) + } + } + + // ============================== Popular =============================== + + override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/page/$page/") + + override fun popularAnimeSelector(): String = "div#content > div > div.row > div" + + override fun popularAnimeNextPageSelector(): String = "nav.gridlove-pagination > span.current + a" + + override fun popularAnimeFromElement(element: Element): SAnime { + return SAnime.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href").toHttpUrl().encodedPath) + thumbnail_url = element.selectFirst("img[src]")?.attr("src") ?: "" + title = element.selectFirst("h1")!!.text() + } + } + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl) + + override fun latestUpdatesSelector(): String = "div#latest-tab-pane > div.row > div.col-md-6" + + override fun latestUpdatesNextPageSelector(): String? = popularAnimeNextPageSelector() + + override fun latestUpdatesFromElement(element: Element): SAnime { + val thumbnailUrl = element.selectFirst("img")!!.attr("data-src") + + return SAnime.create().apply { + setUrlWithoutDomain(element.selectFirst("a.animeparent")!!.attr("href")) + thumbnail_url = if (thumbnailUrl.contains(baseUrl.toHttpUrl().host)) { + thumbnailUrl + } else { + baseUrl + thumbnailUrl + } + title = element.selectFirst("span.animename")!!.text() + } + } + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { +// val filterList = if (filters.isEmpty()) getFilterList() else filters +// val genreFilter = filterList.find { it is GenreFilter } as GenreFilter +// val recentFilter = filterList.find { it is RecentFilter } as RecentFilter +// val seasonFilter = filterList.find { it is SeasonFilter } as SeasonFilter + + val cleanQuery = query.replace(" ", "+") + + return when { + query.isNotBlank() -> GET("$baseUrl/page/$page/?s=$cleanQuery", headers) +// genreFilter.state != 0 -> GET("$baseUrl/genre/${genreFilter.toUriPart()}?page=$page") +// recentFilter.state != 0 -> GET("https://ajax.gogo-load.com/ajax/page-recent-release.html?page=$page&type=${recentFilter.toUriPart()}") + else -> GET("$baseUrl/popular.html?page=$page") + } + } + override fun searchAnimeSelector(): String = popularAnimeSelector() + + override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector() + + override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element) + + // ============================== FILTERS =============================== + + // Todo - add these when the site starts working again +// override fun getFilterList(): AnimeFilterList = AnimeFilterList( +// AnimeFilter.Header("Text search ignores filters"), +// GenreFilter(), +// ) + + // =========================== Anime Details ============================ + + override fun fetchAnimeDetails(anime: SAnime): Observable { + return client.newCall(animeDetailsRequest(anime)) + .asObservableSuccess() + .map { response -> + animeDetailsParse(response, anime).apply { initialized = true } + } + } + + override fun animeDetailsParse(document: Document): SAnime = throw Exception("Not used") + + private fun animeDetailsParse(response: Response, anime: SAnime): SAnime { + val document = response.asJsoup() + val oldAnime = anime + + oldAnime.description = document.selectFirst("div.entry-content > p")?.text() + + return oldAnime + } + + // ============================== Episodes ============================== + + override fun episodeListParse(response: Response): List { + val document = response.asJsoup() + val episodeList = mutableListOf() + + val sizeRegex = Regex("""[\[\(](\d+\.?\d* ?[KMGT]B)[\]\)]""") + val zipRegex = Regex("""\bZIP\b""") + + document.select("div.entry-content:has(h3,h4,p) > p:has(a[href]):not(:has(span.mb-text))").forEach { + it.select("a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + val info = it.previousElementSiblings().firstOrNull { prevTag -> + arrayOf("h3", "h4", "p").contains(prevTag.normalName()) + }?.text() + + val size = sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = link.text() + episode.episode_number = 1F + episode.date_upload = -1L + episode.url = link.attr("href") + episode.scanlator = "${if (size == null) "" else "$size • "}$info" + episodeList.add(episode) + } + } + + // We don't want to parse multiple times + if (episodeList.isEmpty()) { + document.select("div.entry-content:has(pre:contains(episode)) > p:has(a[href])").reversed().forEach { + it.select("a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + val info = it.previousElementSiblings().firstOrNull { prevTag -> + prevTag.normalName() == "p" && prevTag.selectFirst("strong") != null + }?.text() + val episodeNumber = it.previousElementSiblings().firstOrNull { prevTag -> + prevTag.normalName() == "pre" && prevTag.text().contains("episode", true) + }?.text()?.substringAfter(" ")?.toFloatOrNull() ?: 1F + + val size = sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = "Ep. $episodeNumber - ${link.text()}" + episode.episode_number = episodeNumber + episode.date_upload = -1L + episode.url = link.attr("href") + episode.scanlator = "${if (size == null) "" else "$size • "}$info" + episodeList.add(episode) + } + } + } + + if (episodeList.isEmpty()) { + document.select("div.entry-content > p:has(a[href]:has(span.mb-text)), div.entry-content > em p:has(a[href]:has(span.mb-text))").reversed().forEach { + it.select("a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + val title = it.previousElementSiblings().firstOrNull { prevTag -> + arrayOf("p", "h5").contains(prevTag.normalName()) && prevTag.text().isNotBlank() + }?.text() ?: "Item" + + val size = sizeRegex.find(title)?.groupValues?.get(1) + ?: sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = "$title - ${link.text()}" + episode.episode_number = 1F + episode.date_upload = -1L + episode.scanlator = size + episode.url = link.attr("href") + episodeList.add(episode) + } + } + } + + if (episodeList.isEmpty()) { + document.select("div.entry-content:has(pre:has(em)) > p:has(a[href])").reversed().forEach { + it.select("em a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + if (link.text().contains("click here", true)) return@forEach + val title = it.previousElementSiblings().firstOrNull { prevTag -> + prevTag.normalName() == "pre" + }?.text() + + val size = sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = "$title - ${link.text()}" + episode.episode_number = 1F + episode.date_upload = -1L + episode.scanlator = size + episode.url = link.attr("href") + episodeList.add(episode) + } + } + } + + if (episodeList.isEmpty()) { + document.select("div.entry-content:has(p:has(em)) > p:has(a[href])").reversed().forEach { + it.select("a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + val info = it.previousElementSiblings().firstOrNull { prevTag -> + prevTag.normalName() == "p" && prevTag.text().isNotBlank() && prevTag.selectFirst("a") == null + }?.text() ?: "Item" + + val size = sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = link.text() + episode.episode_number = 1F + episode.date_upload = -1L + episode.scanlator = "${if (size == null) "" else "$size • "}$info" + episode.url = link.attr("href") + episodeList.add(episode) + } + } + } + + if (episodeList.isEmpty()) { + document.select("div.entry-content > div.wp-block-buttons").reversed().forEach { + it.select("a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + val info = it.previousElementSiblings().firstOrNull { prevTag -> + prevTag.normalName() == "pre" && prevTag.text().isNotBlank() + }?.text() ?: "" + + val size = sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = link.text() + episode.episode_number = 1F + episode.date_upload = -1L + episode.scanlator = "${if (size == null) "" else "$size • "}$info" + episode.url = link.attr("href") + episodeList.add(episode) + } + } + } + + if (episodeList.isEmpty()) { + document.select("div.entry-content > figure.wp-block-embed").reversed().forEach { + it.select("a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + val size = sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = link.text() + episode.episode_number = 1F + episode.date_upload = -1L + episode.scanlator = size + episode.url = link.attr("href") + episodeList.add(episode) + } + } + } + + if (episodeList.isEmpty()) { + document.select("div.entry-content > a[role=button][href]").reversed().forEach { + it.select("a").forEach { link -> + if (zipRegex.find(link.text()) != null) return@forEach + val size = sizeRegex.find(link.text())?.groupValues?.get(1) + + val episode = SEpisode.create() + episode.name = link.text() + episode.episode_number = 1F + episode.date_upload = -1L + episode.scanlator = size + episode.url = link.attr("href") + episodeList.add(episode) + } + } + } + + return episodeList + } + + override fun episodeListSelector(): String = throw Exception("Not used") + + override fun episodeFromElement(element: Element): SEpisode = throw Exception("Not used") + + // ============================ Video Links ============================= + + override fun fetchVideoList(episode: SEpisode): Observable> { + val videoList = when { + episode.url.toHttpUrl().host.contains("gdflix") -> { + GDFlixExtractor(client, headers).videosFromUrl(episode.url) + } + episode.url.toHttpUrl().host.contains("gdtot") || + episode.url.toHttpUrl().host.contains("gdbot") -> { + GDBotExtractor(client, headers).videosFromUrl(episode.url) + } + else -> { throw Exception("Unsupported url: ${episode.url}") } + } + + return Observable.just(videoList.sort()) + } + + override fun videoFromElement(element: Element): Video = throw Exception("Not Used") + + override fun videoListSelector(): String = throw Exception("Not Used") + + override fun videoUrlParse(document: Document): String = throw Exception("Not Used") + + // ============================= Utilities ============================== + + override fun setupPreferenceScreen(screen: PreferenceScreen) {} +} diff --git a/src/en/holamovies/src/eu/kanade/tachiyomi/animeextension/en/holamovies/extractors/GDBotExtractor.kt b/src/en/holamovies/src/eu/kanade/tachiyomi/animeextension/en/holamovies/extractors/GDBotExtractor.kt new file mode 100644 index 000000000..fe6964970 --- /dev/null +++ b/src/en/holamovies/src/eu/kanade/tachiyomi/animeextension/en/holamovies/extractors/GDBotExtractor.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.animeextension.en.holamovies.extractors + +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient + +class GDBotExtractor(private val client: OkHttpClient, private val headers: Headers) { + + private val botUrl = "https://gdbot.xyz" + + fun videosFromUrl(serverUrl: String): List