diff --git a/src/en/jpfilms/build.gradle b/src/en/jpfilms/build.gradle new file mode 100644 index 00000000..4d0d4e22 --- /dev/null +++ b/src/en/jpfilms/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'JPFilms' + extClass = '.JPFilms' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:playlist-utils")) +} diff --git a/src/en/jpfilms/res/mipmap-hdpi/ic_launcher.png b/src/en/jpfilms/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..80d4f02b Binary files /dev/null and b/src/en/jpfilms/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/jpfilms/res/mipmap-mdpi/ic_launcher.png b/src/en/jpfilms/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..4738b546 Binary files /dev/null and b/src/en/jpfilms/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/jpfilms/res/mipmap-xhdpi/ic_launcher.png b/src/en/jpfilms/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..f8eeb7f4 Binary files /dev/null and b/src/en/jpfilms/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/jpfilms/res/mipmap-xxhdpi/ic_launcher.png b/src/en/jpfilms/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..8f193b70 Binary files /dev/null and b/src/en/jpfilms/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/jpfilms/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/jpfilms/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b43b57de Binary files /dev/null and b/src/en/jpfilms/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/jpfilms/src/eu/kanade/tachiyomi/animeextension/en/jpfilms/JPFilms.kt b/src/en/jpfilms/src/eu/kanade/tachiyomi/animeextension/en/jpfilms/JPFilms.kt new file mode 100644 index 00000000..b3434690 --- /dev/null +++ b/src/en/jpfilms/src/eu/kanade/tachiyomi/animeextension/en/jpfilms/JPFilms.kt @@ -0,0 +1,406 @@ +package eu.kanade.tachiyomi.animeextension.en.jpfilms + +import android.app.Application +import android.content.SharedPreferences +import android.util.Log +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.lib.playlistutils.PlaylistUtils +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.Headers +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 + +class JPFilms : ConfigurableAnimeSource, ParsedAnimeHttpSource() { + override val name = "JPFilms" + override val baseUrl = "https://jp-films.com" + override val lang = "en" + override val supportsLatest = true + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + // ============================== Popular Anime ============================== + override fun popularAnimeSelector(): String = + "div.item" + + override fun popularAnimeRequest(page: Int): Request = GET("https://jp-films.com/wp-content/themes/halimmovies/halim-ajax.php?action=halim_get_popular_post&showpost=50&type=all") + + override fun popularAnimeFromElement(element: Element): SAnime { + val anime = SAnime.create() + anime.setUrlWithoutDomain(element.select("a").attr("href")) + anime.title = element.select("h3.title").text() + anime.thumbnail_url = element.selectFirst("img")?.attr("abs:data-src") + Log.d("JPFilmsDebug", "Thumbnail URL: ${anime.thumbnail_url}") + return anime + } + + override fun popularAnimeNextPageSelector(): String? = null + + // ============================== Latest Anime ============================== + override fun latestUpdatesSelector(): String = + "#ajax-vertical-widget-movie > div.item, " + + "#ajax-vertical-widget-tv_series > div.item" + + override fun latestUpdatesRequest(page: Int): Request = GET(baseUrl) + + override fun latestUpdatesFromElement(element: Element): SAnime { + val anime = SAnime.create() + anime.setUrlWithoutDomain(element.select("a").attr("href")) + anime.title = element.select("h3.title").text() + anime.thumbnail_url = element.select("img").attr("data-src") + Log.d("JPFilmsDebug", "Poster: ${anime.thumbnail_url}") + return anime + } + + override fun latestUpdatesNextPageSelector(): String? = null + + // ============================== Search Anime ============================== + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val searchQuery = query.replace(" ", "+") + return GET("$baseUrl/?s=$searchQuery", headers) + } + + override fun searchAnimeSelector(): String = "#main-contents > section > div.halim_box > article" + + override fun searchAnimeFromElement(element: Element): SAnime { + val anime = SAnime.create() + anime.setUrlWithoutDomain(element.select("a.halim-thumb").attr("href")) + anime.title = element.select("a.halim-thumb").attr("title") + anime.thumbnail_url = element.select("img").attr("data-src") + Log.d("JPFilmsDebug", "Poster: ${anime.thumbnail_url}") + return anime + } + + override fun searchAnimeNextPageSelector(): String? = null + + // ============================== Anime Details ============================== + override suspend fun getAnimeDetails(anime: SAnime): SAnime { + val document = client.newCall(GET(baseUrl + anime.url, headers)).execute().asJsoup() + anime.title = document.select("h1.entry-title").text() + anime.genre = document.select("p.category a").joinToString(", ") { it.text() } + anime.description = document.select("#content > div > div.entry-content.htmlwrap.clearfix > div.video-item.halim-entry-box article p").text() + anime.thumbnail_url = document.select("#content > div > div.halim-movie-wrapper.tpl-2 > div > div.movie-poster.col-md-4 > img").attr("data-src") + anime.author = "forsyth47" + return anime + } + + override fun animeDetailsParse(document: Document): SAnime = throw UnsupportedOperationException() + + // ============================== Episode List ============================== + + @Serializable + data class JsonLdData( + @SerialName("@type") val type: String? = null, + ) + + override fun episodeListSelector(): String { + throw UnsupportedOperationException("Not used because we override episodeListParse.") + } + + override fun episodeListParse(response: Response): List { + val document = response.asJsoup() + + // Extract JSON-LD data to determine if it's a Movie or TVSeries + val jsonLdScript = document.selectFirst("script[type=application/ld+json]:not(.rank-math-schema)")?.data() + Log.d("JPFilmsDebug", "JSON-LD Script: $jsonLdScript") + + val jsonLdData = json.decodeFromString(jsonLdScript ?: "{}") + Log.d("JPFilmsDebug", "JSON-LD Data: $jsonLdData") + + val isMovie = jsonLdData.type == "Movie" + Log.d("JPFilmsDebug", "Type: ${if (isMovie) "Movie" else "TVSeries"}") + + val serverAvailable = document.select("#halim-list-server > ul > li") + Log.d("JPFilmsDebug", "Server Available: $serverAvailable") + + var freeServerFound: Boolean = false + val episodeContainerSelector = run { + freeServerFound = false + var selectedContainer: String? = null + + // Iterate through each server div + for (serverDiv in serverAvailable) { + Log.d("JPFilmsDebug", "Server Div: $serverDiv") + + // Log the title of the current server div + val title = serverDiv.select("li > a").text() + Log.d("JPFilmsDebug", "Server Div Title: $title") + + // Check if the current server contains a
  • with a title containing "FREE" + val hasFreeServer = title.contains("FREE") + Log.d("JPFilmsDebug", "Has Free Server: $hasFreeServer") + + if (hasFreeServer) { + // Mark that a FREE server was found + freeServerFound = true + Log.d("JPFilmsDebug", "FREE Server Found") + + // Select this server's container + selectedContainer = "${serverDiv.select("a").attr("href")} > div > ul" + break // Exit the loop once a FREE server is found + } else if (!freeServerFound) { + // If no FREE server is found yet, select the first available server + selectedContainer = "${serverDiv.select("a").attr("href")} > div > ul" + } + } + + // Return the selected container or an empty string if none is found + selectedContainer ?: "" + } + Log.d("JPFilmsDebug", "Episode Container Selector: $episodeContainerSelector") + + // Extract all
  • elements from the selected container + val episodeElements = document.select("$episodeContainerSelector > li") + Log.d("JPFilmsDebug", "Episode Elements: $episodeElements") + + return episodeElements.map { element -> + SEpisode.create().apply { + // Get the href attribute from either the anchor tag or the span tag + var href = if (element.select("a").hasAttr("href")) { + element.select("a").attr("href") + } else { + element.select("span").attr("data-href") + } + if (!freeServerFound) { + href = "$href?svid=2" + } + setUrlWithoutDomain(href) + Log.d("JPFilmsDebug", "Episode URL: $href") + + // Determine if the episode belongs to a FREE or VIP server + val isFreeServer = element.select("a").attr("title").contains("FREE") || + element.select("span").text().contains("FREE") + val serverPrefix = if (isFreeServer) "[FREE] " else "[VIP] " + + // Use the title attribute of the anchor tag as the episode name + name = serverPrefix + ( + element.select("a").attr("title").ifEmpty { + element.select("span").text() + } + ) + Log.d("JPFilmsDebug", "Episode Name: $name") + + // Generate an episode number based on the text content + episode_number = element.text() + .filter { it.isDigit() } + .toFloatOrNull() ?: 1F + Log.d("JPFilmsDebug", "Episode Number: $episode_number") + } + }.reversed() + } + + override fun episodeFromElement(element: Element): SEpisode { + throw UnsupportedOperationException("Not used because we override episodeListParse.") + } + + // ============================== Video List ============================== + + // Define the JSON serializer + private val json = Json { ignoreUnknownKeys = true } + + override fun videoListParse(response: Response): List
  • with a title containing "FREE" + val hasFreeServer = title.contains("FREE") + Log.d("JPFilmsDebug", "Has Free Server: $hasFreeServer") + + if (hasFreeServer) { + // Mark that a FREE server was found + freeServerFound = true + Log.d("JPFilmsDebug", "FREE Server Found") + + // Select this server's container + selectedContainer = "${serverDiv.select("a").attr("href")} > div > ul" + break // Exit the loop once a FREE server is found + } else if (!freeServerFound) { + // If no FREE server is found yet, select the first available server + selectedContainer = "${serverDiv.select("a").attr("href")} > div > ul" + } + } + + // Return the selected container or an empty string if none is found + selectedContainer ?: "" + } + + val episodeElements = document.select("$episodeContainerSelector > li") + Log.d("JPFilmsDebug", "Episode Elements: $episodeElements") + + val targetEpisodeElement = episodeElements.firstOrNull { element -> + element.select("span").attr("data-episode-slug") == episodeSlug + } ?: run { + Log.e("JPFilmsDebug", "No matching episode element found for slug: $episodeSlug") + return emptyList() // Exit early if no matching element is found + } + + // Extract the server ID from the target
  • element + val serverId = targetEpisodeElement.select("span").attr("data-server").toIntOrNull() ?: 0 + + // Debugging: Log the extracted server ID + Log.d("JPFilmsDebug", "Extracted Server ID: $serverId") + + // First attempt with server_id=serverId and no subsvId + var subsvId: String? = null + val playerUrl1 = getPlayerUrl(serverId = serverId, subsvId = subsvId) + val (_, hlsUrl1) = fetchAndParsePlayerResponse(playerUrl1) + + // Retry with subsvId=2 if the first attempt fails + val hlsUrl = if (hlsUrl1.isEmpty()) { + subsvId = "2" + val playerUrl2 = getPlayerUrl(serverId = serverId, subsvId = subsvId) + val (_, hlsUrl2) = fetchAndParsePlayerResponse(playerUrl2) + hlsUrl2 + } else { + hlsUrl1 + } + + // Return the video list if the HLS URL is found, otherwise return an empty list + return if (hlsUrl.isNotEmpty()) { + PlaylistUtils(client).extractFromHls(hlsUrl, referer = baseUrl) + } else { + emptyList() + } + } + + // Data classes for JSON parsing + @Serializable + data class PlayerResponse( + val data: PlayerData? = null, + ) + + @Serializable + data class PlayerData( + val status: Boolean? = null, + val sources: String? = null, + ) + + private fun extractPostId(document: Document): String { + val bodyClass = document.select("body").attr("class") + return Regex("postid-(\\d+)").find(bodyClass)?.groupValues?.get(1) ?: "" + } + + override fun videoListSelector(): String = throw UnsupportedOperationException() + + override fun videoFromElement(element: Element): Video = throw UnsupportedOperationException() + + override fun videoUrlParse(document: Document): String = throw UnsupportedOperationException() + + // ============================== ToDo ============================== + // Plan to add option to change between original title and translated title + // Plan to add backup server too. + // ============================== Preferences ============================== + override fun setupPreferenceScreen(screen: PreferenceScreen) { + ListPreference(screen.context).apply { + key = Companion.PREF_TITLE_STYLE_KEY + title = "Preferred Title Style" + entries = arrayOf("Original", "Translated") + entryValues = arrayOf("original", "translated") + setDefaultValue("translated") + summary = "%s" + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString(key, newValue as String).commit() + } + }.also(screen::addPreference) + } + + private val SharedPreferences.titleStyle + get() = getString(Companion.PREF_TITLE_STYLE_KEY, "translated")!! + + companion object { + private const val PREF_TITLE_STYLE_KEY = "preferred_title_style" + } +}