[feat] Complete video caching system

This commit is contained in:
acite
2025-09-15 03:15:43 +08:00
parent ad51c5da2f
commit e94249aa8f
22 changed files with 613 additions and 211 deletions

View File

@@ -105,9 +105,8 @@ class AbyssTunnelProxy @Inject constructor(
val read = localIn.read(buffer)
if (read <= 0)
break
Log.i("Delay Analyze", "Read $read Bytes from HttpClient")
abyss.write(buffer, 0, read)
Log.i("Delay Analyze", "Wrote $read Bytes to Remote Abyss")
}
}
@@ -118,9 +117,7 @@ class AbyssTunnelProxy @Inject constructor(
val n = abyss.read(buffer, 0, buffer.size)
if (n <= 0)
break
Log.i("Delay Analyze", "Read $n Bytes from Remote Abyss")
localOut.write(buffer, 0, n)
Log.i("Delay Analyze", "Wrote $n Bytes to HttpClient")
}
}
}

View File

@@ -217,13 +217,10 @@ object ApiClient {
domain = selectedUrl.toHttpUrlOrNull()?.host ?: ""
cert = crt
base = selectedUrl
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.proxy?.config(base.toUri().host!!, 4096)
context.abyssService?.downloader?.init()
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
}
api = createRetrofit().create(ApiInterface::class.java)
Log.i("Delay Analyze", "Start Abyss Hello")

View File

