rough outline for downloading anime

This commit is contained in:
Finnley Somdahl 2023-12-28 06:38:45 -06:00
parent 42c3b42c05
commit c9649751d2
11 changed files with 643 additions and 26 deletions

View file

@ -274,8 +274,9 @@
android:exported="true" /> android:exported="true" />
<service <service
android:name=".download.video.MyDownloadService" android:name=".download.video.MyDownloadService"
android:exported="false"> android:exported="false"
<intent-filter> android:foregroundServiceType="dataSync">
<intent-filter>
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART" /> <action android:name="androidx.media3.exoplayer.downloadService.action.RESTART" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -297,6 +298,9 @@
android:name=".download.novel.NovelDownloaderService" android:name=".download.novel.NovelDownloaderService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service android:name=".download.anime.AnimeDownloaderService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service <service
android:name=".connections.discord.DiscordService" android:name=".connections.discord.DiscordService"
android:exported="false" android:exported="false"

View file

@ -11,12 +11,14 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AnticipateInterpolator import android.view.animation.AnticipateInterpolator
import android.widget.TextView import android.widget.TextView
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.animation.doOnEnd import androidx.core.animation.doOnEnd
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -26,11 +28,13 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import ani.dantotsu.connections.anilist.Anilist import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.anilist.AnilistHomeViewModel import ani.dantotsu.connections.anilist.AnilistHomeViewModel
import ani.dantotsu.databinding.ActivityMainBinding import ani.dantotsu.databinding.ActivityMainBinding
import ani.dantotsu.databinding.SplashScreenBinding import ani.dantotsu.databinding.SplashScreenBinding
import ani.dantotsu.download.video.Helper
import ani.dantotsu.home.AnimeFragment import ani.dantotsu.home.AnimeFragment
import ani.dantotsu.home.HomeFragment import ani.dantotsu.home.HomeFragment
import ani.dantotsu.home.LoginFragment import ani.dantotsu.home.LoginFragment
@ -45,6 +49,7 @@ import ani.dantotsu.themes.ThemeManager
import io.noties.markwon.Markwon import io.noties.markwon.Markwon
import io.noties.markwon.SoftBreakAddsNewLinePlugin import io.noties.markwon.SoftBreakAddsNewLinePlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -60,7 +65,7 @@ class MainActivity : AppCompatActivity() {
private var uiSettings = UserInterfaceSettings() private var uiSettings = UserInterfaceSettings()
override fun onCreate(savedInstanceState: Bundle?) { @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) {
ThemeManager(this).applyTheme() ThemeManager(this).applyTheme()
LangSet.setLocale(this) LangSet.setLocale(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -242,6 +247,21 @@ class MainActivity : AppCompatActivity() {
} }
} }
GlobalScope.launch(Dispatchers.IO) {
val index = Helper.downloadManager(this@MainActivity).downloadIndex
if (index != null) {
val downloadCursor = index.getDownloads()
if (downloadCursor != null) {
while (downloadCursor.moveToNext()) {
val download = downloadCursor.download
Log.e("Downloader", download.request.uri.toString())
Log.e("Downloader", download.request.id.toString())
Log.e("Downloader", download.request.mimeType.toString())
}
}
}
}
} }

View file

