-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #89 from snehilrx/dev
Major - added offline support for subtitles
- Loading branch information
Showing
5 changed files
with
430 additions
and
0 deletions.
There are no files selected for viewing
59 changes: 59 additions & 0 deletions
59
base/src/main/java/com/otaku/fetch/base/download/PTDownloaderFactory.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
} | ||
} |
105 changes: 105 additions & 0 deletions
105
kickassanime/src/main/java/com/otaku/kickassanime/utils/OfflineSubsHelper.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
9 changes: 9 additions & 0 deletions
9
kickassanime/src/main/java/com/otaku/kickassanime/utils/Quality.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
192 changes: 192 additions & 0 deletions
192
kickassanime/src/main/java/com/otaku/kickassanime/work/DownloadAllEpisodeTask.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} | ||
} | ||
|
||
} |
Oops, something went wrong.