Skip to content

Commit

Permalink
Merge pull request #89 from snehilrx/dev
Browse files Browse the repository at this point in the history
Major - added offline support for subtitles
  • Loading branch information
snehilrx authored Oct 25, 2023
2 parents b415fd1 + 0bf6b0b commit 9240379
Show file tree
Hide file tree
Showing 5 changed files with 430 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.otaku.fetch.base.download

import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.exoplayer.offline.DefaultDownloaderFactory
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.Downloader
import java.util.concurrent.Executor

@UnstableApi
class PTDownloaderFactory : DefaultDownloaderFactory {

var progressHook: Tracker? = null

constructor(cacheDataSourceFactory: CacheDataSource.Factory, executor: Executor) : super(
cacheDataSourceFactory,
executor
)

class DownloaderWrapper(
private val downloader: Downloader,
private val intercept: Downloader.ProgressListener?
) : Downloader {
override fun download(progressListener: Downloader.ProgressListener?) {
downloader.download { contentLength, bytesDownloaded, percentDownloaded ->
progressListener?.onProgress(contentLength, bytesDownloaded, percentDownloaded)
intercept?.onProgress(contentLength, bytesDownloaded, percentDownloaded)
}
}

override fun cancel() {
downloader.cancel()
}

override fun remove() {
downloader.remove()
}
}

override fun createDownloader(request: DownloadRequest): Downloader {
return DownloaderWrapper(super.createDownloader(request)) { contentLength, bytesDownloaded, percentDownloaded ->
progressHook?.onProgressChanged(
request,
contentLength,
bytesDownloaded,
percentDownloaded
)
}
}

interface Tracker {
fun onProgressChanged(
request: DownloadRequest,
contentLength: Long,
bytesDownloaded: Long,
percentDownloaded: Float
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.otaku.kickassanime.utils

import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import com.otaku.kickassanime.api.model.CommonSubtitle
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.commonGet
import java.io.File
import javax.inject.Inject

class OfflineSubsHelper @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttp: OkHttpClient
) {

fun downloadSubs(
animeSlug: String,
episodeSlug: String,
subs: List<CommonSubtitle>
) {
val cacheDirectory = context.cacheDir
val folderName = "/episode/${animeSlug}/${episodeSlug}"

val folder = File(cacheDirectory, folderName)
if (!folder.exists()) {
val isFolderCreated = folder.mkdirs()
if (isFolderCreated) {
// Folder was successfully created
saveSubs(folder, subs)
} else {
// Failed to create the folder
Log.e(
"SUB_DOWNLOAD",
"Kickass Anime Error : Subs cache cannot be created "
)
}
} else {
// Folder already exists
saveSubs(folder, subs)
}
}

private fun saveSubs(
folder: File,
subs: List<CommonSubtitle>
) {
subs.forEach {
val url = it.getLink().toHttpUrl()
val request = Request.Builder().url(url)
.header("origin", "https://kaavid.com")
.header(
"user-agent",
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
).commonGet()
val response = okHttp.newCall(request.build()).execute()

val parts = it.getFormat().split('/')
val suffix = if (parts.size > 1) {
"${parts[0]}.${parts[1]}"
} else {
parts[0]
}
val subsFile = File("${folder.absolutePath}/${it.getLanguage()}~${suffix}")
if (!subsFile.exists()) {
subsFile.writeText(response.body.string())
}
}
}

@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun loadSubs(
animeSlug: String,
episodeSlug: String,
offlineCachingDataSourceFactory: CacheDataSource.Factory
): List<MediaSource>? {
val cacheDirectory = context.cacheDir
val folderName = "/episode/${animeSlug}/${episodeSlug}"
val folder = File(cacheDirectory, folderName)

if (folder.exists()) {
return folder.listFiles()?.map {
val nameWithoutExtension = it.nameWithoutExtension.split("~")
SingleSampleMediaSource.Factory(offlineCachingDataSourceFactory).createMediaSource(
MediaItem.SubtitleConfiguration
.Builder(Uri.fromFile(it))
.setLanguage(nameWithoutExtension[0])
.setMimeType("${nameWithoutExtension[1]}/${it.extension}")
.setLabel(nameWithoutExtension[0])
.build(),
C.TIME_UNSET
)
}
}
return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.otaku.kickassanime.utils

enum class Quality(val bitrate: Int) {
MAX(Constants.QualityBitRate.MAX),
P_1080(Constants.QualityBitRate.P_1080),
P_720(Constants.QualityBitRate.P_720),
P_480(Constants.QualityBitRate.P_480),
P_360(Constants.QualityBitRate.P_360);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package com.otaku.kickassanime.work

import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.media3.common.MediaItem
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.offline.DownloadHelper
import androidx.media3.exoplayer.offline.DownloadHelper.Callback
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import com.google.gson.Gson
import com.otaku.fetch.ModuleRegistry
import com.otaku.fetch.base.download.DownloadUtils
import com.otaku.fetch.base.download.FetchDownloadService
import com.otaku.fetch.base.settings.Settings
import com.otaku.fetch.base.settings.dataStore
import com.otaku.kickassanime.Strings
import com.otaku.kickassanime.api.model.CommonSubtitle
import com.otaku.kickassanime.db.models.CommonVideoLink
import com.otaku.kickassanime.page.episodepage.CustomWebView
import com.otaku.kickassanime.page.episodepage.EpisodeViewModel
import com.otaku.kickassanime.pojo.PlayData
import com.otaku.kickassanime.utils.OfflineSubsHelper
import com.otaku.kickassanime.utils.Quality
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.IOException
import kotlin.coroutines.resume


@HiltWorker
class DownloadAllEpisodeTask @AssistedInject constructor(
@Assisted val context: Context,
@Assisted val workerParameters: WorkerParameters,
private val gson: Gson,
private val downloadUtils: DownloadUtils,
private val offlineSubsHelper: OfflineSubsHelper
) : CoroutineWorker(context, workerParameters) {


companion object {
fun createNewInput(
episodeUrls: Array<String>, episodeSlugs: Array<String>, animeSlug: String
) = Data.Builder().putStringArray(EPISODES_URLS, episodeUrls)
.putStringArray(EPISODES_SLUGS, episodeSlugs).putString(ANIME_SLUG, animeSlug).build()

fun getErrors(result: Data): Array<out String>? {
return result.getStringArray(ERRORS)
}

fun getDownloadUrls(result: Data): Array<out String>? {
return result.getStringArray(DOWNLOAD_URLS)
}


private const val EPISODES_URLS = "all_episodes"
private const val EPISODES_SLUGS = "all_episodes_slugs"
private const val ANIME_SLUG = "anime_slug"

private const val DOWNLOAD_URLS = "download_urls"
private const val ERRORS = "errors"
}

@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
override suspend fun doWork(): Result {
return runBlocking {
val preferences = context.dataStore.data.first()
// get all episodes
val episodeUrls = workerParameters.inputData.getStringArray(EPISODES_URLS)
?: return@runBlocking Result.failure()
val episodeSlugs = workerParameters.inputData.getStringArray(EPISODES_SLUGS)
?: return@runBlocking Result.failure()
val animeSlug = workerParameters.inputData.getString(ANIME_SLUG)
?: return@runBlocking Result.failure()
val webView: CustomWebView =
ModuleRegistry.modules[Strings.KICKASSANIME]?.appModule?.webView as? CustomWebView
?: return@runBlocking Result.failure()

val allDownloads = ArrayList<Pair<String, PlayData>>()
loadNextEpisode(webView, episodeUrls, episodeSlugs, 0, allDownloads)
val downloadRequestUri = ArrayList<String>()
val errors = ArrayList<String>()
allDownloads.forEach { (episodeSlug, episodePlayData) ->
try {
val downloadRequest = processEpisode(animeSlug,
episodeSlug,
episodePlayData,
preferences[Settings.DOWNLOADS_VIDEO_QUALITY]?.let { quality ->
Quality.entries[quality.toIntOrNull() ?: 0].bitrate
} ?: Quality.MAX.bitrate
)
downloadRequestUri.add(downloadRequest.uri.toString())
} catch (e: Throwable) {
errors.add(episodeSlug)
}
}
Result.success(
Data.Builder()
.putStringArray(DOWNLOAD_URLS, downloadRequestUri.toTypedArray())
.putStringArray(ERRORS, errors.toTypedArray())
.build()
)
}
}

@androidx.annotation.OptIn(UnstableApi::class)
private suspend fun processEpisode(
animeSlug: String,
episodeSlug: String,
playData: PlayData,
downloadBitRate: Int
): DownloadRequest {
val links = EpisodeViewModel.processPlayData(playData.allSources)
val subs = ArrayList<CommonSubtitle>()
val videoLinks = ArrayList<CommonVideoLink>()
links.forEach { (videoLink, subtitles) ->
run {
subs.addAll(subtitles)
videoLinks.add(videoLink)
}
}
val mediaLink = videoLinks.getOrNull(0)?.getLink() ?: throw Exception("No video link found")
val trackSelectionParameters =
TrackSelectionParameters.Builder(context).setPreferredTextLanguage("en")
.setMinVideoBitrate(downloadBitRate - 1000)
.setPreferredAudioLanguage("en").setMaxVideoBitrate(downloadBitRate).build()
offlineSubsHelper.downloadSubs(animeSlug, episodeSlug, subs)
val link = Uri.parse(mediaLink)
val helper = DownloadHelper.forMediaItem(
context,
MediaItem.fromUri(link),
DefaultRenderersFactory(context),
downloadUtils.getHttpDataSourceFactory(context)
)

val downloadRequest = suspendCancellableCoroutine<DownloadRequest> { continuation ->
helper.prepare(object : Callback {
override fun onPrepared(helper: DownloadHelper) {
for (periodIndex in 0 until helper.periodCount) {
helper.clearTrackSelections(periodIndex)
helper.addTrackSelection(periodIndex, trackSelectionParameters)
}
val downloadRequest = helper.getDownloadRequest(Util.getUtf8Bytes(episodeSlug))
DownloadService.sendAddDownload(
context, FetchDownloadService::class.java,
downloadRequest, false
)
continuation.resume(downloadRequest)
}

override fun onPrepareError(helper: DownloadHelper, e: IOException) {
Log.e("Download Episode", "Failed while preparing media", e)
continuation.cancel(e)
}
})
}
return downloadRequest
}

private suspend fun loadNextEpisode(
webView: CustomWebView,
episodeUrls: Array<String>,
episodeSlugs: Array<String>,
index: Int,
playlist: ArrayList<Pair<String, PlayData>>
) {
try {
val enqueue = webView.enqueue(episodeUrls[index])
val playData = gson.fromJson(enqueue, PlayData::class.java)
playlist.add(episodeSlugs[index] to playData)
if (index + 1 < episodeUrls.size) {
loadNextEpisode(webView, episodeUrls, episodeSlugs, index + 1, playlist)
}
} catch (e: Exception) {
if (index + 1 < episodeUrls.size) {
loadNextEpisode(webView, episodeUrls, episodeSlugs, index + 1, playlist)
}
}
}

}
Loading

0 comments on commit 9240379

Please sign in to comment.