Skip to content

Commit 0bf6b0b

Browse files
authored
Merge pull request #88 from snehilrx/offline_subtitle
added offline download support
2 parents a9795ad + 09cafa8 commit 0bf6b0b

File tree

5 files changed

+430
-0
lines changed

5 files changed

+430
-0
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.otaku.fetch.base.download
2+
3+
import androidx.media3.common.util.UnstableApi
4+
import androidx.media3.datasource.cache.CacheDataSource
5+
import androidx.media3.exoplayer.offline.DefaultDownloaderFactory
6+
import androidx.media3.exoplayer.offline.DownloadRequest
7+
import androidx.media3.exoplayer.offline.Downloader
8+
import java.util.concurrent.Executor
9+
10+
@UnstableApi
11+
class PTDownloaderFactory : DefaultDownloaderFactory {
12+
13+
var progressHook: Tracker? = null
14+
15+
constructor(cacheDataSourceFactory: CacheDataSource.Factory, executor: Executor) : super(
16+
cacheDataSourceFactory,
17+
executor
18+
)
19+
20+
class DownloaderWrapper(
21+
private val downloader: Downloader,
22+
private val intercept: Downloader.ProgressListener?
23+
) : Downloader {
24+
override fun download(progressListener: Downloader.ProgressListener?) {
25+
downloader.download { contentLength, bytesDownloaded, percentDownloaded ->
26+
progressListener?.onProgress(contentLength, bytesDownloaded, percentDownloaded)
27+
intercept?.onProgress(contentLength, bytesDownloaded, percentDownloaded)
28+
}
29+
}
30+
31+
override fun cancel() {
32+
downloader.cancel()
33+
}
34+
35+
override fun remove() {
36+
downloader.remove()
37+
}
38+
}
39+
40+
override fun createDownloader(request: DownloadRequest): Downloader {
41+
return DownloaderWrapper(super.createDownloader(request)) { contentLength, bytesDownloaded, percentDownloaded ->
42+
progressHook?.onProgressChanged(
43+
request,
44+
contentLength,
45+
bytesDownloaded,
46+
percentDownloaded
47+
)
48+
}
49+
}
50+
51+
interface Tracker {
52+
fun onProgressChanged(
53+
request: DownloadRequest,
54+
contentLength: Long,
55+
bytesDownloaded: Long,
56+
percentDownloaded: Float
57+
)
58+
}
59+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.otaku.kickassanime.utils
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import android.util.Log
6+
import androidx.media3.common.C
7+
import androidx.media3.common.MediaItem
8+
import androidx.media3.datasource.cache.CacheDataSource
9+
import androidx.media3.exoplayer.source.MediaSource
10+
import androidx.media3.exoplayer.source.SingleSampleMediaSource
11+
import com.otaku.kickassanime.api.model.CommonSubtitle
12+
import dagger.hilt.android.qualifiers.ApplicationContext
13+
import okhttp3.HttpUrl.Companion.toHttpUrl
14+
import okhttp3.OkHttpClient
15+
import okhttp3.Request
16+
import okhttp3.internal.commonGet
17+
import java.io.File
18+
import javax.inject.Inject
19+
20+
class OfflineSubsHelper @Inject constructor(
21+
@ApplicationContext private val context: Context,
22+
private val okHttp: OkHttpClient
23+
) {
24+
25+
fun downloadSubs(
26+
animeSlug: String,
27+
episodeSlug: String,
28+
subs: List<CommonSubtitle>
29+
) {
30+
val cacheDirectory = context.cacheDir
31+
val folderName = "/episode/${animeSlug}/${episodeSlug}"
32+
33+
val folder = File(cacheDirectory, folderName)
34+
if (!folder.exists()) {
35+
val isFolderCreated = folder.mkdirs()
36+
if (isFolderCreated) {
37+
// Folder was successfully created
38+
saveSubs(folder, subs)
39+
} else {
40+
// Failed to create the folder
41+
Log.e(
42+
"SUB_DOWNLOAD",
43+
"Kickass Anime Error : Subs cache cannot be created "
44+
)
45+
}
46+
} else {
47+
// Folder already exists
48+
saveSubs(folder, subs)
49+
}
50+
}
51+
52+
private fun saveSubs(
53+
folder: File,
54+
subs: List<CommonSubtitle>
55+
) {
56+
subs.forEach {
57+
val url = it.getLink().toHttpUrl()
58+
val request = Request.Builder().url(url)
59+
.header("origin", "https://kaavid.com")
60+
.header(
61+
"user-agent",
62+
"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"
63+
).commonGet()
64+
val response = okHttp.newCall(request.build()).execute()
65+
66+
val parts = it.getFormat().split('/')
67+
val suffix = if (parts.size > 1) {
68+
"${parts[0]}.${parts[1]}"
69+
} else {
70+
parts[0]
71+
}
72+
val subsFile = File("${folder.absolutePath}/${it.getLanguage()}~${suffix}")
73+
if (!subsFile.exists()) {
74+
subsFile.writeText(response.body.string())
75+
}
76+
}
77+
}
78+
79+
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
80+
fun loadSubs(
81+
animeSlug: String,
82+
episodeSlug: String,
83+
offlineCachingDataSourceFactory: CacheDataSource.Factory
84+
): List<MediaSource>? {
85+
val cacheDirectory = context.cacheDir
86+
val folderName = "/episode/${animeSlug}/${episodeSlug}"
87+
val folder = File(cacheDirectory, folderName)
88+
89+
if (folder.exists()) {
90+
return folder.listFiles()?.map {
91+
val nameWithoutExtension = it.nameWithoutExtension.split("~")
92+
SingleSampleMediaSource.Factory(offlineCachingDataSourceFactory).createMediaSource(
93+
MediaItem.SubtitleConfiguration
94+
.Builder(Uri.fromFile(it))
95+
.setLanguage(nameWithoutExtension[0])
96+
.setMimeType("${nameWithoutExtension[1]}/${it.extension}")
97+
.setLabel(nameWithoutExtension[0])
98+
.build(),
99+
C.TIME_UNSET
100+
)
101+
}
102+
}
103+
return null
104+
}
105+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.otaku.kickassanime.utils
2+
3+
enum class Quality(val bitrate: Int) {
4+
MAX(Constants.QualityBitRate.MAX),
5+
P_1080(Constants.QualityBitRate.P_1080),
6+
P_720(Constants.QualityBitRate.P_720),
7+
P_480(Constants.QualityBitRate.P_480),
8+
P_360(Constants.QualityBitRate.P_360);
9+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.otaku.kickassanime.work
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import android.util.Log
6+
import androidx.hilt.work.HiltWorker
7+
import androidx.media3.common.MediaItem
8+
import androidx.media3.common.TrackSelectionParameters
9+
import androidx.media3.common.util.UnstableApi
10+
import androidx.media3.common.util.Util
11+
import androidx.media3.exoplayer.DefaultRenderersFactory
12+
import androidx.media3.exoplayer.offline.DownloadHelper
13+
import androidx.media3.exoplayer.offline.DownloadHelper.Callback
14+
import androidx.media3.exoplayer.offline.DownloadRequest
15+
import androidx.media3.exoplayer.offline.DownloadService
16+
import androidx.work.CoroutineWorker
17+
import androidx.work.Data
18+
import androidx.work.WorkerParameters
19+
import com.google.gson.Gson
20+
import com.otaku.fetch.ModuleRegistry
21+
import com.otaku.fetch.base.download.DownloadUtils
22+
import com.otaku.fetch.base.download.FetchDownloadService
23+
import com.otaku.fetch.base.settings.Settings
24+
import com.otaku.fetch.base.settings.dataStore
25+
import com.otaku.kickassanime.Strings
26+
import com.otaku.kickassanime.api.model.CommonSubtitle
27+
import com.otaku.kickassanime.db.models.CommonVideoLink
28+
import com.otaku.kickassanime.page.episodepage.CustomWebView
29+
import com.otaku.kickassanime.page.episodepage.EpisodeViewModel
30+
import com.otaku.kickassanime.pojo.PlayData
31+
import com.otaku.kickassanime.utils.OfflineSubsHelper
32+
import com.otaku.kickassanime.utils.Quality
33+
import dagger.assisted.Assisted
34+
import dagger.assisted.AssistedInject
35+
import kotlinx.coroutines.flow.first
36+
import kotlinx.coroutines.runBlocking
37+
import kotlinx.coroutines.suspendCancellableCoroutine
38+
import java.io.IOException
39+
import kotlin.coroutines.resume
40+
41+
42+
@HiltWorker
43+
class DownloadAllEpisodeTask @AssistedInject constructor(
44+
@Assisted val context: Context,
45+
@Assisted val workerParameters: WorkerParameters,
46+
private val gson: Gson,
47+
private val downloadUtils: DownloadUtils,
48+
private val offlineSubsHelper: OfflineSubsHelper
49+
) : CoroutineWorker(context, workerParameters) {
50+
51+
52+
companion object {
53+
fun createNewInput(
54+
episodeUrls: Array<String>, episodeSlugs: Array<String>, animeSlug: String
55+
) = Data.Builder().putStringArray(EPISODES_URLS, episodeUrls)
56+
.putStringArray(EPISODES_SLUGS, episodeSlugs).putString(ANIME_SLUG, animeSlug).build()
57+
58+
fun getErrors(result: Data): Array<out String>? {
59+
return result.getStringArray(ERRORS)
60+
}
61+
62+
fun getDownloadUrls(result: Data): Array<out String>? {
63+
return result.getStringArray(DOWNLOAD_URLS)
64+
}
65+
66+
67+
private const val EPISODES_URLS = "all_episodes"
68+
private const val EPISODES_SLUGS = "all_episodes_slugs"
69+
private const val ANIME_SLUG = "anime_slug"
70+
71+
private const val DOWNLOAD_URLS = "download_urls"
72+
private const val ERRORS = "errors"
73+
}
74+
75+
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
76+
override suspend fun doWork(): Result {
77+
return runBlocking {
78+
val preferences = context.dataStore.data.first()
79+
// get all episodes
80+
val episodeUrls = workerParameters.inputData.getStringArray(EPISODES_URLS)
81+
?: return@runBlocking Result.failure()
82+
val episodeSlugs = workerParameters.inputData.getStringArray(EPISODES_SLUGS)
83+
?: return@runBlocking Result.failure()
84+
val animeSlug = workerParameters.inputData.getString(ANIME_SLUG)
85+
?: return@runBlocking Result.failure()
86+
val webView: CustomWebView =
87+
ModuleRegistry.modules[Strings.KICKASSANIME]?.appModule?.webView as? CustomWebView
88+
?: return@runBlocking Result.failure()
89+
90+
val allDownloads = ArrayList<Pair<String, PlayData>>()
91+
loadNextEpisode(webView, episodeUrls, episodeSlugs, 0, allDownloads)
92+
val downloadRequestUri = ArrayList<String>()
93+
val errors = ArrayList<String>()
94+
allDownloads.forEach { (episodeSlug, episodePlayData) ->
95+
try {
96+
val downloadRequest = processEpisode(animeSlug,
97+
episodeSlug,
98+
episodePlayData,
99+
preferences[Settings.DOWNLOADS_VIDEO_QUALITY]?.let { quality ->
100+
Quality.entries[quality.toIntOrNull() ?: 0].bitrate
101+
} ?: Quality.MAX.bitrate
102+
)
103+
downloadRequestUri.add(downloadRequest.uri.toString())
104+
} catch (e: Throwable) {
105+
errors.add(episodeSlug)
106+
}
107+
}
108+
Result.success(
109+
Data.Builder()
110+
.putStringArray(DOWNLOAD_URLS, downloadRequestUri.toTypedArray())
111+
.putStringArray(ERRORS, errors.toTypedArray())
112+
.build()
113+
)
114+
}
115+
}
116+
117+
@androidx.annotation.OptIn(UnstableApi::class)
118+
private suspend fun processEpisode(
119+
animeSlug: String,
120+
episodeSlug: String,
121+
playData: PlayData,
122+
downloadBitRate: Int
123+
): DownloadRequest {
124+
val links = EpisodeViewModel.processPlayData(playData.allSources)
125+
val subs = ArrayList<CommonSubtitle>()
126+
val videoLinks = ArrayList<CommonVideoLink>()
127+
links.forEach { (videoLink, subtitles) ->
128+
run {
129+
subs.addAll(subtitles)
130+
videoLinks.add(videoLink)
131+
}
132+
}
133+
val mediaLink = videoLinks.getOrNull(0)?.getLink() ?: throw Exception("No video link found")
134+
val trackSelectionParameters =
135+
TrackSelectionParameters.Builder(context).setPreferredTextLanguage("en")
136+
.setMinVideoBitrate(downloadBitRate - 1000)
137+
.setPreferredAudioLanguage("en").setMaxVideoBitrate(downloadBitRate).build()
138+
offlineSubsHelper.downloadSubs(animeSlug, episodeSlug, subs)
139+
val link = Uri.parse(mediaLink)
140+
val helper = DownloadHelper.forMediaItem(
141+
context,
142+
MediaItem.fromUri(link),
143+
DefaultRenderersFactory(context),
144+
downloadUtils.getHttpDataSourceFactory(context)
145+
)
146+
147+
val downloadRequest = suspendCancellableCoroutine<DownloadRequest> { continuation ->
148+
helper.prepare(object : Callback {
149+
override fun onPrepared(helper: DownloadHelper) {
150+
for (periodIndex in 0 until helper.periodCount) {
151+
helper.clearTrackSelections(periodIndex)
152+
helper.addTrackSelection(periodIndex, trackSelectionParameters)
153+
}
154+
val downloadRequest = helper.getDownloadRequest(Util.getUtf8Bytes(episodeSlug))
155+
DownloadService.sendAddDownload(
156+
context, FetchDownloadService::class.java,
157+
downloadRequest, false
158+
)
159+
continuation.resume(downloadRequest)
160+
}
161+
162+
override fun onPrepareError(helper: DownloadHelper, e: IOException) {
163+
Log.e("Download Episode", "Failed while preparing media", e)
164+
continuation.cancel(e)
165+
}
166+
})
167+
}
168+
return downloadRequest
169+
}
170+
171+
private suspend fun loadNextEpisode(
172+
webView: CustomWebView,
173+
episodeUrls: Array<String>,
174+
episodeSlugs: Array<String>,
175+
index: Int,
176+
playlist: ArrayList<Pair<String, PlayData>>
177+
) {
178+
try {
179+
val enqueue = webView.enqueue(episodeUrls[index])
180+
val playData = gson.fromJson(enqueue, PlayData::class.java)
181+
playlist.add(episodeSlugs[index] to playData)
182+
if (index + 1 < episodeUrls.size) {
183+
loadNextEpisode(webView, episodeUrls, episodeSlugs, index + 1, playlist)
184+
}
185+
} catch (e: Exception) {
186+
if (index + 1 < episodeUrls.size) {
187+
loadNextEpisode(webView, episodeUrls, episodeSlugs, index + 1, playlist)
188+
}
189+
}
190+
}
191+
192+
}

0 commit comments

Comments
 (0)