From 89622ad3d0c58385b5e7e498ca227a074dff35b6 Mon Sep 17 00:00:00 2001 From: Syer10 Date: Mon, 29 Nov 2021 10:43:54 -0500 Subject: [PATCH] Unimplemented library updates --- .../gosyer/core/service/WebsocketService.kt | 99 +++++++++++++++++++ src/main/kotlin/ca/gosyer/data/DataModule.kt | 4 + .../gosyer/data/download/DownloadService.kt | 87 +++------------- .../data/library/LibraryUpdateService.kt | 36 +++++++ .../ca/gosyer/data/library/model/JobStatus.kt | 17 ++++ .../gosyer/data/library/model/UpdateStatus.kt | 15 +++ .../interactions/UpdatesInteractionHandler.kt | 23 +++++ .../ca/gosyer/data/server/requests/Updates.kt | 12 +++ .../ui/library/LibraryScreenViewModel.kt | 14 +++ .../ui/main/components/DownloadsExtraInfo.kt | 10 +- 10 files changed, 239 insertions(+), 78 deletions(-) create mode 100644 src/main/kotlin/ca/gosyer/core/service/WebsocketService.kt create mode 100644 src/main/kotlin/ca/gosyer/data/library/LibraryUpdateService.kt create mode 100644 src/main/kotlin/ca/gosyer/data/library/model/JobStatus.kt create mode 100644 src/main/kotlin/ca/gosyer/data/library/model/UpdateStatus.kt diff --git a/src/main/kotlin/ca/gosyer/core/service/WebsocketService.kt b/src/main/kotlin/ca/gosyer/core/service/WebsocketService.kt new file mode 100644 index 00000000..127850cb --- /dev/null +++ b/src/main/kotlin/ca/gosyer/core/service/WebsocketService.kt @@ -0,0 +1,99 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.core.service + +import ca.gosyer.build.BuildConfig +import ca.gosyer.data.server.Http +import ca.gosyer.data.server.ServerPreferences +import ca.gosyer.util.lang.throwIfCancellation +import ca.gosyer.util.system.CKLogger +import io.ktor.client.features.websocket.ws +import io.ktor.http.cio.websocket.Frame +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.serialization.json.Json + +@OptIn(DelicateCoroutinesApi::class) +abstract class WebsocketService( + protected val serverPreferences: ServerPreferences, + protected val client: Http +) { + protected val json = Json { + ignoreUnknownKeys = !BuildConfig.DEBUG + } + private val _status = MutableStateFlow(Status.STARTING) + val status = _status.asStateFlow() + + protected val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope) + + private var errorConnectionCount = 0 + + private var job: Job? = null + + init { + init() + } + + fun init() { + errorConnectionCount = 0 + job?.cancel() + job = serverUrl.mapLatest { serverUrl -> + _status.value = Status.STARTING + while (true) { + if (errorConnectionCount > 3) { + _status.value = Status.STOPPED + throw CancellationException() + } + runCatching { + client.ws( + host = serverUrl.substringAfter("://"), + path = query + ) { + errorConnectionCount = 0 + _status.value = Status.RUNNING + send(Frame.Text("STATUS")) + + incoming.receiveAsFlow() + .filterIsInstance() + .mapLatest(::onReceived) + .catch { it.throwIfCancellation() } + .collect() + } + }.throwIfCancellation().isFailure.let { + _status.value = Status.STARTING + if (it) errorConnectionCount++ + } + } + }.catch { + _status.value = Status.STOPPED + error(it) { "Error while running websocket service" } + throw it + }.launchIn(GlobalScope) + } + + abstract val query: String + + abstract suspend fun onReceived(frame: Frame.Text) + + enum class Status { + STARTING, + RUNNING, + STOPPED + } + + private companion object : CKLogger({}) +} diff --git a/src/main/kotlin/ca/gosyer/data/DataModule.kt b/src/main/kotlin/ca/gosyer/data/DataModule.kt index 7daf94ac..3ebb326d 100644 --- a/src/main/kotlin/ca/gosyer/data/DataModule.kt +++ b/src/main/kotlin/ca/gosyer/data/DataModule.kt @@ -11,6 +11,7 @@ import ca.gosyer.data.catalog.CatalogPreferences import ca.gosyer.data.download.DownloadService import ca.gosyer.data.extension.ExtensionPreferences import ca.gosyer.data.library.LibraryPreferences +import ca.gosyer.data.library.LibraryUpdateService import ca.gosyer.data.reader.ReaderPreferences import ca.gosyer.data.server.Http import ca.gosyer.data.server.HttpProvider @@ -100,4 +101,7 @@ val DataModule = module { bind() .toClass() .singleton() + bind() + .toClass() + .singleton() } diff --git a/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt b/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt index bcf6266f..55f1592d 100644 --- a/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt +++ b/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt @@ -6,45 +6,30 @@ package ca.gosyer.data.download -import ca.gosyer.build.BuildConfig +import ca.gosyer.core.service.WebsocketService import ca.gosyer.data.download.model.DownloadChapter import ca.gosyer.data.download.model.DownloadStatus import ca.gosyer.data.download.model.DownloaderStatus import ca.gosyer.data.server.Http import ca.gosyer.data.server.ServerPreferences import ca.gosyer.data.server.requests.downloadsQuery -import ca.gosyer.util.lang.throwIfCancellation import ca.gosyer.util.system.CKLogger -import io.ktor.client.features.websocket.ws import io.ktor.http.cio.websocket.Frame import io.ktor.http.cio.websocket.readText -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json import javax.inject.Inject @OptIn(DelicateCoroutinesApi::class) class DownloadService @Inject constructor( - val serverPreferences: ServerPreferences, - val client: Http -) { - private val json = Json { - ignoreUnknownKeys = !BuildConfig.DEBUG - } - private val _status = MutableStateFlow(Status.STARTING) - val status = _status.asStateFlow() + serverPreferences: ServerPreferences, + client: Http +) : WebsocketService(serverPreferences, client) { - private val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope) private val _downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped) val downloaderStatus = _downloaderStatus.asStateFlow() @@ -52,56 +37,18 @@ class DownloadService @Inject constructor( val downloadQueue = _downloadQueue.asStateFlow() private val watching = mutableMapOf>>>() - private var errorConnectionCount = 0 - private var job: Job? = null - init { - init() - } + override val query: String + get() = downloadsQuery() - fun init() { - errorConnectionCount = 0 - job?.cancel() - job = serverUrl.mapLatest { serverUrl -> - _status.value = Status.STARTING - while (true) { - if (errorConnectionCount > 3) { - _status.value = Status.STOPPED - throw CancellationException() - } - runCatching { - client.ws( - host = serverUrl.substringAfter("://"), - path = downloadsQuery() - ) { - errorConnectionCount = 0 - _status.value = Status.RUNNING - send(Frame.Text("STATUS")) - - while (true) { - val frame = incoming.receive() - runCatching { - frame as Frame.Text - val status = json.decodeFromString(frame.readText()) - _downloaderStatus.value = status.status - _downloadQueue.value = status.queue - val queue = status.queue.groupBy { it.mangaId } - watching.forEach { (mangaId, flow) -> - flow.emit(mangaId to queue[mangaId].orEmpty()) - } - }.throwIfCancellation() - } - } - }.throwIfCancellation().isFailure.let { - _status.value = Status.STARTING - if (it) errorConnectionCount++ - } - } - }.catch { - _status.value = Status.STOPPED - error(it) { "Error while running downloader" } - throw it - }.launchIn(GlobalScope) + override suspend fun onReceived(frame: Frame.Text) { + val status = json.decodeFromString(frame.readText()) + _downloaderStatus.value = status.status + _downloadQueue.value = status.queue + val queue = status.queue.groupBy { it.mangaId } + watching.forEach { (mangaId, flow) -> + flow.emit(mangaId to queue[mangaId].orEmpty()) + } } fun registerWatch(mangaId: Long) = @@ -116,11 +63,5 @@ class DownloadService @Inject constructor( watching -= mangaIds } - enum class Status { - STARTING, - RUNNING, - STOPPED - } - private companion object : CKLogger({}) } diff --git a/src/main/kotlin/ca/gosyer/data/library/LibraryUpdateService.kt b/src/main/kotlin/ca/gosyer/data/library/LibraryUpdateService.kt new file mode 100644 index 00000000..322e371c --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/library/LibraryUpdateService.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.data.library + +import ca.gosyer.core.service.WebsocketService +import ca.gosyer.data.library.model.UpdateStatus +import ca.gosyer.data.server.Http +import ca.gosyer.data.server.ServerPreferences +import ca.gosyer.data.server.requests.updatesQuery +import ca.gosyer.util.system.CKLogger +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.readText +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.serialization.decodeFromString +import javax.inject.Inject + +@OptIn(DelicateCoroutinesApi::class) +class LibraryUpdateService @Inject constructor( + serverPreferences: ServerPreferences, + client: Http +) : WebsocketService(serverPreferences, client) { + + override val query: String + get() = updatesQuery() + + override suspend fun onReceived(frame: Frame.Text) { + val status = json.decodeFromString(frame.readText()) + info { status } + } + + private companion object : CKLogger({}) +} diff --git a/src/main/kotlin/ca/gosyer/data/library/model/JobStatus.kt b/src/main/kotlin/ca/gosyer/data/library/model/JobStatus.kt new file mode 100644 index 00000000..0610c00d --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/library/model/JobStatus.kt @@ -0,0 +1,17 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.data.library.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class JobStatus { + PENDING, + RUNNING, + COMPLETE, + FAILED +} diff --git a/src/main/kotlin/ca/gosyer/data/library/model/UpdateStatus.kt b/src/main/kotlin/ca/gosyer/data/library/model/UpdateStatus.kt new file mode 100644 index 00000000..461e77ed --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/library/model/UpdateStatus.kt @@ -0,0 +1,15 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package ca.gosyer.data.library.model + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateStatus( + val statusMap: Map, + val running: Boolean +) diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/UpdatesInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/UpdatesInteractionHandler.kt index 3a1eaf4b..112fbccc 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/UpdatesInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/UpdatesInteractionHandler.kt @@ -6,12 +6,18 @@ package ca.gosyer.data.server.interactions +import ca.gosyer.data.models.Category import ca.gosyer.data.models.Updates import ca.gosyer.data.server.Http import ca.gosyer.data.server.ServerPreferences +import ca.gosyer.data.server.requests.fetchUpdatesRequest import ca.gosyer.data.server.requests.recentUpdatesQuery import ca.gosyer.util.lang.withIOContext +import io.ktor.client.request.forms.submitForm import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.statement.HttpResponse +import io.ktor.http.Parameters import javax.inject.Inject class UpdatesInteractionHandler @Inject constructor( @@ -24,4 +30,21 @@ class UpdatesInteractionHandler @Inject constructor( serverUrl + recentUpdatesQuery(pageNum) ) } + + suspend fun updateLibrary() = withIOContext { + client.post( + serverUrl + fetchUpdatesRequest() + ) + } + + suspend fun updateCategory(categoryId: Long) = withIOContext { + client.submitForm( + serverUrl + fetchUpdatesRequest(), + formParameters = Parameters.build { + append("category", categoryId.toString()) + } + ) + } + + suspend fun updateCategory(category: Category) = updateCategory(category.id) } diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Updates.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Updates.kt index 11cef9b4..2ced5756 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Updates.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Updates.kt @@ -9,3 +9,15 @@ package ca.gosyer.data.server.requests @Get fun recentUpdatesQuery(pageNum: Int) = "/api/v1/update/recentChapters/$pageNum" + +@Post +fun fetchUpdatesRequest() = + "/api/v1/update/fetch" + +@Get +fun updatesSummaryQuery() = + "/api/v1/update/summary" + +@WS +fun updatesQuery() = + "/api/v1/update" diff --git a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt index 3230df26..880446cb 100644 --- a/src/main/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt +++ b/src/main/kotlin/ca/gosyer/ui/library/LibraryScreenViewModel.kt @@ -11,6 +11,7 @@ import ca.gosyer.data.models.Category import ca.gosyer.data.models.Manga import ca.gosyer.data.server.interactions.CategoryInteractionHandler import ca.gosyer.data.server.interactions.LibraryInteractionHandler +import ca.gosyer.data.server.interactions.UpdatesInteractionHandler import ca.gosyer.ui.base.vm.ViewModel import ca.gosyer.util.compose.saveIntInBundle import ca.gosyer.util.compose.saveStringInBundle @@ -72,6 +73,7 @@ class LibraryScreenViewModel @Inject constructor( private val bundle: Bundle, private val categoryHandler: CategoryInteractionHandler, private val libraryHandler: LibraryInteractionHandler, + private val updatesHandler: UpdatesInteractionHandler, libraryPreferences: LibraryPreferences ) : ViewModel() { private val library = Library(MutableStateFlow(emptyList()), mutableMapOf()) @@ -155,6 +157,18 @@ class LibraryScreenViewModel @Inject constructor( _query.value = query } + fun updateLibrary() { + scope.launch { + updatesHandler.updateLibrary() + } + } + + fun updateCategory(category: Category) { + scope.launch { + updatesHandler.updateCategory(category) + } + } + companion object { const val QUERY_KEY = "query" const val SELECTED_CATEGORY_KEY = "selected_category" diff --git a/src/main/kotlin/ca/gosyer/ui/main/components/DownloadsExtraInfo.kt b/src/main/kotlin/ca/gosyer/ui/main/components/DownloadsExtraInfo.kt index cacc23d3..8a29caa0 100644 --- a/src/main/kotlin/ca/gosyer/ui/main/components/DownloadsExtraInfo.kt +++ b/src/main/kotlin/ca/gosyer/ui/main/components/DownloadsExtraInfo.kt @@ -17,7 +17,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import ca.gosyer.data.download.DownloadService +import ca.gosyer.core.service.WebsocketService import ca.gosyer.ui.base.resources.stringResource import ca.gosyer.ui.base.vm.viewModel import ca.gosyer.ui.downloads.DownloadsMenuViewModel @@ -28,13 +28,13 @@ fun DownloadsExtraInfo() { val status by vm.serviceStatus.collectAsState() val list by vm.downloadQueue.collectAsState() val text = when (status) { - DownloadService.Status.STARTING -> stringResource("downloads_loading") - DownloadService.Status.RUNNING -> { + WebsocketService.Status.STARTING -> stringResource("downloads_loading") + WebsocketService.Status.RUNNING -> { if (list.isNotEmpty()) { stringResource("downloads_remaining", list.size) } else null } - DownloadService.Status.STOPPED -> null + WebsocketService.Status.STOPPED -> null } if (text != null) { Text( @@ -42,7 +42,7 @@ fun DownloadsExtraInfo() { style = MaterialTheme.typography.body2, color = LocalContentColor.current.copy(alpha = ContentAlpha.disabled) ) - } else if (status == DownloadService.Status.STOPPED) { + } else if (status == WebsocketService.Status.STOPPED) { Surface(onClick = vm::restartDownloader, shape = RoundedCornerShape(4.dp)) { Text( stringResource("downloads_stopped"),