package ani.dantotsu.download.anime import android.Manifest import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.documentfile.provider.DocumentFile import androidx.media3.common.util.UnstableApi import ani.dantotsu.FileUrl import ani.dantotsu.R import ani.dantotsu.connections.crashlytics.CrashlyticsInterface import ani.dantotsu.download.DownloadedType import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager.Companion.getSubDirectory import ani.dantotsu.download.anime.AnimeDownloaderService.AnimeDownloadTask.Companion.getTaskName import ani.dantotsu.media.Media import ani.dantotsu.media.MediaType import ani.dantotsu.media.SubtitleDownloader import ani.dantotsu.media.anime.AnimeWatchFragment import ani.dantotsu.parsers.Subtitle import ani.dantotsu.parsers.Video import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.snackString import ani.dantotsu.util.Logger import com.anggrayudi.storage.file.forceDelete import com.anggrayudi.storage.file.openOutputStream import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.FFprobeKit import com.arthenica.ffmpegkit.SessionState import com.google.gson.GsonBuilder import com.google.gson.InstanceCreator import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnimeImpl import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisodeImpl import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapterImpl import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.net.HttpURLConnection import java.net.URL import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue class AnimeDownloaderService : Service() { private lateinit var notificationManager: NotificationManagerCompat private lateinit var builder: NotificationCompat.Builder private val downloadsManager: DownloadsManager = Injekt.get() private val downloadJobs = mutableMapOf() private val mutex = Mutex() private var isCurrentlyProcessing = false private var currentTasks: MutableList = mutableListOf() 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_download_24) priority = NotificationCompat.PRIORITY_DEFAULT setOnlyAlertOnce(true) setProgress(100, 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) } currentTasks.add(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) { val sessionIds = AnimeServiceDataSingleton.downloadQueue.filter { it.getTaskName() == taskName } .map { it.sessionId }.toMutableList() sessionIds.addAll(currentTasks.filter { it.getTaskName() == taskName }.map { it.sessionId }) sessionIds.forEach { FFmpegKit.cancel(it) } currentTasks.removeAll { it.getTaskName() == taskName } CoroutineScope(Dispatchers.Default).launch { mutex.withLock { 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: AnimeDownloadTask) { 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 ${getTaskName(task.title, task.episode)}") if (notifi) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } val outputDir = getSubDirectory( this@AnimeDownloaderService, MediaType.ANIME, false, task.title, task.episode ) ?: throw Exception("Failed to create output directory") outputDir.findFile("${task.getTaskName()}.mp4")?.delete() val outputFile = outputDir.createFile("video/mp4", "${task.getTaskName()}.mp4") ?: throw Exception("Failed to create output file") var percent = 0 var totalLength = 0.0 val path = FFmpegKitConfig.getSafParameterForWrite( this@AnimeDownloaderService, outputFile.uri ) val headersStringBuilder = StringBuilder().append(" ") task.video.file.headers.forEach { headersStringBuilder.append("\"${it.key}: ${it.value}\"\'\r\n\'") } headersStringBuilder.append(" ") FFprobeKit.executeAsync( "-headers $headersStringBuilder -i ${task.video.file.url} -show_entries format=duration -v quiet -of csv=\"p=0\"", { Logger.log("FFprobeKit: $it") }, { if (it.message.toDoubleOrNull() != null) { totalLength = it.message.toDouble() } }) var request = "-headers" val headers = headersStringBuilder.toString() if (task.video.file.headers.isNotEmpty()) { request += headers } request += "-i ${task.video.file.url} -c copy -bsf:a aac_adtstoasc -tls_verify 0 $path -v trace" println("Request: $request") val ffTask = FFmpegKit.executeAsync(request, { session -> val state: SessionState = session.state val returnCode = session.returnCode // CALLED WHEN SESSION IS EXECUTED Logger.log( java.lang.String.format( "FFmpeg process exited with state %s and rc %s.%s", state, returnCode, session.failStackTrace ) ) }, { // CALLED WHEN SESSION PRINTS LOGS Logger.log(it.message) }) { // CALLED WHEN SESSION GENERATES STATISTICS val timeInMilliseconds = it.time if (timeInMilliseconds > 0 && totalLength > 0) { percent = ((it.time / 1000) / totalLength * 100).toInt() } Logger.log("Statistics: $it") } task.sessionId = ffTask.sessionId currentTasks.find { it.getTaskName() == task.getTaskName() }?.sessionId = ffTask.sessionId saveMediaInfo(task) task.subtitle?.let { SubtitleDownloader.downloadSubtitle( this@AnimeDownloaderService, it.file.url, DownloadedType( task.title, task.episode, MediaType.ANIME, ) ) } // periodically check if the download is complete while (ffTask.state != SessionState.COMPLETED) { if (ffTask.state == SessionState.FAILED) { Logger.log("Download failed") builder.setContentText( "${ getTaskName( task.title, task.episode ) } Download failed" ) notificationManager.notify(NOTIFICATION_ID, builder.build()) snackString("${getTaskName(task.title, task.episode)} Download failed") Logger.log("Download failed: ${ffTask.failStackTrace}") downloadsManager.removeDownload( DownloadedType( task.title, task.episode, MediaType.ANIME, ) ) {} Injekt.get().logException( Exception( "Anime Download failed:" + " ${getTaskName(task.title, task.episode)}" + " url: ${task.video.file.url}" + " title: ${task.title}" + " episode: ${task.episode}" ) ) currentTasks.removeAll { it.getTaskName() == task.getTaskName() } broadcastDownloadFailed(task.episode) break } builder.setProgress( 100, percent.coerceAtMost(99), false ) broadcastDownloadProgress( task.episode, percent.coerceAtMost(99) ) if (notifi) { notificationManager.notify(NOTIFICATION_ID, builder.build()) } kotlinx.coroutines.delay(2000) } if (ffTask.state == SessionState.COMPLETED) { if (ffTask.returnCode.isValueError) { Logger.log("Download failed") builder.setContentText( "${ getTaskName( task.title, task.episode ) } Download failed" ) notificationManager.notify(NOTIFICATION_ID, builder.build()) snackString("${getTaskName(task.title, task.episode)} Download failed") downloadsManager.removeDownload( DownloadedType( task.title, task.episode, MediaType.ANIME, ) ) {} Injekt.get().logException( Exception( "Anime Download failed:" + " ${getTaskName(task.title, task.episode)}" + " url: ${task.video.file.url}" + " title: ${task.title}" + " episode: ${task.episode}" ) ) currentTasks.removeAll { it.getTaskName() == task.getTaskName() } broadcastDownloadFailed(task.episode) return@withContext } Logger.log("Download completed") builder.setContentText( "${ getTaskName( task.title, task.episode ) } Download completed" ) notificationManager.notify(NOTIFICATION_ID, builder.build()) snackString("${getTaskName(task.title, task.episode)} Download completed") PrefManager.getAnimeDownloadPreferences().edit().putString( task.getTaskName(), task.video.file.url ).apply() downloadsManager.addDownload( DownloadedType( task.title, task.episode, MediaType.ANIME, ) ) currentTasks.removeAll { it.getTaskName() == task.getTaskName() } broadcastDownloadFinished(task.episode) } else throw Exception("Download failed") } } catch (e: Exception) { if (e.message?.contains("Coroutine was cancelled") == false) { //wut Logger.log("Exception while downloading file: ${e.message}") snackString("Exception while downloading file: ${e.message}") e.printStackTrace() Injekt.get().logException(e) } broadcastDownloadFailed(task.episode) } } private fun saveMediaInfo(task: AnimeDownloadTask) { CoroutineScope(Dispatchers.IO).launch { val directory = getSubDirectory(this@AnimeDownloaderService, MediaType.ANIME, false, task.title) ?: throw Exception("Directory not found") directory.findFile("media.json")?.forceDelete(this@AnimeDownloaderService) val file = directory.createFile("application/json", "media.json") ?: throw Exception("File not created") val episodeDirectory = getSubDirectory( this@AnimeDownloaderService, MediaType.ANIME, false, task.title, task.episode ) ?: throw Exception("Directory not found") 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") } if (task.episodeImage != null) { media.anime?.episodes?.get(task.episode)?.let { episode -> episode.thumb = downloadImage( task.episodeImage, episodeDirectory, "episodeImage.jpg" )?.let { FileUrl( it ) } } downloadImage(task.episodeImage, episodeDirectory, "episodeImage.jpg") } val jsonString = gson.toJson(media) withContext(Dispatchers.Main) { try { file.openOutputStream(this@AnimeDownloaderService, false).use { output -> if (output == null) throw Exception("Output stream is null") output.write(jsonString.toByteArray()) } } catch (e: android.system.ErrnoException) { e.printStackTrace() Toast.makeText( this@AnimeDownloaderService, "Error while saving: ${e.localizedMessage}", Toast.LENGTH_LONG ).show() } } } } } private suspend fun downloadImage(url: String, directory: DocumentFile, 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}") } directory.findFile(name)?.forceDelete(this@AnimeDownloaderService) val file = directory.createFile("image/jpeg", name) ?: throw Exception("File not created") file.openOutputStream(this@AnimeDownloaderService, false).use { output -> if (output == null) throw Exception("Output stream is null") connection.inputStream.use { input -> input.copyTo(output) } } return@withContext file.uri.toString() } 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(episodeNumber: String) { val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_STARTED).apply { putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, episodeNumber) } sendBroadcast(intent) } private fun broadcastDownloadFinished(episodeNumber: String) { val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_FINISHED).apply { putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, episodeNumber) } sendBroadcast(intent) } private fun broadcastDownloadFailed(episodeNumber: String) { val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_FAILED).apply { putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, episodeNumber) } sendBroadcast(intent) } private fun broadcastDownloadProgress(episodeNumber: String, progress: Int) { val intent = Intent(AnimeWatchFragment.ACTION_DOWNLOAD_PROGRESS).apply { putExtra(AnimeWatchFragment.EXTRA_EPISODE_NUMBER, episodeNumber) 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 AnimeDownloadTask( val title: String, val episode: String, val video: Video, val subtitle: Subtitle? = null, val sourceMedia: Media? = null, val episodeImage: String? = null, val retries: Int = 2, val simultaneousDownloads: Int = 2, var sessionId: Long = -1 ) { fun getTaskName(): String { return "${title.replace("/", "")}/${episode.replace("/", "")}" } companion object { fun getTaskName(title: String, episode: String): String { return "${title.replace("/", "")}/${episode.replace("/", "")}" } } } 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 downloadQueue: Queue = ConcurrentLinkedQueue() @Volatile var isServiceRunning: Boolean = false }