@ -3,7 +3,11 @@ package ani.dantotsu.aniyomi.anime.custom
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import androidx.annotation.OptIn
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.SimpleCache
import ani.dantotsu.download.DownloadsManager import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.media.manga.MangaCache import ani.dantotsu.media.manga.MangaCache
import ani.dantotsu.parsers.novel.NovelExtensionManager import ani.dantotsu.parsers.novel.NovelExtensionManager
@ -27,7 +31,7 @@ import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class AppModule(val app: Application) : InjektModule { class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() { @OptIn(UnstableApi::class) override fun InjektRegistrar.registerInjectables() {
addSingleton(app) addSingleton(app)
addSingletonFactory { DownloadsManager(app) } addSingletonFactory { DownloadsManager(app) }
@ -51,6 +55,8 @@ class AppModule(val app: Application) : InjektModule {
} }
} }
addSingletonFactory { StandaloneDatabaseProvider(app) }
addSingletonFactory { MangaCache() } addSingletonFactory { MangaCache() }
ContextCompat.getMainExecutor(app).execute { ContextCompat.getMainExecutor(app).execute {

View file

@ -0,0 +1,420 @@
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.graphics.Bitmap
import android.os.Build
import android.os.Environment
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.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.DownloadService
import ani.dantotsu.R
import ani.dantotsu.currActivity
import ani.dantotsu.download.Download
import ani.dantotsu.download.DownloadsManager
import ani.dantotsu.download.anime.AnimeDownloaderService
import ani.dantotsu.download.anime.AnimeServiceDataSingleton
import ani.dantotsu.download.video.Helper
import ani.dantotsu.download.video.MyDownloadService
import ani.dantotsu.logger
import ani.dantotsu.media.Media
import ani.dantotsu.media.anime.AnimeWatchFragment
import ani.dantotsu.parsers.Subtitle
import ani.dantotsu.parsers.Video
import ani.dantotsu.snackString
import com.google.firebase.crashlytics.FirebaseCrashlytics
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.Deferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
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.io.File
import java.io.FileOutputStream
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<DownloadsManager>()
private val downloadJobs = mutableMapOf<String, Job>()
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<SChapter> {
SChapterImpl() // Provide an instance of SChapterImpl
})
.registerTypeAdapter(SAnime::class.java, InstanceCreator<SAnime> {
SAnimeImpl() // Provide an instance of SAnimeImpl
})
.registerTypeAdapter(SEpisode::class.java, InstanceCreator<SEpisode> {
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<AnimeDownloaderService.DownloadTask> = ConcurrentLinkedQueue()
@Volatile
var isServiceRunning: Boolean = false
}

View file

@ -1,8 +1,17 @@
package ani.dantotsu.download.video package ani.dantotsu.download.video
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri 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.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes 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.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadHelper import androidx.media3.exoplayer.offline.DownloadHelper
import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadService import androidx.media3.exoplayer.offline.DownloadService
@ -22,7 +32,10 @@ import androidx.media3.exoplayer.scheduler.Requirements
import androidx.media3.ui.TrackSelectionDialogBuilder import androidx.media3.ui.TrackSelectionDialogBuilder
import ani.dantotsu.R import ani.dantotsu.R
import ani.dantotsu.defaultHeaders import ani.dantotsu.defaultHeaders
import ani.dantotsu.download.anime.AnimeDownloaderService
import ani.dantotsu.download.anime.AnimeServiceDataSingleton
import ani.dantotsu.logError import ani.dantotsu.logError
import ani.dantotsu.media.Media
import ani.dantotsu.okHttpClient import ani.dantotsu.okHttpClient
import ani.dantotsu.parsers.Subtitle import ani.dantotsu.parsers.Subtitle
import ani.dantotsu.parsers.SubtitleType import ani.dantotsu.parsers.SubtitleType
@ -37,6 +50,7 @@ import java.util.concurrent.*
object Helper { object Helper {
var simpleCache: SimpleCache? = null
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
fun downloadVideo(context: Context, video: Video, subtitle: Subtitle?) { fun downloadVideo(context: Context, video: Video, subtitle: Subtitle?) {
val dataSourceFactory = DataSource.Factory { val dataSourceFactory = DataSource.Factory {
@ -114,13 +128,13 @@ object Helper {
private var download: DownloadManager? = null private var download: DownloadManager? = null
private const val DOWNLOAD_CONTENT_DIRECTORY = "downloads" private const val DOWNLOAD_CONTENT_DIRECTORY = "Anime_Downloads"
@Synchronized @Synchronized
@UnstableApi @UnstableApi
fun downloadManager(context: Context): DownloadManager { fun downloadManager(context: Context): DownloadManager {
return download ?: let { return download ?: let {
val database = StandaloneDatabaseProvider(context) val database = Injekt.get<StandaloneDatabaseProvider>()
val downloadDirectory = File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY) val downloadDirectory = File(getDownloadDirectory(context), DOWNLOAD_CONTENT_DIRECTORY)
val dataSourceFactory = DataSource.Factory { val dataSourceFactory = DataSource.Factory {
//val dataSource: HttpDataSource = OkHttpDataSource.Factory(okHttpClient).createDataSource() //val dataSource: HttpDataSource = OkHttpDataSource.Factory(okHttpClient).createDataSource()
@ -133,17 +147,42 @@ object Helper {
} }
dataSource dataSource
} }
DownloadManager( val threadPoolSize = Runtime.getRuntime().availableProcessors()
val executorService = Executors.newFixedThreadPool(threadPoolSize)
val downloadManager = DownloadManager(
context, context,
database, database,
SimpleCache(downloadDirectory, NoOpCacheEvictor(), database), getSimpleCache(context),
dataSourceFactory, dataSourceFactory,
Executor(Runnable::run) executorService
).apply { ).apply {
requirements = requirements =
Requirements(Requirements.NETWORK or Requirements.DEVICE_STORAGE_NOT_LOW) Requirements(Requirements.NETWORK or Requirements.DEVICE_STORAGE_NOT_LOW)
maxParallelDownloads = 3 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!! 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<StandaloneDatabaseProvider>()
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
}
} }

View file

@ -11,7 +11,7 @@ import androidx.media3.exoplayer.scheduler.Scheduler
import ani.dantotsu.R import ani.dantotsu.R
@UnstableApi @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 { companion object {
private const val JOB_ID = 1 private const val JOB_ID = 1
private const val FOREGROUND_NOTIFICATION_ID = 1 private const val FOREGROUND_NOTIFICATION_ID = 1

View file

@ -424,4 +424,12 @@ class AnimeWatchFragment : Fragment() {
state = binding.animeSourceRecycler.layoutManager?.onSaveInstanceState() 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"
}
} }

View file

@ -14,7 +14,7 @@ data class Episode(
var selectedExtractor: String? = null, var selectedExtractor: String? = null,
var selectedVideo: Int = 0, var selectedVideo: Int = 0,
var selectedSubtitle: Int? = -1, var selectedSubtitle: Int? = -1,
var extractors: MutableList<VideoExtractor>? = null, @Transient var extractors: MutableList<VideoExtractor>? = null,
@Transient var extractorCallback: ((VideoExtractor) -> Unit)? = null, @Transient var extractorCallback: ((VideoExtractor) -> Unit)? = null,
var allStreams: Boolean = false, var allStreams: Boolean = false,
var watched: Long? = null, var watched: Long? = null,

View file

@ -20,6 +20,7 @@ import ani.dantotsu.*
import ani.dantotsu.databinding.BottomSheetSelectorBinding import ani.dantotsu.databinding.BottomSheetSelectorBinding
import ani.dantotsu.databinding.ItemStreamBinding import ani.dantotsu.databinding.ItemStreamBinding
import ani.dantotsu.databinding.ItemUrlBinding import ani.dantotsu.databinding.ItemUrlBinding
import ani.dantotsu.download.video.Helper
import ani.dantotsu.media.Media import ani.dantotsu.media.Media
import ani.dantotsu.media.MediaDetailsViewModel import ani.dantotsu.media.MediaDetailsViewModel
import ani.dantotsu.others.Download.download import ani.dantotsu.others.Download.download
@ -214,7 +215,8 @@ class SelectorDialogFragment : BottomSheetDialogFragment() {
override fun onBindViewHolder(holder: StreamViewHolder, position: Int) { override fun onBindViewHolder(holder: StreamViewHolder, position: Int) {
val extractor = links[position] 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.layoutManager = LinearLayoutManager(requireContext())
holder.binding.streamRecyclerView.adapter = VideoAdapter(extractor) holder.binding.streamRecyclerView.adapter = VideoAdapter(extractor)
@ -256,10 +258,10 @@ class SelectorDialogFragment : BottomSheetDialogFragment() {
override fun onBindViewHolder(holder: UrlViewHolder, position: Int) { override fun onBindViewHolder(holder: UrlViewHolder, position: Int) {
val binding = holder.binding val binding = holder.binding
val video = extractor.videos[position] val video = extractor.videos[position]
binding.urlQuality.text = //binding.urlQuality.text =
if (video.quality != null) "${video.quality}p" else "Default Quality" // if (video.quality != null) "${video.quality}p" else "Default Quality"
binding.urlNote.text = video.extraNote ?: "" //binding.urlNote.text = video.extraNote ?: ""
binding.urlNote.visibility = if (video.extraNote != null) View.VISIBLE else View.GONE //binding.urlNote.visibility = if (video.extraNote != null) View.VISIBLE else View.GONE
binding.urlDownload.visibility = View.VISIBLE binding.urlDownload.visibility = View.VISIBLE
binding.urlDownload.setSafeOnClickListener { binding.urlDownload.setSafeOnClickListener {
media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.selectedExtractor = media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.selectedExtractor =
@ -267,11 +269,23 @@ class SelectorDialogFragment : BottomSheetDialogFragment() {
media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.selectedVideo = media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!.selectedVideo =
position position
binding.urlDownload.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) binding.urlDownload.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
download( //download(
requireActivity(), // requireActivity(),
media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!, // media!!.anime!!.episodes!![media!!.anime!!.selectedEpisode!!]!!,
media!!.userPreferredName // 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() dismiss()
} }
if (video.format == VideoType.CONTAINER) { if (video.format == VideoType.CONTAINER) {
@ -282,11 +296,13 @@ class SelectorDialogFragment : BottomSheetDialogFragment() {
"#.##" "#.##"
).format(video.size ?: 0).toString() + " MB")) ).format(video.size ?: 0).toString() + " MB"))
} else { } else {
binding.urlQuality.text = "Multi Quality"
if ((loadData<Int>("settings_download_manager") ?: 0) == 0) { if ((loadData<Int>("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 override fun getItemCount(): Int = extractor.videos.size

View file

@ -26,6 +26,7 @@ import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension import eu.kanade.tachiyomi.extension.anime.model.AnimeExtension
import eu.kanade.tachiyomi.extension.manga.model.MangaExtension 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.network.interceptor.CloudflareBypassException
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
@ -41,6 +42,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Request
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File import java.io.File
@ -112,7 +114,8 @@ class DynamicAnimeParser(extension: AnimeExtension.Installed) : AnimeParser() {
seasonGroups.keys.sorted().flatMap { season -> seasonGroups.keys.sorted().flatMap { season ->
seasonGroups[season]?.sortedBy { it.episode_number }?.map { episode -> seasonGroups[season]?.sortedBy { it.episode_number }?.map { episode ->
if (episode.episode_number != 0f) { // Skip renumbering for episode number 0 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) { if (potentialNumber != null) {
episode.episode_number = potentialNumber episode.episode_number = potentialNumber
} else { } else {
@ -613,6 +616,10 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() {
val fileName = queryPairs.find { it.first == "file" }?.second ?: "" val fileName = queryPairs.find { it.first == "file" }?.second ?: ""
format = getVideoType(fileName) format = getVideoType(fileName)
if (format == null) {
val networkHelper = Injekt.get<NetworkHelper>()
format = headRequest(videoUrl, networkHelper)
}
} }
// If the format is still undetermined, log an error or handle it appropriately // If the format is still undetermined, log an error or handle it appropriately
@ -630,12 +637,12 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() {
number, number,
format, format,
FileUrl(videoUrl, headersMap), FileUrl(videoUrl, headersMap),
aniVideo.totalContentLength.toDouble() if (aniVideo.totalContentLength == 0L) null else aniVideo.bytesDownloaded.toDouble()
) )
} }
private fun getVideoType(fileName: String): VideoType? { private fun getVideoType(fileName: String): VideoType? {
return when { val type = when {
fileName.endsWith(".mp4", ignoreCase = true) || fileName.endsWith( fileName.endsWith(".mp4", ignoreCase = true) || fileName.endsWith(
".mkv", ".mkv",
ignoreCase = true ignoreCase = true
@ -645,6 +652,47 @@ class VideoServerPassthrough(val videoServer: VideoServer) : VideoExtractor() {
fileName.endsWith(".mpd", ignoreCase = true) -> VideoType.DASH fileName.endsWith(".mpd", ignoreCase = true) -> VideoType.DASH
else -> null 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 { private fun TrackToSubtitle(track: Track): Subtitle {

View file

@ -645,5 +645,6 @@
<string name="add_widget">Add widget</string> <string name="add_widget">Add widget</string>
<string name="app_widget_description">This is an app widget description</string> <string name="app_widget_description">This is an app widget description</string>
<string name="airing_image">Airing Image</string> <string name="airing_image">Airing Image</string>
<string name="anime_downloads">animeDownloads</string>
</resources> </resources>