diff --git a/build.gradle.kts b/build.gradle.kts index d57f9c79..02eb781c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation("io.ktor:ktor-client-okhttp:$ktorVersion") implementation("io.ktor:ktor-client-serialization:$ktorVersion") implementation("io.ktor:ktor-client-logging:$ktorVersion") + implementation("io.ktor:ktor-client-websockets:$ktorVersion") // Logging val log4jVersion = "2.14.1" diff --git a/src/main/kotlin/ca/gosyer/data/DataModule.kt b/src/main/kotlin/ca/gosyer/data/DataModule.kt index 912d2382..81065ce6 100644 --- a/src/main/kotlin/ca/gosyer/data/DataModule.kt +++ b/src/main/kotlin/ca/gosyer/data/DataModule.kt @@ -8,6 +8,7 @@ package ca.gosyer.data import ca.gosyer.core.prefs.PreferenceStoreFactory 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.reader.ReaderPreferences @@ -73,4 +74,8 @@ 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 new file mode 100644 index 00000000..4d219fad --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/download/DownloadService.kt @@ -0,0 +1,79 @@ +/* + * 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.download + +import ca.gosyer.BuildConfig +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 io.ktor.client.features.websocket.ws +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.readText +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +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 serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope) + private val _downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped) + val downloaderStatus = _downloaderStatus.asStateFlow() + private val watching = mutableMapOf>>() + + init { + serverUrl.mapLatest { serverUrl -> + while (true) { + runCatching { + client.ws( + host = serverUrl.substringAfter("://"), + path = downloadsQuery() + ) { + 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 + val queue = status.queue.groupBy { it.mangaId } + watching.forEach { (mangaId, flow) -> + flow.emit(queue[mangaId].orEmpty()) + } + }.throwIfCancellation() + } + } + }.throwIfCancellation() + } + }.launchIn(GlobalScope) + } + + fun registerWatch(mangaId: Long) = + MutableSharedFlow>().also { watching[mangaId] = it }.asSharedFlow() + + fun removeWatch(mangaId: Long) { + watching.remove(mangaId) + } +} diff --git a/src/main/kotlin/ca/gosyer/data/download/model/DownloadChapter.kt b/src/main/kotlin/ca/gosyer/data/download/model/DownloadChapter.kt new file mode 100644 index 00000000..d2457f07 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/download/model/DownloadChapter.kt @@ -0,0 +1,20 @@ +/* + * 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.download.model + +import ca.gosyer.data.models.Chapter +import kotlinx.serialization.Serializable + +@Serializable +data class DownloadChapter( + val chapterIndex: Int, + val mangaId: Long, + var state: DownloadState = DownloadState.Queued, + var progress: Float = 0f, + var tries: Int = 0, + var chapter: Chapter? = null, +) diff --git a/src/main/kotlin/ca/gosyer/data/download/model/DownloadState.kt b/src/main/kotlin/ca/gosyer/data/download/model/DownloadState.kt new file mode 100644 index 00000000..11a2cea0 --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/download/model/DownloadState.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.download.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class DownloadState(val state: Int) { + Queued(0), + Downloading(1), + Finished(2), + Error(3), +} diff --git a/src/main/kotlin/ca/gosyer/data/download/model/DownloadStatus.kt b/src/main/kotlin/ca/gosyer/data/download/model/DownloadStatus.kt new file mode 100644 index 00000000..c3f0c77d --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/download/model/DownloadStatus.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.download.model + +import kotlinx.serialization.Serializable + +@Serializable +data class DownloadStatus( + val status: DownloaderStatus, + val queue: List, +) diff --git a/src/main/kotlin/ca/gosyer/data/download/model/DownloaderStatus.kt b/src/main/kotlin/ca/gosyer/data/download/model/DownloaderStatus.kt new file mode 100644 index 00000000..27bf6c0e --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/download/model/DownloaderStatus.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.download.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class DownloaderStatus { + Started, + Stopped +} diff --git a/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt b/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt index 228d2fbd..39387edf 100644 --- a/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt +++ b/src/main/kotlin/ca/gosyer/data/server/HttpClient.kt @@ -13,6 +13,7 @@ import io.ktor.client.features.json.JsonFeature import io.ktor.client.features.json.serializer.KotlinxSerializer import io.ktor.client.features.logging.LogLevel import io.ktor.client.features.logging.Logging +import io.ktor.client.features.websocket.WebSockets import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Provider @@ -32,6 +33,7 @@ internal class HttpProvider @Inject constructor() : Provider { } ) } + install(WebSockets) install(Logging) { level = if (BuildConfig.DEBUG) { LogLevel.HEADERS diff --git a/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt b/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt index 72d7962d..e8c7cb53 100644 --- a/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt +++ b/src/main/kotlin/ca/gosyer/data/server/interactions/ChapterInteractionHandler.kt @@ -10,9 +10,11 @@ import ca.gosyer.data.models.Chapter import ca.gosyer.data.models.Manga import ca.gosyer.data.server.Http import ca.gosyer.data.server.ServerPreferences +import ca.gosyer.data.server.requests.deleteDownloadChapterRequest import ca.gosyer.data.server.requests.getChapterQuery import ca.gosyer.data.server.requests.getMangaChaptersQuery import ca.gosyer.data.server.requests.getPageQuery +import ca.gosyer.data.server.requests.queueDownloadChapterRequest import ca.gosyer.data.server.requests.updateChapterRequest import ca.gosyer.util.lang.withIOContext import io.ktor.client.request.HttpRequestBuilder @@ -127,4 +129,28 @@ class ChapterInteractionHandler @Inject constructor( suspend fun getPage(manga: Manga, chapterIndex: Int, pageNum: Int, block: HttpRequestBuilder.() -> Unit) = getPage(manga.id, chapterIndex, pageNum, block) suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int, block: HttpRequestBuilder.() -> Unit) = getPage(manga.id, chapter.index, pageNum, block) + + suspend fun queueChapterDownload(mangaId: Long, chapterIndex: Int) = withIOContext { + client.getRepeat( + serverUrl + queueDownloadChapterRequest(mangaId, chapterIndex) + ) + } + + suspend fun queueChapterDownload(chapter: Chapter) = queueChapterDownload(chapter.mangaId, chapter.index) + + suspend fun queueChapterDownload(manga: Manga, chapterIndex: Int) = queueChapterDownload(manga.id, chapterIndex) + + suspend fun queueChapterDownload(manga: Manga, chapter: Chapter) = queueChapterDownload(manga.id, chapter.index) + + suspend fun deleteChapterDownload(mangaId: Long, chapterIndex: Int) = withIOContext { + client.deleteRepeat( + serverUrl + deleteDownloadChapterRequest(mangaId, chapterIndex) + ) + } + + suspend fun deleteChapterDownload(chapter: Chapter) = deleteChapterDownload(chapter.mangaId, chapter.index) + + suspend fun deleteChapterDownload(manga: Manga, chapterIndex: Int) = deleteChapterDownload(manga.id, chapterIndex) + + suspend fun deleteChapterDownload(manga: Manga, chapter: Chapter) = deleteChapterDownload(manga.id, chapter.index) } diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt index 8dd36f54..cbc6afd7 100644 --- a/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Chapters.kt @@ -21,3 +21,11 @@ fun updateChapterRequest(mangaId: Long, chapterIndex: Int) = @Get fun getPageQuery(mangaId: Long, chapterIndex: Int, index: Int) = "/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$index" + +@Get +fun queueDownloadChapterRequest(mangaId: Long, chapterIndex: Int) = + "/api/v1/download/$mangaId/chapter/$chapterIndex" + +@Delete +fun deleteDownloadChapterRequest(mangaId: Long, chapterIndex: Int) = + "/api/v1/download/$mangaId/chapter/$chapterIndex" diff --git a/src/main/kotlin/ca/gosyer/data/server/requests/Downloads.kt b/src/main/kotlin/ca/gosyer/data/server/requests/Downloads.kt new file mode 100644 index 00000000..a087549b --- /dev/null +++ b/src/main/kotlin/ca/gosyer/data/server/requests/Downloads.kt @@ -0,0 +1,19 @@ +/* + * 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.server.requests + +fun downloadsQuery() = + "/api/v1/downloads" + +fun downloadsStartRequest() = + "/api/v1/downloads/start" + +fun downloadsStopRequest() = + "/api/v1/downloads/stop" + +fun downloadsClearRequest() = + "/api/v1/downloads/clear" diff --git a/src/main/kotlin/ca/gosyer/util/lang/CoroutineExtensions.kt b/src/main/kotlin/ca/gosyer/util/lang/CoroutineExtensions.kt index 5cfc9a63..357abb61 100644 --- a/src/main/kotlin/ca/gosyer/util/lang/CoroutineExtensions.kt +++ b/src/main/kotlin/ca/gosyer/util/lang/CoroutineExtensions.kt @@ -63,7 +63,9 @@ suspend fun withIOContext( fun Throwable.throwIfCancellation() { if (this is CancellationException) throw this } fun Result.throwIfCancellation(): Result { - val exception = exceptionOrNull() - if (exception is CancellationException) throw exception + if (isFailure) { + val exception = exceptionOrNull() + if (exception is CancellationException) throw exception + } return this }