package ani.dantotsu.parsers import android.content.ContentResolver import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import ani.dantotsu.FileUrl import ani.dantotsu.logger import ani.dantotsu.media.anime.AnimeNameAdapter import ani.dantotsu.media.manga.ImageData import ani.dantotsu.media.manga.MangaCache import ani.dantotsu.snackString import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource 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.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.manga.model.MangaExtension import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.interceptor.CloudflareBypassException import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.lang.awaitSingle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import okhttp3.Request import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File import java.io.FileOutputStream import java.io.UnsupportedEncodingException import java.net.URL import java.net.URLDecoder import java.util.regex.Pattern class AniyomiAdapter { fun aniyomiToAnimeParser(extension: AnimeExtension.Installed): DynamicAnimeParser { return DynamicAnimeParser(extension) } } class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { val extension: AnimeExtension.Installed var sourceLanguage = 0 init { this.extension = extension } override val name = extension.name override val saveName = extension.name override val hostUrl = extension.sources.first().name override val isDubAvailableSeparately = false override val isNSFW = extension.isNsfw override suspend fun loadEpisodes( animeLink: String, extra: Map?, sAnime: SAnime ): List { val source = try { extension.sources[sourceLanguage] } catch (e: Exception) { sourceLanguage = 0 extension.sources[sourceLanguage] } as? AnimeHttpSource ?: (extension.sources[sourceLanguage] as? AnimeCatalogueSource ?: return emptyList()) try { val res = source.getEpisodeList(sAnime) val sortedEpisodes = if (res[0].episode_number == -1f) { // Find the number in the string and sort by that number val sortedByStringNumber = res.sortedBy { val matchResult = AnimeNameAdapter.findEpisodeNumber(it.name) val number = matchResult ?: Float.MAX_VALUE it.episode_number = number // Store the found number in episode_number number } // If there is no number, reverse the order and give them an incrementing number var incrementingNumber = 1f sortedByStringNumber.map { if (it.episode_number == Float.MAX_VALUE) { it.episode_number = incrementingNumber++ // Update episode_number with the incrementing number } it } } else { var episodeCounter = 1f // Group by season, sort within each season, and then renumber while keeping episode number 0 as is val seasonGroups = res.groupBy { AnimeNameAdapter.findSeasonNumber(it.name) ?: 0 } seasonGroups.keys.sortedBy { it.toInt() } .flatMap { season -> seasonGroups[season]?.sortedBy { it.episode_number }?.map { episode -> if (episode.episode_number != 0f) { // Skip renumbering for episode number 0 val potentialNumber = AnimeNameAdapter.findEpisodeNumber(episode.name) if (potentialNumber != null) { episode.episode_number = potentialNumber } else { episode.episode_number = episodeCounter } episodeCounter++ } episode } ?: emptyList() } } return sortedEpisodes.map { SEpisodeToEpisode(it) } } catch (e: Exception) { logger("Exception: $e") } return emptyList() } override suspend fun loadVideoServers( episodeLink: String, extra: Map?, sEpisode: SEpisode ): List { val source = try { extension.sources[sourceLanguage] } catch (e: Exception) { sourceLanguage = 0 extension.sources[sourceLanguage] } as? AnimeHttpSource ?: (extension.sources[sourceLanguage] as? AnimeCatalogueSource ?: return emptyList()) return try { val videos = source.getVideoList(sEpisode) videos.map { VideoToVideoServer(it) } } catch (e: Exception) { logger("Exception occurred: ${e.message}") emptyList() } } override suspend fun getVideoExtractor(server: VideoServer): VideoExtractor { return VideoServerPassthrough(server) } override suspend fun search(query: String): List { val source = try { extension.sources[sourceLanguage] } catch (e: Exception) { sourceLanguage = 0 extension.sources[sourceLanguage] } as? AnimeHttpSource ?: (extension.sources[sourceLanguage] as? AnimeCatalogueSource ?: return emptyList()) return try { val res = source.fetchSearchAnime(1, query, source.getFilterList()).awaitSingle() logger("query: $query") convertAnimesPageToShowResponse(res) } catch (e: CloudflareBypassException) { logger("Exception in search: $e") withContext(Dispatchers.Main) { snackString( "Failed to bypass Cloudflare") } emptyList() } catch (e: Exception) { logger("General exception in search: $e") emptyList() } } private fun convertAnimesPageToShowResponse(animesPage: AnimesPage): List { return animesPage.animes.map { sAnime -> // Extract required fields from sAnime val name = sAnime.title val link = sAnime.url val coverUrl = sAnime.thumbnail_url ?: "" val otherNames = emptyList() // Populate as needed val total = 1 val extra: Map? = null // Populate as needed // Create a new ShowResponse ShowResponse(name, link, coverUrl, sAnime) } } private fun SEpisodeToEpisode(sEpisode: SEpisode): Episode { //if the float episode number is a whole number, convert it to an int val episodeNumberInt = if (sEpisode.episode_number % 1 == 0f) { sEpisode.episode_number.toInt() } else { sEpisode.episode_number } return Episode( if (episodeNumberInt.toInt() != -1) { if (sEpisode.episode_number % 1 == 0f) { episodeNumberInt.toInt().toString() } else { sEpisode.episode_number.toString() } } else { sEpisode.name }, sEpisode.url, sEpisode.name, null, null, false, null, sEpisode ) } private fun VideoToVideoServer(video: Video): VideoServer { return VideoServer( video.quality, video.url, null, video ) } } class DynamicMangaParser(extension: MangaExtension.Installed) : MangaParser() { val mangaCache = Injekt.get() val extension: MangaExtension.Installed var sourceLanguage = 0 init { this.extension = extension } override val name = extension.name override val saveName = extension.name override val hostUrl = extension.sources.first().name override val isNSFW = extension.isNsfw override suspend fun loadChapters( mangaLink: String, extra: Map?, sManga: SManga ): List { val source = try { extension.sources[sourceLanguage] } catch (e: Exception) { sourceLanguage = 0 extension.sources[sourceLanguage] } as? HttpSource ?: return emptyList() return try { val res = source.getChapterList(sManga) val reversedRes = res.reversed() val chapterList = reversedRes.map { SChapterToMangaChapter(it) } logger("chapterList size: ${chapterList.size}") logger("chapterList: ${chapterList[1].title}") logger("chapterList: ${chapterList[1].description}") chapterList } catch (e: Exception) { logger("loadChapters Exception: $e") emptyList() } } override suspend fun loadImages(chapterLink: String, sChapter: SChapter): List { val source = try { extension.sources[sourceLanguage] } catch (e: Exception) { sourceLanguage = 0 extension.sources[sourceLanguage] } as? HttpSource ?: return emptyList() var imageDataList: List = listOf() val ret = coroutineScope { try { logger("source.name " + source.name) val res = source.getPageList(sChapter) val reIndexedPages = res.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } val deferreds = reIndexedPages.map { page -> async(Dispatchers.IO) { mangaCache.put(page.imageUrl ?: "", ImageData(page, source)) imageDataList += ImageData(page, source) logger("put page: ${page.imageUrl}") pageToMangaImage(page) } } deferreds.awaitAll() } catch (e: Exception) { logger("loadImages Exception: $e") snackString("Failed to load images: $e") emptyList() } } return ret } suspend fun imageList(chapterLink: String, sChapter: SChapter): List { val source = try { extension.sources[sourceLanguage] } catch (e: Exception) { sourceLanguage = 0 extension.sources[sourceLanguage] } as? HttpSource ?: return emptyList() return coroutineScope { try { logger("source.name " + source.name) val res = source.getPageList(sChapter) val reIndexedPages = res.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } val semaphore = Semaphore(5) val deferreds = reIndexedPages.map { page -> async(Dispatchers.IO) { semaphore.withPermit { ImageData(page, source) } } } deferreds.awaitAll() } catch (e: Exception) { logger("loadImages Exception: $e") snackString("Failed to load images: $e") emptyList() } } } suspend fun fetchAndProcessImage( page: Page, httpSource: HttpSource, context: Context ): Bitmap? { return withContext(Dispatchers.IO) { try { // Fetch the image val response = httpSource.getImage(page) logger("Response: ${response.code}") logger("Response: ${response.message}") // Convert the Response to an InputStream val inputStream = response.body.byteStream() // Convert InputStream to Bitmap val bitmap = BitmapFactory.decodeStream(inputStream) inputStream.close() ani.dantotsu.media.manga.saveImage( bitmap, context.contentResolver, page.imageUrl!!, Bitmap.CompressFormat.JPEG, 100 ) return@withContext bitmap } catch (e: Exception) { // Handle any exceptions logger("An error occurred: ${e.message}") return@withContext null } } } fun fetchAndSaveImage(page: Page, httpSource: HttpSource, contentResolver: ContentResolver) { CoroutineScope(Dispatchers.IO).launch { try { // Fetch the image val response = httpSource.getImage(page) // Convert the Response to an InputStream val inputStream = response.body.byteStream() // Convert InputStream to Bitmap val bitmap = BitmapFactory.decodeStream(inputStream) withContext(Dispatchers.IO) { // Save the Bitmap using MediaStore API saveImage( bitmap, contentResolver, "image_${System.currentTimeMillis()}.jpg", Bitmap.CompressFormat.JPEG, 100 ) } inputStream.close() } catch (e: Exception) { // Handle any exceptions logger("An error occurred: ${e.message}") } } } fun saveImage( bitmap: Bitmap, contentResolver: ContentResolver, filename: String, format: Bitmap.CompressFormat, quality: Int ) { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, filename) put(MediaStore.MediaColumns.MIME_TYPE, "image/${format.name.lowercase()}") put( MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/Dantotsu/Anime" ) } val uri: Uri? = contentResolver.insert( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues ) uri?.let { contentResolver.openOutputStream(it)?.use { os -> bitmap.compress(format, quality, os) } } } else { val directory = File("${Environment.getExternalStorageDirectory()}${File.separator}Dantotsu${File.separator}Anime") if (!directory.exists()) { directory.mkdirs() } val file = File(directory, filename) FileOutputStream(file).use { outputStream -> bitmap.compress(format, quality, outputStream) } } } catch (e: Exception) { // Handle exception here logger("Exception while saving image: ${e.message}") } } override suspend fun search(query: String): List { val source = try { extension.sources[sourceLanguage] } catch (e: Exception) { sourceLanguage = 0 extension.sources[sourceLanguage] } as? HttpSource ?: return emptyList() return try { val res = source.fetchSearchManga(1, query, source.getFilterList()).awaitSingle() logger("res observable: $res") convertMangasPageToShowResponse(res) } catch (e: CloudflareBypassException) { logger("Exception in search: $e") withContext(Dispatchers.Main) { snackString("Failed to bypass Cloudflare") } emptyList() } catch (e: Exception) { logger("General exception in search: $e") emptyList() } } private fun convertMangasPageToShowResponse(mangasPage: MangasPage): List { return mangasPage.mangas.map { sManga -> // Extract required fields from sManga val name = sManga.title val link = sManga.url val coverUrl = sManga.thumbnail_url ?: "" val otherNames = emptyList() // Populate as needed val total = 1 val extra: Map? = null // Populate as needed // Create a new ShowResponse ShowResponse(name, link, coverUrl, sManga) } } private fun pageToMangaImage(page: Page): MangaImage { var headersMap = mapOf() var urlWithoutHeaders = "" var url = "" page.imageUrl?.let { val splitUrl = it.split("&") urlWithoutHeaders = splitUrl.getOrNull(0) ?: "" url = it headersMap = splitUrl.mapNotNull { part -> val idx = part.indexOf("=") if (idx != -1) { try { val key = URLDecoder.decode(part.substring(0, idx), "UTF-8") val value = URLDecoder.decode(part.substring(idx + 1), "UTF-8") Pair(key, value) } catch (e: UnsupportedEncodingException) { null } } else { null } }.toMap() } return MangaImage( FileUrl(url, headersMap), false, page ) } private fun SChapterToMangaChapter(sChapter: SChapter): MangaChapter { return MangaChapter( sChapter.name, sChapter.url, sChapter.name, null, sChapter.scanlator, sChapter ) } fun parseChapterTitle(title: String): Triple { val volumePattern = Pattern.compile("(?:vol\\.?|v|volume\\s?)(\\d+)", Pattern.CASE_INSENSITIVE) val chapterPattern = Pattern.compile("(?:ch\\.?|chapter\\s?)(\\d+)", Pattern.CASE_INSENSITIVE) val volumeMatcher = volumePattern.matcher(title) val chapterMatcher = chapterPattern.matcher(title) val volumeNumber = if (volumeMatcher.find()) volumeMatcher.group(1) else null val chapterNumber = if (chapterMatcher.find()) chapterMatcher.group(1) else null var remainingTitle = title if (volumeNumber != null) { remainingTitle = volumeMatcher.group(0)?.let { remainingTitle.replace(it, "") }.toString() } if (chapterNumber != null) { remainingTitle = chapterMatcher.group(0)?.let { remainingTitle.replace(it, "") }.toString() } return Triple(volumeNumber, chapterNumber, remainingTitle.trim()) } } class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() { override val server: VideoServer get() = videoServer override suspend fun extract(): VideoContainer { val vidList = listOfNotNull(videoServer.video?.let { AniVideoToSaiVideo(it) }) val subList = videoServer.video?.subtitleTracks?.map { TrackToSubtitle(it) } ?: emptyList() return if (vidList.isNotEmpty()) { VideoContainer(vidList, subList) } else { throw Exception("No videos found") } } private fun AniVideoToSaiVideo(aniVideo: Video): ani.dantotsu.parsers.Video { // Find the number value from the .quality string val number = Regex("""\d+""").find(aniVideo.quality)?.value?.toInt() ?: 0 // Check for null video URL val videoUrl = aniVideo.videoUrl ?: throw Exception("Video URL is null") val urlObj = URL(videoUrl) val path = urlObj.path val query = urlObj.query var format = getVideoType(path) if (format == null && query != null) { val queryPairs: List> = query.split("&").map { val idx = it.indexOf("=") val key = URLDecoder.decode(it.substring(0, idx), "UTF-8") val value = URLDecoder.decode(it.substring(idx + 1), "UTF-8") Pair(key, value) } // Assume the file is named under the "file" query parameter val fileName = queryPairs.find { it.first == "file" }?.second ?: "" format = getVideoType(fileName) // this solves a problem no one has, so I'm commenting it out for now //if (format == null) { // val networkHelper = Injekt.get() // format = headRequest(videoUrl, networkHelper) //} } // If the format is still undetermined, log an error if (format == null) { logger("Unknown video format: $videoUrl") //FirebaseCrashlytics.getInstance() // .recordException(Exception("Unknown video format: $videoUrl")) format = VideoType.CONTAINER } val headersMap: Map = aniVideo.headers?.toMultimap()?.mapValues { it.value.joinToString() } ?: mapOf() return Video( number, format, FileUrl(videoUrl, headersMap), if (aniVideo.totalContentLength == 0L) null else aniVideo.bytesDownloaded.toDouble() ) } private fun getVideoType(fileName: String): VideoType? { val type = when { fileName.endsWith(".mp4", ignoreCase = true) || fileName.endsWith( ".mkv", ignoreCase = true ) -> VideoType.CONTAINER fileName.endsWith(".m3u8", ignoreCase = true) -> VideoType.M3U8 fileName.endsWith(".mpd", ignoreCase = true) -> VideoType.DASH else -> null } return type } private fun headRequest(fileName: String, networkHelper: NetworkHelper): VideoType? { return try { logger("attempting head request for $fileName") val request = Request.Builder() .url(fileName) .head() .build() networkHelper.client.newCall(request).execute().use { response -> val contentType = response.header("Content-Type") val contentDisposition = response.header("Content-Disposition") if (contentType != null) { when { contentType.contains("mpegurl", ignoreCase = true) -> VideoType.M3U8 contentType.contains("dash", ignoreCase = true) -> VideoType.DASH contentType.contains("mp4", ignoreCase = true) -> VideoType.CONTAINER else -> null } } else if (contentDisposition != null) { when { contentDisposition.contains("mpegurl", ignoreCase = true) -> VideoType.M3U8 contentDisposition.contains("dash", ignoreCase = true) -> VideoType.DASH contentDisposition.contains("mp4", ignoreCase = true) -> VideoType.CONTAINER else -> null } } else { logger("failed head request for $fileName") null } } } catch (e: Exception) { logger("Exception in headRequest: $e") null } } private fun TrackToSubtitle(track: Track): Subtitle { //use Dispatchers.IO to make a HTTP request to determine the subtitle type var type: SubtitleType? = null runBlocking { type = findSubtitleType(track.url) } return Subtitle(track.lang, track.url, type ?: SubtitleType.SRT) } private fun findSubtitleType(url: String): SubtitleType { // First, try to determine the type based on the URL file extension val type: SubtitleType = when { url.endsWith(".vtt", true) -> SubtitleType.VTT url.endsWith(".ass", true) -> SubtitleType.ASS url.endsWith(".srt", true) -> SubtitleType.SRT else -> SubtitleType.UNKNOWN } return type } }