@@ -1,6 +1,7 @@
package com.acitelight.aether.service
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import com.acitelight.aether.Screen
import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient.createOkHttp
@@ -13,7 +14,18 @@ import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2core.Extras
import com.tonyodev.fetch2okhttp.OkHttpDownloader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -23,21 +35,25 @@ class FetchManager @Inject constructor(
) {
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private var client: OkHttpClient? = null
val configured = MutableStateFlow(false)
fun init()
{
client = createOkHttp()
val fetchConfiguration = FetchConfiguration.Builder(context)
.setDownloadConcurrentLimit(8)
.setHttpDownloader(OkHttpDownloader(createOkHttp()))
.setHttpDownloader(OkHttpDownloader(client))
.build()
fetch = Fetch.Impl.getInstance(fetchConfiguration)
fetch = Fetch.Impl.getInstance(fetchConfiguration)
configured.update { true }
}
// listener management
fun setListener(l: FetchListener) {
if (fetch == null)
return
suspend fun setListener(l: FetchListener) {
configured.filter { it }.first()
listener?.let { fetch?.removeListener(it) }
listener = l
fetch?.addListener(l)
@@ -51,14 +67,24 @@ class FetchManager @Inject constructor(
}
// query downloads
fun getAllDownloads(callback: (List<Download>) -> Unit) {
if (fetch == null) init()
suspend fun getAllDownloads(callback: (List<Download>) -> Unit) {
configured.filter { it }.first()
fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList())
}
fun getDownloadsByStatus(status: Status, callback: (List<Download>) -> Unit) {
if (fetch == null) init()
fetch?.getDownloadsWithStatus(status) { list -> callback(list) } ?: callback(emptyList())
suspend fun getAllDownloadsAsync(): List<Download>
{
configured.filter { it }.first()
val completed = MutableStateFlow(false)
var r = listOf<Download>()
fetch?.getDownloads { list ->
r = list
completed.update { true }
}
completed.filter { it }.first()
return r
}
// operations
@@ -80,13 +106,13 @@ class FetchManager @Inject constructor(
} ?: callback?.invoke()
}
private fun enqueue(request: Request, onEnqueued: ((Request) -> Unit)? = null, onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null) {
if (fetch == null) init()
private suspend fun enqueue(request: Request, onEnqueued: ((Request) -> Unit)? = null, onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null) {
configured.filter { it }.first()
fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) })
}
private fun getVideosDirectory() {
val appFilesDir = context.filesDir
val appFilesDir = context.getExternalFilesDir(null)
val videosDir = File(appFilesDir, "videos")
if (!videosDir.exists()) {
@@ -94,12 +120,52 @@ class FetchManager @Inject constructor(
}
}
fun startVideoDownload(video: Video)
suspend fun downloadFile(
client: OkHttpClient,
url: String,
destFile: File
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val request = okhttp3.Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(IOException("Unexpected code $response"))
}
destFile.parentFile?.mkdirs()
response.body.byteStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun startVideoDownload(video: Video)
{
val path = File(context.filesDir, "videos/${video.klass}/${video.id}/video.mp4")
val path = File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4")
val request = Request(video.getVideo(), path.path).apply {
extras = Extras(mapOf("name" to video.video.name, "id" to video.id, "class" to video.klass))
}
downloadFile(
client!!,
video.getCover(),
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg"))
enqueue(request)
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/summary.json").writeText(Json.encodeToString(video))
for(p in video.getGallery())
{
downloadFile(
client!!,
p.url,
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/gallery/${p.name}"))
}
}
}

View File

@@ -5,14 +5,18 @@ import com.acitelight.aether.model.BookMark
import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicResponse
import com.acitelight.aether.model.Video
import com.tonyodev.fetch2.Status
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaManager @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context
)
{
var token: String = "null"
@@ -43,23 +47,86 @@ class MediaManager @Inject constructor(
suspend fun queryVideo(klass: String, id: String): Video?
{
val downloaded = fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED &&
it.extras.getString("id", "") == id &&
it.extras.getString("class", "") == klass
}
if(!downloaded.isEmpty())
{
val jsonString = File(
context.getExternalFilesDir(null),
"videos/$klass/$id/summary.json"
).readText()
return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!)
}
try {
val j = ApiClient.api!!.queryVideo(klass, id, token)
return Video(klass = klass, id = id, token=token, isLocal = false, video = j)
return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j)
}catch (e: Exception)
{
return null
}
}
suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>?
{
try {
val j = ApiClient.api!!.queryVideoBulk(klass, id, token)
return j.zip(id).map {Video(klass = klass, id = it.second, token=token, isLocal = false, video = it.first)}
}catch (e: Exception)
{
return null
suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? {
return try {
val completedDownloads = fetchManager.getAllDownloadsAsync()
.filter { it.status == Status.COMPLETED }
val localIds = mutableSetOf<String>()
val remoteIds = mutableListOf<String>()
for (videoId in id) {
if (completedDownloads.any {
it.extras.getString("id", "") == videoId &&
it.extras.getString("class", "") == klass
}) {
localIds.add(videoId)
} else {
remoteIds.add(videoId)
}
}
val localVideos = localIds.mapNotNull { videoId ->
val localFile = File(
context.getExternalFilesDir(null),
"videos/$klass/$videoId/summary.json"
)
if (localFile.exists()) {
try {
val jsonString = localFile.readText()
Json.decodeFromString<Video>(jsonString).toLocal(
context.getExternalFilesDir(null)?.path ?: ""
)
} catch (e: Exception) {
null
}
} else {
null
}
}
val remoteVideos = if (remoteIds.isNotEmpty()) {
val j = ApiClient.api!!.queryVideoBulk(klass, remoteIds, token)
j.zip(remoteIds).map {
Video(
klass = klass,
id = it.second,
token = token,
isLocal = false,
localBase = "",
video = it.first
)
}
} else {
emptyList()
}
localVideos + remoteVideos
} catch (e: Exception) {
null
}
}

View File

@@ -91,7 +91,7 @@ class RecentManager @Inject constructor(
recent.removeAt(index)
}
recent.add(0, mediaManager.queryVideoBulk(video.klass, listOf(video.id))!![0])
recent.add(0, mediaManager.queryVideo(video.klass, video.id)!!)
if(recent.size >= 21)

View File

@@ -0,0 +1,20 @@
package com.acitelight.aether.service
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.acitelight.aether.model.Video
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VideoLibrary @Inject constructor(
@ApplicationContext private val context: Context
) {
var classes = mutableStateListOf<String>()
val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>()
val updatingMap: MutableMap<Int, Boolean> = mutableMapOf()
}