diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7750a958..23f4b20d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -274,8 +274,9 @@ android:exported="true" /> - + android:exported="false" + android:foregroundServiceType="dataSync"> + @@ -297,6 +298,9 @@ android:name=".download.novel.NovelDownloaderService" android:exported="false" android:foregroundServiceType="dataSync" /> + () + + private val downloadJobs = mutableMapOf() + private val mutex = Mutex() + private var isCurrentlyProcessing = false + + override fun onBind(intent: Intent?): IBinder? { + // This is only required for bound services. + return null + } + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + builder = NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER_PROGRESS).apply { + setContentTitle("Anime Download Progress") + setSmallIcon(R.drawable.ic_round_download_24) + priority = NotificationCompat.PRIORITY_DEFAULT + setOnlyAlertOnce(true) + setProgress(0, 0, false) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + builder.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(NOTIFICATION_ID, builder.build()) + } + ContextCompat.registerReceiver( + this, + cancelReceiver, + IntentFilter(ACTION_CANCEL_DOWNLOAD), + ContextCompat.RECEIVER_EXPORTED + ) + } + + override fun onDestroy() { + super.onDestroy() + AnimeServiceDataSingleton.downloadQueue.clear() + downloadJobs.clear() + AnimeServiceDataSingleton.isServiceRunning = false + unregisterReceiver(cancelReceiver) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + snackString("Download started") + val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + serviceScope.launch { + mutex.withLock { + if (!isCurrentlyProcessing) { + isCurrentlyProcessing = true + processQueue() + isCurrentlyProcessing = false + } + } + } + return START_NOT_STICKY + } + + private fun processQueue() { + CoroutineScope(Dispatchers.Default).launch { + while (AnimeServiceDataSingleton.downloadQueue.isNotEmpty()) { + val task = AnimeServiceDataSingleton.downloadQueue.poll() + if (task != null) { + val job = launch { download(task) } + mutex.withLock { + downloadJobs[task.getTaskName()] = job + } + job.join() // Wait for the job to complete before continuing to the next task + mutex.withLock { + downloadJobs.remove(task.getTaskName()) + } + updateNotification() // Update the notification after each task is completed + } + if (AnimeServiceDataSingleton.downloadQueue.isEmpty()) { + withContext(Dispatchers.Main) { + stopSelf() // Stop the service when the queue is empty + } + } + } + } + } + + @UnstableApi + fun cancelDownload(taskName: String) { + CoroutineScope(Dispatchers.Default).launch { + mutex.withLock { + val url = AnimeServiceDataSingleton.downloadQueue.find { it.getTaskName() == taskName }?.video?.file?.url ?: "" + DownloadService.sendRemoveDownload( + this@AnimeDownloaderService, + MyDownloadService::class.java, + url, + false + ) + downloadJobs[taskName]?.cancel() + downloadJobs.remove(taskName) + AnimeServiceDataSingleton.downloadQueue.removeAll { it.getTaskName() == taskName } + updateNotification() // Update the notification after cancellation + } + } + } + + private fun updateNotification() { + // Update the notification to reflect the current state of the queue + val pendingDownloads = AnimeServiceDataSingleton.downloadQueue.size + val text = if (pendingDownloads > 0) { + "Pending downloads: $pendingDownloads" + } else { + "All downloads completed" + } + builder.setContentText(text) + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + @androidx.annotation.OptIn(UnstableApi::class) suspend fun download(task: DownloadTask) { + try { + val downloadManager = Helper.downloadManager(this@AnimeDownloaderService) + withContext(Dispatchers.Main) { + val notifi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + this@AnimeDownloaderService, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + + builder.setContentText("Downloading ${task.title} - ${task.episode}") + if (notifi) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + + broadcastDownloadStarted(task.getTaskName()) + + currActivity()?.let { + Helper.downloadVideo( + it, + task.video, + task.subtitle) + } + + saveMediaInfo(task) + downloadsManager.addDownload( + Download( + task.title, + task.episode, + Download.Type.ANIME, + ) + ) + + // periodically check if the download is complete + while (downloadManager.downloadIndex.getDownload(task.video.file.url) != null) { + val download = downloadManager.downloadIndex.getDownload(task.video.file.url) + if (download != null) { + if (download.state == androidx.media3.exoplayer.offline.Download.STATE_FAILED) { + logger("Download failed") + builder.setContentText("${task.title} - ${task.episode} Download failed") + .setProgress(0, 0, false) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${task.title} - ${task.episode} Download failed") + broadcastDownloadFailed(task.getTaskName()) + break + } + if (download.state == androidx.media3.exoplayer.offline.Download.STATE_COMPLETED) { + logger("Download completed") + builder.setContentText("${task.title} - ${task.episode} Download completed") + .setProgress(0, 0, false) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${task.title} - ${task.episode} Download completed") + getSharedPreferences(getString(R.string.anime_downloads), Context.MODE_PRIVATE).edit().putString( + task.getTaskName(), + task.video.file.url + ).apply() + broadcastDownloadFinished(task.getTaskName()) + break + } + if (download.state == androidx.media3.exoplayer.offline.Download.STATE_STOPPED) { + logger("Download stopped") + builder.setContentText("${task.title} - ${task.episode} Download stopped") + .setProgress(0, 0, false) + notificationManager.notify(NOTIFICATION_ID, builder.build()) + snackString("${task.title} - ${task.episode} Download stopped") + break + } + broadcastDownloadProgress(task.getTaskName(), download.percentDownloaded.toInt()) + builder.setProgress(100, download.percentDownloaded.toInt(), false) + if (notifi) { + notificationManager.notify(NOTIFICATION_ID, builder.build()) + } + } + kotlinx.coroutines.delay(2000) + } + } + } catch (e: Exception) { + logger("Exception while downloading file: ${e.message}") + snackString("Exception while downloading file: ${e.message}") + FirebaseCrashlytics.getInstance().recordException(e) + broadcastDownloadFailed(task.getTaskName()) + } + } + + @OptIn(DelicateCoroutinesApi::class) + private fun saveMediaInfo(task: DownloadTask) { + GlobalScope.launch(Dispatchers.IO) { + val directory = File( + getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), + "Dantotsu/Anime/${task.title}" + ) + if (!directory.exists()) directory.mkdirs() + + val file = File(directory, "media.json") + val gson = GsonBuilder() + .registerTypeAdapter(SChapter::class.java, InstanceCreator { + SChapterImpl() // Provide an instance of SChapterImpl + }) + .registerTypeAdapter(SAnime::class.java, InstanceCreator { + SAnimeImpl() // Provide an instance of SAnimeImpl + }) + .registerTypeAdapter(SEpisode::class.java, InstanceCreator { + SEpisodeImpl() // Provide an instance of SEpisodeImpl + }) + .create() + val mediaJson = gson.toJson(task.sourceMedia) + val media = gson.fromJson(mediaJson, Media::class.java) + if (media != null) { + media.cover = media.cover?.let { downloadImage(it, directory, "cover.jpg") } + media.banner = media.banner?.let { downloadImage(it, directory, "banner.jpg") } + + val jsonString = gson.toJson(media) + withContext(Dispatchers.Main) { + file.writeText(jsonString) + } + } + } + } + + + private suspend fun downloadImage(url: String, directory: File, name: String): String? = + withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + println("Downloading url $url") + try { + connection = URL(url).openConnection() as HttpURLConnection + connection.connect() + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + throw Exception("Server returned HTTP ${connection.responseCode} ${connection.responseMessage}") + } + + val file = File(directory, name) + FileOutputStream(file).use { output -> + connection.inputStream.use { input -> + input.copyTo(output) + } + } + return@withContext file.absolutePath + } catch (e: Exception) { + e.printStackTrace() + withContext(Dispatchers.Main) { + Toast.makeText( + this@AnimeDownloaderService, + "Exception while saving ${name}: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + null + } finally { + connection?.disconnect() + } + } + + private fun broadcastDownloadStarted(chapterNumber: String) { + val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_STARTED).apply { + putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, chapterNumber) + } + sendBroadcast(intent) + } + + private fun broadcastDownloadFinished(chapterNumber: String) { + val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_FINISHED).apply { + putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, chapterNumber) + } + sendBroadcast(intent) + } + + private fun broadcastDownloadFailed(chapterNumber: String) { + val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_FAILED).apply { + putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, chapterNumber) + } + sendBroadcast(intent) + } + + private fun broadcastDownloadProgress(chapterNumber: String, progress: Int) { + val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_PROGRESS).apply { + putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, chapterNumber) + putExtra("progress", progress) + } + sendBroadcast(intent) + } + + private val cancelReceiver = object : BroadcastReceiver() { + @androidx.annotation.OptIn(UnstableApi::class) override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_CANCEL_DOWNLOAD) { + val taskName = intent.getStringExtra(EXTRA_TASK_NAME) + taskName?.let { + cancelDownload(it) + } + } + } + } + + + data class DownloadTask( + val title: String, + val episode: String, + val video: Video, + val subtitle: Subtitle? = null, + val sourceMedia: Media? = null, + val retries: Int = 2, + val simultaneousDownloads: Int = 2, + ) { + fun getTaskName(): String { + return "$title - $episode" + } + } + + companion object { + private const val NOTIFICATION_ID = 1103 + const val ACTION_CANCEL_DOWNLOAD = "action_cancel_download" + const val EXTRA_TASK_NAME = "extra_task_name" + } +} + +object AnimeServiceDataSingleton { + var video: Video? = null + var sourceMedia: Media? = null + var downloadQueue: Queue = ConcurrentLinkedQueue() + + @Volatile + var isServiceRunning: Boolean = false +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/download/video/Helper.kt b/app/src/main/java/ani/dantotsu/download/video/Helper.kt index 2dcf31c6..056e96ee 100644 --- a/app/src/main/java/ani/dantotsu/download/video/Helper.kt +++ b/app/src/main/java/ani/dantotsu/download/video/Helper.kt @@ -1,8 +1,17 @@ package ani.dantotsu.download.video +import android.Manifest import android.annotation.SuppressLint +import android.app.Activity import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.OptIn +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes @@ -15,6 +24,7 @@ import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.DownloadHelper import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.offline.DownloadService @@ -22,7 +32,10 @@ import androidx.media3.exoplayer.scheduler.Requirements import androidx.media3.ui.TrackSelectionDialogBuilder import ani.dantotsu.R import ani.dantotsu.defaultHeaders +import ani.dantotsu.download.anime.AnimeDownloaderService +import ani.dantotsu.download.anime.AnimeServiceDataSingleton import ani.dantotsu.logError +import ani.dantotsu.media.Media import ani.dantotsu.okHttpClient import ani.dantotsu.parsers.Subtitle import ani.dantotsu.parsers.SubtitleType @@ -37,6 +50,7 @@ import java.util.concurrent.* object Helper { + var simpleCache: SimpleCache? = null @SuppressLint("UnsafeOptInUsageError") fun downloadVideo(context: Context, video: Video, subtitle: Subtitle?) { val dataSourceFactory = DataSource.Factory { @@ -114,13 +128,13 @@ object Helper { private var download: DownloadManager? = null - private const val DOWNLOAD_CONTENT_DIRECTORY = "downloads" + private const val DOWNLOAD_CONTENT_DIRECTORY = "Anime_Downloads" @Synchronized @UnstableApi fun downloadManager(context: Context): DownloadManager { return download ?: let { - val database = StandaloneDatabaseProvider(context) + val database = Injekt.get() val downloadDirectory = File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY) val dataSourceFactory = DataSource.Factory { //val dataSource: HttpDataSource = OkHttpDataSource.Factory(okHttpClient).createDataSource() @@ -133,17 +147,42 @@ object Helper { } dataSource } - DownloadManager( + val threadPoolSize = Runtime.getRuntime().availableProcessors() + val executorService = Executors.newFixedThreadPool(threadPoolSize) + val downloadManager = DownloadManager( context, database, - SimpleCache(downloadDirectory, NoOpCacheEvictor(), database), + getSimpleCache(context), dataSourceFactory, - Executor(Runnable::run) + executorService ).apply { requirements = Requirements(Requirements.NETWORK or Requirements.DEVICE_STORAGE_NOT_LOW) maxParallelDownloads = 3 } + downloadManager.addListener( + object : DownloadManager.Listener { // Override methods of interest here. + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception? + ) { + if (download.state == Download.STATE_COMPLETED) { + Log.e("Downloader", "Download Completed") + } else if (download.state == Download.STATE_FAILED) { + Log.e("Downloader", "Download Failed") + } else if (download.state == Download.STATE_STOPPED) { + Log.e("Downloader", "Download Stopped") + } else if (download.state == Download.STATE_QUEUED) { + Log.e("Downloader", "Download Queued") + } else if (download.state == Download.STATE_DOWNLOADING) { + Log.e("Downloader", "Download Downloading") + } + } + } + ) + + downloadManager } } @@ -159,4 +198,59 @@ object Helper { } return downloadDirectory!! } + + fun startAnimeDownloadService( + context: Context, + title: String, + episode: String, + video: Video, + subtitle: Subtitle? = null, + sourceMedia: Media? = null + ) { + if (!isNotificationPermissionGranted(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + context as Activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1 + ) + } + } + + val downloadTask = AnimeDownloaderService.DownloadTask( + title, + episode, + video, + subtitle, + sourceMedia + ) + AnimeServiceDataSingleton.downloadQueue.offer(downloadTask) + + if (!AnimeServiceDataSingleton.isServiceRunning) { + val intent = Intent(context, AnimeDownloaderService::class.java) + ContextCompat.startForegroundService(context, intent) + AnimeServiceDataSingleton.isServiceRunning = true + } + } + + @OptIn(UnstableApi::class) private fun getSimpleCache(context: Context): SimpleCache { + return if (simpleCache == null) { + val downloadDirectory = File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY) + val database = Injekt.get() + simpleCache = SimpleCache(downloadDirectory, NoOpCacheEvictor(), database) + simpleCache!! + } else { + simpleCache!! + } + } + + private fun isNotificationPermissionGranted(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + return true + } } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/download/video/MyDownloadService.kt b/app/src/main/java/ani/dantotsu/download/video/MyDownloadService.kt index 3a26ac1d..8c0d56d2 100644 --- a/app/src/main/java/ani/dantotsu/download/video/MyDownloadService.kt +++ b/app/src/main/java/ani/dantotsu/download/video/MyDownloadService.kt @@ -11,7 +11,7 @@ import androidx.media3.exoplayer.scheduler.Scheduler import ani.dantotsu.R @UnstableApi -class MyDownloadService : DownloadService(1, 1, "download_service", R.string.downloads, 0) { +class MyDownloadService : DownloadService(1, 2000, "download_service", R.string.downloads, 0) { companion object { private const val JOB_ID = 1 private const val FOREGROUND_NOTIFICATION_ID = 1 diff --git a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt index df352220..5dba0e85 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/AnimeWatchFragment.kt @@ -424,4 +424,12 @@ class AnimeWatchFragment : Fragment() { state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState() } + companion object { + const val ACTION_DOWNLOAD_STARTED = "ani.dantotsu.ACTION_DOWNLOAD_STARTED" + const val ACTION_DOWNLOAD_FINISHED = "ani.dantotsu.ACTION_DOWNLOAD_FINISHED" + const val ACTION_DOWNLOAD_FAILED = "ani.dantotsu.ACTION_DOWNLOAD_FAILED" + const val ACTION_DOWNLOAD_PROGRESS = "ani.dantotsu.ACTION_DOWNLOAD_PROGRESS" + const val EXTRA_EPISODE_NUMBER = "extra_episode_number" + } + } \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/media/anime/Episode.kt b/app/src/main/java/ani/dantotsu/media/anime/Episode.kt index 14615246..cc4ce613 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/Episode.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/Episode.kt @@ -14,7 +14,7 @@ data class Episode( var selectedExtractor: String? = null, var selectedVideo: Int = 0, var selectedSubtitle: Int? = -1, - var extractors: MutableList? = null, + @Transient var extractors: MutableList? = null, @Transient var extractorCallback: ((VideoExtractor) -> Unit)? = null, var allStreams: Boolean = false, var watched: Long? = null, diff --git a/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt b/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt index d42bcd94..c9d734f4 100644 --- a/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt +++ b/app/src/main/java/ani/dantotsu/media/anime/SelectorDialogFragment.kt @@ -20,6 +20,7 @@ import ani.dantotsu.* import ani.dantotsu.databinding.BottomSheetSelectorBinding import ani.dantotsu.databinding.ItemStreamBinding import ani.dantotsu.databinding.ItemUrlBinding +import ani.dantotsu.download.video.Helper import ani.dantotsu.media.Media import ani.dantotsu.media.MediaDetailsViewModel import ani.dantotsu.others.Download.download @@ -214,7 +215,8 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { override fun onBindViewHolder(holder: StreamViewHolder, position: Int) { val extractor = links[position] - holder.binding.streamName.text = extractor.server.name + holder.binding.streamName.text = ""//extractor.server.name + holder.binding.streamName.visibility = View.GONE holder.binding.streamRecyclerView.layoutManager = LinearLayoutManager(requireContext()) holder.binding.streamRecyclerView.adapter = VideoAdapter(extractor) @@ -256,10 +258,10 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { override fun onBindViewHolder(holder: UrlViewHolder, position: Int) { val binding = holder.binding val video = extractor.videos[position] - binding.urlQuality.text = - if (video.quality != null) "${video.quality}p" else "Default Quality" - binding.urlNote.text = video.extraNote ?: "" - binding.urlNote.visibility = if (video.extraNote != null) View.VISIBLE else View.GONE + //binding.urlQuality.text = + // if (video.quality != null) "${video.quality}p" else "Default Quality" + //binding.urlNote.text = video.extraNote ?: "" + //binding.urlNote.visibility = if (video.extraNote != null) View.VISIBLE else View.GONE binding.urlDownload.visibility = View.VISIBLE binding.urlDownload.setSafeOnClickListener { media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.selectedExtractor = @@ -267,11 +269,23 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.selectedVideo = position binding.urlDownload.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - download( - requireActivity(), - media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!, - media!!.userPreferredName - ) + //download( + // requireActivity(), + // media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!, + // media!!.userPreferredName + //) + val episode = media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!! + val video = if (extractor.videos.size > episode.selectedVideo) extractor.videos[episode.selectedVideo] else null + if (video != null) { + Helper.startAnimeDownloadService( + requireActivity(), + media!!.userPreferredName, + episode.number, + video, + null, + media + ) + } dismiss() } if (video.format == VideoType.CONTAINER) { @@ -282,11 +296,13 @@ class SelectorDialogFragment : BottomSheetDialogFragment() { "#.##" ).format(video.size ?: 0).toString() + " MB")) } else { - binding.urlQuality.text = "Multi Quality" if ((loadData("settings_download_manager") ?: 0) == 0) { - binding.urlDownload.visibility = View.GONE + ////binding.urlDownload.visibility = View.GONE } } + binding.urlNote.visibility = View.VISIBLE + binding.urlNote.text = video.format.name + binding.urlQuality.text = extractor.server.name } override fun getItemCount(): Int = extractor.videos.size diff --git a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt index 89a6087c..27aece5f 100644 --- a/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt +++ b/app/src/main/java/ani/dantotsu/parsers/AniyomiAdapter.kt @@ -26,6 +26,7 @@ import eu.kanade.tachiyomi.animesource.model.Track import eu.kanade.tachiyomi.animesource.model.Video 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 @@ -41,6 +42,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import okhttp3.Request import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -112,7 +114,8 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() { seasonGroups.keys.sorted().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) + val potentialNumber = + AnimeNameAdapter.findEpisodeNumber(episode.name) if (potentialNumber != null) { episode.episode_number = potentialNumber } else { @@ -613,6 +616,10 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() { val fileName = queryPairs.find { it.first == "file" }?.second ?: "" format = getVideoType(fileName) + if (format == null) { + val networkHelper = Injekt.get() + format = headRequest(videoUrl, networkHelper) + } } // If the format is still undetermined, log an error or handle it appropriately @@ -630,12 +637,12 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() { number, format, FileUrl(videoUrl, headersMap), - aniVideo.totalContentLength.toDouble() + if (aniVideo.totalContentLength == 0L) null else aniVideo.bytesDownloaded.toDouble() ) } private fun getVideoType(fileName: String): VideoType? { - return when { + val type = when { fileName.endsWith(".mp4", ignoreCase = true) || fileName.endsWith( ".mkv", ignoreCase = true @@ -645,6 +652,47 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() { 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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2668a0ab..6a8da5ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -645,5 +645,6 @@ Add widget This is an app widget description Airing Image + animeDownloads