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