Simple domain module

This commit is contained in:
Syer10
2022-06-30 11:11:01 -04:00
parent 873f3715a6
commit c97528e0bf
33 changed files with 240 additions and 110 deletions

View File

@@ -9,18 +9,14 @@ package ca.gosyer.jui.data
import ca.gosyer.jui.core.di.AppScope
import ca.gosyer.jui.core.prefs.PreferenceStoreFactory
import ca.gosyer.jui.data.catalog.CatalogPreferences
import ca.gosyer.jui.data.download.DownloadService
import ca.gosyer.jui.data.extension.ExtensionPreferences
import ca.gosyer.jui.data.library.LibraryPreferences
import ca.gosyer.jui.data.library.LibraryUpdateService
import ca.gosyer.jui.data.migration.MigrationPreferences
import ca.gosyer.jui.data.migration.Migrations
import ca.gosyer.jui.data.reader.ReaderPreferences
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.HttpProvider
import ca.gosyer.jui.data.server.ServerPreferences
import ca.gosyer.jui.data.ui.UiPreferences
import ca.gosyer.jui.data.update.UpdateChecker
import ca.gosyer.jui.data.update.UpdatePreferences
import me.tatarka.inject.annotations.Provides
@@ -29,14 +25,6 @@ actual interface DataComponent {
val httpProvider: HttpProvider
val downloadService: DownloadService
val libraryUpdateService: LibraryUpdateService
val migrations: Migrations
val updateChecker: UpdateChecker
val http: Http
val serverPreferences: ServerPreferences
@@ -102,25 +90,5 @@ actual interface DataComponent {
val httpFactory: Http
get() = httpProvider.get(serverPreferences)
@get:AppScope
@get:Provides
val libraryUpdateServiceFactory: LibraryUpdateService
get() = LibraryUpdateService(serverPreferences, http)
@get:AppScope
@get:Provides
val downloadServiceFactory: DownloadService
get() = DownloadService(serverPreferences, http)
@get:AppScope
@get:Provides
val migrationsFactory: Migrations
get() = Migrations(migrationPreferences, readerPreferences)
@get:AppScope
@get:Provides
val updateCheckerFactory: UpdateChecker
get() = UpdateChecker(updatePreferences, http)
companion object
}

View File

@@ -1,112 +0,0 @@
/*
* 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.jui.data.base
import ca.gosyer.jui.core.lang.throwIfCancellation
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.ServerPreferences
import io.ktor.client.plugins.websocket.ws
import io.ktor.http.URLProtocol
import io.ktor.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.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
import org.lighthousegames.logging.logging
@OptIn(DelicateCoroutinesApi::class)
abstract class WebsocketService(
protected val serverPreferences: ServerPreferences,
protected val client: Http
) {
protected val json = Json {
ignoreUnknownKeys = true
}
protected abstract val _status: MutableStateFlow<Status>
protected val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope)
private var errorConnectionCount = 0
private var job: Job? = null
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("Finish")
}
runCatching {
client.ws(
host = serverUrl.host,
port = serverUrl.port,
path = serverUrl.encodedPath + query,
request = {
if (serverUrl.port == 443) {
url.protocol = URLProtocol.WSS
url.port = serverUrl.port
}
}
) {
errorConnectionCount = 0
_status.value = Status.RUNNING
send(Frame.Text("STATUS"))
incoming.receiveAsFlow()
.filterIsInstance<Frame.Text>()
.mapLatest(::onReceived)
.catch {
log.warn(it) { "Error running websocket" }
}
.collect()
}
}.throwIfCancellation().isFailure.let {
_status.value = Status.STARTING
if (it) errorConnectionCount++
}
}
}
.catch {
_status.value = Status.STOPPED
log.warn(it) { "Error while running websocket service" }
}
.launchIn(GlobalScope)
}
abstract val query: String
abstract suspend fun onReceived(frame: Frame.Text)
enum class Status {
STARTING,
RUNNING,
STOPPED
}
enum class Actions {
STOP,
START,
RESTART
}
private companion object {
val log = logging()
}
}

View File

@@ -1,55 +0,0 @@
/*
* 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.jui.data.download
import ca.gosyer.jui.data.base.WebsocketService
import ca.gosyer.jui.data.download.model.DownloadChapter
import ca.gosyer.jui.data.download.model.DownloadStatus
import ca.gosyer.jui.data.download.model.DownloaderStatus
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.ServerPreferences
import ca.gosyer.jui.data.server.requests.downloadsQuery
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import me.tatarka.inject.annotations.Inject
class DownloadService @Inject constructor(
serverPreferences: ServerPreferences,
client: Http
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status>
get() = status
override val query: String
get() = downloadsQuery()
override suspend fun onReceived(frame: Frame.Text) {
val status = json.decodeFromString<DownloadStatus>(frame.readText())
downloaderStatus.value = status.status
downloadQueue.value = status.queue
}
companion object {
val status = MutableStateFlow(Status.STARTING)
val downloadQueue = MutableStateFlow(emptyList<DownloadChapter>())
val downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped)
fun registerWatch(mangaId: Long) =
downloadQueue
.map {
it.filter { it.mangaId == mangaId }
}
fun registerWatches(mangaIds: Set<Long>) =
downloadQueue
.map {
it.filter { it.mangaId in mangaIds }
}
}
}

View File

@@ -1,39 +0,0 @@
/*
* 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.jui.data.library
import ca.gosyer.jui.data.base.WebsocketService
import ca.gosyer.jui.data.library.model.UpdateStatus
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.ServerPreferences
import ca.gosyer.jui.data.server.requests.updatesQuery
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.decodeFromString
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class LibraryUpdateService @Inject constructor(
serverPreferences: ServerPreferences,
client: Http
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status> = MutableStateFlow(Status.STARTING)
override val query: String
get() = updatesQuery()
override suspend fun onReceived(frame: Frame.Text) {
val status = json.decodeFromString<UpdateStatus>(frame.readText())
log.info { status }
}
private companion object {
private val log = logging()
}
}

View File

@@ -1,28 +0,0 @@
/*
* 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.jui.data.migration
import ca.gosyer.jui.data.build.BuildKonfig
import ca.gosyer.jui.data.reader.ReaderPreferences
import me.tatarka.inject.annotations.Inject
class Migrations @Inject constructor(
private val migrationPreferences: MigrationPreferences,
private val readerPreferences: ReaderPreferences
) {
fun runMigrations() {
val code = migrationPreferences.version().get()
if (code <= 0) {
readerPreferences.modes().get().forEach {
readerPreferences.getMode(it).direction().delete()
}
migrationPreferences.version().set(BuildKonfig.MIGRATION_CODE)
return
}
}
}

View File

@@ -1,91 +0,0 @@
/*
* 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.jui.data.reader
import ca.gosyer.jui.core.prefs.getAsFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
class ReaderModeWatch(
private val readerPreferences: ReaderPreferences,
private val scope: CoroutineScope,
private val mode: StateFlow<String> = readerPreferences.mode().stateIn(scope),
initialPreferences: ReaderModePreferences = readerPreferences.getMode(
mode.value
)
) {
private val preferenceJobs = mutableListOf<Job>()
val direction = MutableStateFlow(initialPreferences.direction().get())
val continuous = MutableStateFlow(initialPreferences.continuous().get())
val padding = MutableStateFlow(initialPreferences.padding().get())
val imageScale = MutableStateFlow(initialPreferences.imageScale().get())
val fitSize = MutableStateFlow(initialPreferences.fitSize().get())
val maxSize = MutableStateFlow(initialPreferences.maxSize().get())
val navigationMode = MutableStateFlow(initialPreferences.navigationMode().get())
init {
setupJobs(mode.value)
mode
.mapLatest { mode ->
setupJobs(mode)
}
.launchIn(scope)
}
private fun setupJobs(mode: String) {
preferenceJobs.forEach {
it.cancel()
}
preferenceJobs.clear()
val preferences = readerPreferences.getMode(mode)
preferenceJobs += preferences.direction()
.getAsFlow {
direction.value = it
}
.launchIn(scope)
preferenceJobs += preferences.continuous()
.getAsFlow {
continuous.value = it
}
.launchIn(scope)
preferenceJobs += preferences.padding()
.getAsFlow {
padding.value = it
}
.launchIn(scope)
preferenceJobs += preferences.imageScale()
.getAsFlow {
imageScale.value = it
}
.launchIn(scope)
preferenceJobs += preferences.fitSize()
.getAsFlow {
fitSize.value = it
}
.launchIn(scope)
preferenceJobs += preferences.maxSize()
.getAsFlow {
maxSize.value = it
}
.launchIn(scope)
preferenceJobs += preferences.navigationMode()
.getAsFlow {
navigationMode.value = it
}
.launchIn(scope)
}
}

View File

@@ -1,75 +0,0 @@
/*
* 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.jui.data.update
import ca.gosyer.jui.core.lang.IO
import ca.gosyer.jui.data.build.BuildKonfig
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.update.model.GithubRelease
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import me.tatarka.inject.annotations.Inject
class UpdateChecker @Inject constructor(
private val updatePreferences: UpdatePreferences,
private val client: Http
) {
fun checkForUpdates(manualFetch: Boolean) = flow {
if (!manualFetch && !updatePreferences.enabled().get()) return@flow
val latestRelease = client.get(
"https://api.github.com/repos/$GITHUB_REPO/releases/latest"
).body<GithubRelease>()
if (isNewVersion(latestRelease.version)) {
emit(Update.UpdateFound(latestRelease))
} else {
emit(Update.NoUpdatesFound)
}
}.flowOn(Dispatchers.IO)
sealed class Update {
data class UpdateFound(val release: GithubRelease) : Update()
object NoUpdatesFound : Update()
}
// Thanks to Tachiyomi for inspiration
private fun isNewVersion(versionTag: String): Boolean {
// Removes prefixes like "r" or "v"
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
return if (BuildKonfig.IS_PREVIEW) {
// Preview builds: based on releases in "Suwayomi/Tachidesk-JUI-preview" repo
// tagged as something like "r123"
newVersion.toInt() > BuildKonfig.PREVIEW_BUILD
} else {
// Release builds: based on releases in "Suwayomi/Tachidesk-JUI" repo
// tagged as something like "v1.1.2"
newVersion != BuildKonfig.VERSION
}
}
companion object {
private val GITHUB_REPO = if (BuildKonfig.IS_PREVIEW) {
"Suwayomi/Tachidesk-JUI-preview"
} else {
"Suwayomi/Tachidesk-JUI"
}
private val RELEASE_TAG: String by lazy {
if (BuildKonfig.IS_PREVIEW) {
"r${BuildKonfig.PREVIEW_BUILD}"
} else {
"v${BuildKonfig.VERSION}"
}
}
val RELEASE_URL = "https://github.com/$GITHUB_REPO/releases/tag/$RELEASE_TAG"
}
}

View File

@@ -9,20 +9,15 @@ package ca.gosyer.jui.data
import ca.gosyer.jui.core.di.AppScope
import ca.gosyer.jui.core.prefs.PreferenceStoreFactory
import ca.gosyer.jui.data.catalog.CatalogPreferences
import ca.gosyer.jui.data.download.DownloadService
import ca.gosyer.jui.data.extension.ExtensionPreferences
import ca.gosyer.jui.data.library.LibraryPreferences
import ca.gosyer.jui.data.library.LibraryUpdateService
import ca.gosyer.jui.data.migration.MigrationPreferences
import ca.gosyer.jui.data.migration.Migrations
import ca.gosyer.jui.data.reader.ReaderPreferences
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.HttpProvider
import ca.gosyer.jui.data.server.ServerHostPreferences
import ca.gosyer.jui.data.server.ServerPreferences
import ca.gosyer.jui.data.server.ServerService
import ca.gosyer.jui.data.ui.UiPreferences
import ca.gosyer.jui.data.update.UpdateChecker
import ca.gosyer.jui.data.update.UpdatePreferences
import me.tatarka.inject.annotations.Provides
@@ -31,16 +26,6 @@ actual interface DataComponent {
val httpProvider: HttpProvider
val downloadService: DownloadService
val libraryUpdateService: LibraryUpdateService
val migrations: Migrations
val updateChecker: UpdateChecker
val serverService: ServerService
val http: Http
val serverHostPreferences: ServerHostPreferences
@@ -113,30 +98,5 @@ actual interface DataComponent {
val httpFactory: Http
get() = httpProvider.get(serverPreferences)
@get:AppScope
@get:Provides
val serverServiceFactory: ServerService
get() = ServerService(serverHostPreferences)
@get:AppScope
@get:Provides
val libraryUpdateServiceFactory: LibraryUpdateService
get() = LibraryUpdateService(serverPreferences, http)
@get:AppScope
@get:Provides
val downloadServiceFactory: DownloadService
get() = DownloadService(serverPreferences, http)
@get:AppScope
@get:Provides
val migrationsFactory: Migrations
get() = Migrations(migrationPreferences, readerPreferences)
@get:AppScope
@get:Provides
val updateCheckerFactory: UpdateChecker
get() = UpdateChecker(updatePreferences, http)
companion object
}

View File

@@ -1,203 +0,0 @@
/*
* 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.jui.data.server
import ca.gosyer.jui.core.io.copyTo
import ca.gosyer.jui.core.io.userDataDir
import ca.gosyer.jui.core.lang.withIOContext
import ca.gosyer.jui.data.build.BuildKonfig
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import me.tatarka.inject.annotations.Inject
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import okio.buffer
import okio.source
import org.lighthousegames.logging.logging
import java.io.File.pathSeparatorChar
import java.io.IOException
import java.io.Reader
import java.util.jar.Attributes
import java.util.jar.JarInputStream
import kotlin.concurrent.thread
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.isExecutable
@OptIn(DelicateCoroutinesApi::class)
class ServerService @Inject constructor(
private val serverHostPreferences: ServerHostPreferences
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val host = serverHostPreferences.host().stateIn(GlobalScope)
private val _initialized = MutableStateFlow(
if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
}
)
val initialized = _initialized.asStateFlow()
private var process: Process? = null
fun startAnyway() {
_initialized.value = ServerResult.UNUSED
}
@Throws(IOException::class)
private suspend fun copyJar(jarFile: Path) {
javaClass.getResourceAsStream("/Tachidesk.jar")?.source()
?.copyTo(FileSystem.SYSTEM.sink(jarFile).buffer())
}
private fun getJavaFromPath(javaPath: Path): String? {
val javaExeFile = javaPath.resolve("java.exe").toNioPath()
val javaUnixFile = javaPath.resolve("java").toNioPath()
return when {
javaExeFile.exists() && javaExeFile.isExecutable() -> javaExeFile.absolutePathString()
javaUnixFile.exists() && javaUnixFile.isExecutable() -> javaUnixFile.absolutePathString()
else -> null
}
}
private fun getRuntimeJava(): String? {
return System.getProperty("java.home")?.let { getJavaFromPath(it.toPath().resolve("bin")) }
}
private fun getPossibleJava(): String? {
return System.getProperty("java.library.path")?.split(pathSeparatorChar)
.orEmpty()
.asSequence()
.mapNotNull {
val file = it.toPath()
if (file.toString().contains("java") || file.toString().contains("jdk")) {
if (file.name.equals("bin", true)) {
file
} else file.resolve("bin")
} else null
}
.mapNotNull { getJavaFromPath(it) }
.firstOrNull()
}
private suspend fun runService() {
process?.destroy()
process?.waitFor()
_initialized.value = if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
return
}
val handler = CoroutineExceptionHandler { _, throwable ->
log.error(throwable) { "Error launching Tachidesk.jar" }
if (_initialized.value == ServerResult.STARTING || _initialized.value == ServerResult.STARTED) {
_initialized.value = ServerResult.FAILED
}
}
withContext(handler) {
val jarFile = userDataDir / "Tachidesk.jar"
if (!FileSystem.SYSTEM.exists(jarFile)) {
log.info { "Copying server to resources" }
withIOContext { copyJar(jarFile) }
} else {
try {
val jarVersion = withIOContext {
JarInputStream(FileSystem.SYSTEM.source(jarFile).buffer().inputStream()).use { jar ->
jar.manifest?.mainAttributes?.getValue(Attributes.Name.IMPLEMENTATION_VERSION)?.toIntOrNull()
}
}
if (jarVersion != BuildKonfig.SERVER_CODE) {
log.info { "Updating server file from resources" }
withIOContext { copyJar(jarFile) }
}
} catch (e: IOException) {
log.error(e) {
"Error accessing server jar, cannot update server, ${BuildKonfig.NAME} may not work properly"
}
}
}
val javaPath = getRuntimeJava() ?: getPossibleJava() ?: "java"
log.info { "Starting server with $javaPath" }
val properties = serverHostPreferences.properties()
log.info { "Using server properties:\n" + properties.joinToString(separator = "\n") }
withIOContext {
val reader: Reader
process = ProcessBuilder(javaPath, *properties, "-jar", jarFile.toString())
.redirectErrorStream(true)
.start()
.also {
reader = it.inputStream.reader()
}
log.info { "Server started successfully" }
val log = logging("Server")
reader.forEachLine {
if (_initialized.value == ServerResult.STARTING) {
when {
it.contains("Javalin started") ->
_initialized.value = ServerResult.STARTED
it.contains("Javalin has stopped") ->
_initialized.value = ServerResult.FAILED
}
}
log.info { it }
}
if (_initialized.value == ServerResult.STARTING) {
_initialized.value = ServerResult.FAILED
}
log.info { "Server closed" }
val exitVal = process?.waitFor()
log.info { "Process exitValue: $exitVal" }
process = null
}
}
}
fun startServer() {
scope.coroutineContext.cancelChildren()
host
.mapLatest {
runService()
}
.launchIn(scope)
}
init {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
}
)
}
enum class ServerResult {
UNUSED,
STARTING,
STARTED,
FAILED;
}
private companion object {
private val log = logging()
}
}