Implement Downloads GQL and fix url changes requiring restarts.

This commit is contained in:
Syer10
2025-10-14 14:19:47 -04:00
parent bef31a01eb
commit 5b7228c687
41 changed files with 688 additions and 229 deletions

View File

@@ -23,17 +23,12 @@ import ca.gosyer.jui.core.prefs.getAsFlow
import ca.gosyer.jui.domain.base.WebsocketService.Actions import ca.gosyer.jui.domain.base.WebsocketService.Actions
import ca.gosyer.jui.domain.base.WebsocketService.Status import ca.gosyer.jui.domain.base.WebsocketService.Status
import ca.gosyer.jui.domain.download.model.DownloadState import ca.gosyer.jui.domain.download.model.DownloadState
import ca.gosyer.jui.domain.download.model.DownloadStatus
import ca.gosyer.jui.domain.download.service.DownloadService import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.domain.download.service.DownloadService.Companion.status import ca.gosyer.jui.domain.download.service.DownloadService.Companion.status
import ca.gosyer.jui.i18n.MR import ca.gosyer.jui.i18n.MR
import com.diamondedge.logging.logging import com.diamondedge.logging.logging
import dev.icerock.moko.resources.desc.desc import dev.icerock.moko.resources.desc.desc
import dev.icerock.moko.resources.format import dev.icerock.moko.resources.format
import io.ktor.client.plugins.websocket.ws
import io.ktor.http.URLProtocol
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,13 +37,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.job import kotlinx.coroutines.job
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.util.regex.Pattern import java.util.regex.Pattern
@@ -154,31 +145,12 @@ class AndroidDownloadService : Service() {
throw CancellationException() throw CancellationException()
} }
runCatching { runCatching {
client.ws( appComponent.downloadService
host = serverUrl.host, .getSubscription()
port = serverUrl.port, .onEach {
path = serverUrl.encodedPath + "/api/v1/downloads", onReceived()
request = { }
if (serverUrl.protocol == URLProtocol.HTTPS) { .collect()
url.protocol = URLProtocol.WSS
}
},
) {
errorConnectionCount = 0
status.value = Status.RUNNING
send(Frame.Text("STATUS"))
incoming.receiveAsFlow()
.filterIsInstance<Frame.Text>()
.map { json.decodeFromString<DownloadStatus>(it.readText()) }
.distinctUntilChanged()
.drop(1)
.mapLatest(::onReceived)
.catch {
log.warn(it) { "Error running downloader" }
}
.collect()
}
}.throwIfCancellation().isFailure.let { }.throwIfCancellation().isFailure.let {
status.value = Status.STARTING status.value = Status.STARTING
if (it) errorConnectionCount++ if (it) errorConnectionCount++
@@ -193,13 +165,11 @@ class AndroidDownloadService : Service() {
.launchIn(ioScope) .launchIn(ioScope)
} }
private fun onReceived(status: DownloadStatus) { private fun onReceived() {
DownloadService.downloaderStatus.value = status.status val downloadingChapter = DownloadService.downloadQueue.value.lastOrNull { it.state == DownloadState.DOWNLOADING }
DownloadService.downloadQueue.value = status.queue
val downloadingChapter = status.queue.lastOrNull { it.state == DownloadState.Downloading }
if (downloadingChapter != null) { if (downloadingChapter != null) {
val notification = with(progressNotificationBuilder) { val notification = with(progressNotificationBuilder) {
val max = downloadingChapter.chapter.pageCount ?: 0 val max = downloadingChapter.chapter.pageCount
val current = (max * downloadingChapter.progress).toInt().coerceIn(0, max) val current = (max * downloadingChapter.progress).toInt().coerceIn(0, max)
setProgress(max, current, false) setProgress(max, current, false)

View File

@@ -39,3 +39,29 @@ mutation EnqueueChapterDownloads($ids: [Int!]!) {
clientMutationId clientMutationId
} }
} }
subscription DownloadStatusChanged($maxUpdates: Int = 30) {
downloadStatusChanged(input: { maxUpdates: $maxUpdates }) {
initial {
...DownloadFragment
}
updates {
type
download {
...DownloadFragment
}
}
state
omittedUpdates
}
}
query DownloadStatus {
downloadStatus {
state
queue {
...DownloadFragment
}
}
}

View File

@@ -0,0 +1,17 @@
fragment DownloadFragment on DownloadType {
chapter {
id
name
pageCount
}
manga {
id
title
thumbnailUrl
thumbnailUrlLastFetched
}
position
progress
state
tries
}

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data package ca.gosyer.jui.data
import ca.gosyer.jui.core.di.AppScope
import ca.gosyer.jui.data.backup.BackupRepositoryImpl import ca.gosyer.jui.data.backup.BackupRepositoryImpl
import ca.gosyer.jui.data.category.CategoryRepositoryImpl import ca.gosyer.jui.data.category.CategoryRepositoryImpl
import ca.gosyer.jui.data.chapter.ChapterRepositoryImpl import ca.gosyer.jui.data.chapter.ChapterRepositoryImpl
@@ -31,100 +32,125 @@ import ca.gosyer.jui.domain.settings.service.SettingsRepository
import ca.gosyer.jui.domain.source.service.SourceRepository import ca.gosyer.jui.domain.source.service.SourceRepository
import ca.gosyer.jui.domain.updates.service.UpdatesRepository import ca.gosyer.jui.domain.updates.service.UpdatesRepository
import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.annotations.ApolloExperimental
import com.apollographql.apollo.network.ws.GraphQLWsProtocol import com.apollographql.apollo.network.ws.GraphQLWsProtocol
import com.apollographql.ktor.ktorClient import com.apollographql.ktor.ktorClient
import io.ktor.client.HttpClient
import io.ktor.http.URLBuilder import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.http.appendPathSegments import io.ktor.http.appendPathSegments
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import me.tatarka.inject.annotations.Provides import me.tatarka.inject.annotations.Provides
interface DataComponent : SharedDataComponent { typealias ApolloAppClient = StateFlow<ApolloClient>
@Provides
fun apolloClient( private fun getApolloClient(
http: Http, httpClient: HttpClient,
serverPreferences: ServerPreferences, serverUrl: Url,
) = ApolloClient.Builder() ): ApolloClient {
.serverUrl( val url = URLBuilder(serverUrl)
URLBuilder(serverPreferences.serverUrl().get()) .appendPathSegments("api", "graphql")
.appendPathSegments("api", "graphql") .buildString()
.buildString(), return ApolloClient.Builder()
) .serverUrl(url)
.ktorClient(http) .ktorClient(httpClient)
.wsProtocol(GraphQLWsProtocol.Factory()) .wsProtocol(GraphQLWsProtocol.Factory(pingIntervalMillis = 30))
.dispatcher(Dispatchers.IO) .dispatcher(Dispatchers.IO)
.build() .build()
}
interface DataComponent : SharedDataComponent {
@OptIn(ApolloExperimental::class)
@Provides
@AppScope
fun apolloAppClient(
http: Http,
serverPreferences: ServerPreferences,
): ApolloAppClient =
http
.map { getApolloClient(it, serverPreferences.serverUrl().get()) }
.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getApolloClient(http.value, serverPreferences.serverUrl().get()),
)
@Provides @Provides
fun settingsRepository(apolloClient: ApolloClient): SettingsRepository = SettingsRepositoryImpl(apolloClient) fun settingsRepository(apolloAppClient: ApolloAppClient): SettingsRepository = SettingsRepositoryImpl(apolloAppClient)
@Provides @Provides
fun categoryRepository( fun categoryRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): CategoryRepository = CategoryRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): CategoryRepository = CategoryRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun chapterRepository( fun chapterRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): ChapterRepository = ChapterRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): ChapterRepository = ChapterRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun downloadRepository( fun downloadRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): DownloadRepository = DownloadRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): DownloadRepository = DownloadRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun extensionRepository( fun extensionRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): ExtensionRepository = ExtensionRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): ExtensionRepository = ExtensionRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun globalRepository( fun globalRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): GlobalRepository = GlobalRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): GlobalRepository = GlobalRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun libraryRepository( fun libraryRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): LibraryRepository = LibraryRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): LibraryRepository = LibraryRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun mangaRepository( fun mangaRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): MangaRepository = MangaRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): MangaRepository = MangaRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun sourceRepository( fun sourceRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): SourceRepository = SourceRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): SourceRepository = SourceRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun updatesRepository( fun updatesRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): UpdatesRepository = UpdatesRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): UpdatesRepository = UpdatesRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
@Provides @Provides
fun backupRepository( fun backupRepository(
apolloClient: ApolloClient, apolloAppClient: ApolloAppClient,
http: Http, http: Http,
serverPreferences: ServerPreferences, serverPreferences: ServerPreferences,
): BackupRepository = BackupRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get()) ): BackupRepository = BackupRepositoryImpl(apolloAppClient, http, serverPreferences.serverUrl().get())
} }

View File

@@ -1,6 +1,7 @@
package ca.gosyer.jui.data.backup package ca.gosyer.jui.data.backup
import ca.gosyer.jui.core.io.toSource import ca.gosyer.jui.core.io.toSource
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.CreateBackupMutation import ca.gosyer.jui.data.graphql.CreateBackupMutation
import ca.gosyer.jui.data.graphql.RestoreBackupMutation import ca.gosyer.jui.data.graphql.RestoreBackupMutation
import ca.gosyer.jui.data.graphql.RestoreStatusQuery import ca.gosyer.jui.data.graphql.RestoreStatusQuery
@@ -24,10 +25,13 @@ import okio.Source
import okio.buffer import okio.buffer
class BackupRepositoryImpl( class BackupRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : BackupRepository { ) : BackupRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun validateBackup(source: Source): Flow<BackupValidationResult> = override fun validateBackup(source: Source): Flow<BackupValidationResult> =
apolloClient.query( apolloClient.query(
ValidateBackupQuery( ValidateBackupQuery(
@@ -89,7 +93,7 @@ class BackupRepositoryImpl(
.toFlow() .toFlow()
.map { .map {
val url = it.dataAssertNoErrors.createBackup.url val url = it.dataAssertNoErrors.createBackup.url
val response = http.get( val response = http.value.get(
Url("$serverUrl$url"), Url("$serverUrl$url"),
) )
val fileName = response.headers["content-disposition"]!! val fileName = response.headers["content-disposition"]!!

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.category package ca.gosyer.jui.data.category
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.AddMangaToCategoriesMutation import ca.gosyer.jui.data.graphql.AddMangaToCategoriesMutation
import ca.gosyer.jui.data.graphql.CreateCategoryMutation import ca.gosyer.jui.data.graphql.CreateCategoryMutation
import ca.gosyer.jui.data.graphql.DeleteCategoryMutation import ca.gosyer.jui.data.graphql.DeleteCategoryMutation
@@ -29,10 +30,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class CategoryRepositoryImpl( class CategoryRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : CategoryRepository { ) : CategoryRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getMangaCategories(mangaId: Long): Flow<List<Category>> = override fun getMangaCategories(mangaId: Long): Flow<List<Category>> =
apolloClient.query( apolloClient.query(
GetMangaCategoriesQuery(mangaId.toInt()), GetMangaCategoriesQuery(mangaId.toInt()),

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.chapter package ca.gosyer.jui.data.chapter
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.DeleteDownloadedChapterMutation import ca.gosyer.jui.data.graphql.DeleteDownloadedChapterMutation
import ca.gosyer.jui.data.graphql.DeleteDownloadedChaptersMutation import ca.gosyer.jui.data.graphql.DeleteDownloadedChaptersMutation
import ca.gosyer.jui.data.graphql.FetchChapterPagesMutation import ca.gosyer.jui.data.graphql.FetchChapterPagesMutation
@@ -29,10 +30,13 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class ChapterRepositoryImpl( class ChapterRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : ChapterRepository { ) : ChapterRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getChapter(chapterId: Long): Flow<Chapter> = override fun getChapter(chapterId: Long): Flow<Chapter> =
apolloClient.query( apolloClient.query(
GetChapterQuery(chapterId.toInt()), GetChapterQuery(chapterId.toInt()),
@@ -164,7 +168,7 @@ class ChapterRepositoryImpl(
): Flow<ByteArray> { ): Flow<ByteArray> {
val realUrl = Url("$serverUrl$url") val realUrl = Url("$serverUrl$url")
return flow { emit(http.get(realUrl, block).readRawBytes()) } return flow { emit(http.value.get(realUrl, block).readRawBytes()) }
} }
companion object { companion object {

View File

@@ -6,25 +6,44 @@
package ca.gosyer.jui.data.download package ca.gosyer.jui.data.download
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.ClearDownloaderMutation import ca.gosyer.jui.data.graphql.ClearDownloaderMutation
import ca.gosyer.jui.data.graphql.DequeueChapterDownloadMutation import ca.gosyer.jui.data.graphql.DequeueChapterDownloadMutation
import ca.gosyer.jui.data.graphql.DownloadStatusChangedSubscription
import ca.gosyer.jui.data.graphql.DownloadStatusQuery
import ca.gosyer.jui.data.graphql.EnqueueChapterDownloadMutation import ca.gosyer.jui.data.graphql.EnqueueChapterDownloadMutation
import ca.gosyer.jui.data.graphql.EnqueueChapterDownloadsMutation import ca.gosyer.jui.data.graphql.EnqueueChapterDownloadsMutation
import ca.gosyer.jui.data.graphql.ReorderChapterDownloadMutation import ca.gosyer.jui.data.graphql.ReorderChapterDownloadMutation
import ca.gosyer.jui.data.graphql.StartDownloaderMutation import ca.gosyer.jui.data.graphql.StartDownloaderMutation
import ca.gosyer.jui.data.graphql.StopDownloaderMutation import ca.gosyer.jui.data.graphql.StopDownloaderMutation
import ca.gosyer.jui.data.graphql.fragment.DownloadFragment
import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.model.DownloadManga
import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloadState
import ca.gosyer.jui.domain.download.model.DownloadStatus
import ca.gosyer.jui.domain.download.model.DownloadUpdate
import ca.gosyer.jui.domain.download.model.DownloadUpdateType
import ca.gosyer.jui.domain.download.model.DownloadUpdates
import ca.gosyer.jui.domain.download.model.DownloaderState
import ca.gosyer.jui.domain.download.service.DownloadRepository import ca.gosyer.jui.domain.download.service.DownloadRepository
import ca.gosyer.jui.domain.server.Http import ca.gosyer.jui.domain.server.Http
import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.ApolloClient
import io.ktor.http.Url import io.ktor.http.Url
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import ca.gosyer.jui.data.graphql.type.DownloadState as GraphQLDownloadState
import ca.gosyer.jui.data.graphql.type.DownloadUpdateType as GraphQLDownloadUpdateType
import ca.gosyer.jui.data.graphql.type.DownloaderState as GraphQLDownloaderState
class DownloadRepositoryImpl( class DownloadRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : DownloadRepository { ) : DownloadRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun startDownloading(): Flow<Unit> = override fun startDownloading(): Flow<Unit> =
apolloClient.mutation( apolloClient.mutation(
StartDownloaderMutation(), StartDownloaderMutation(),
@@ -90,4 +109,82 @@ class DownloadRepositoryImpl(
.map { .map {
it.dataAssertNoErrors it.dataAssertNoErrors
} }
override fun downloadSubscription(): Flow<DownloadUpdates> =
apolloClient.subscription(
DownloadStatusChangedSubscription(),
)
.toFlow()
.map {
val data = it.dataAssertNoErrors.downloadStatusChanged
DownloadUpdates(
data.initial?.map { it.downloadFragment.toDownloadQueueItem() },
data.omittedUpdates,
data.state.toClient(),
data.updates.map {
DownloadUpdate(
when (it.type) {
GraphQLDownloadUpdateType.QUEUED -> DownloadUpdateType.QUEUED
GraphQLDownloadUpdateType.DEQUEUED -> DownloadUpdateType.DEQUEUED
GraphQLDownloadUpdateType.PAUSED -> DownloadUpdateType.PAUSED
GraphQLDownloadUpdateType.STOPPED -> DownloadUpdateType.STOPPED
GraphQLDownloadUpdateType.PROGRESS -> DownloadUpdateType.PROGRESS
GraphQLDownloadUpdateType.FINISHED -> DownloadUpdateType.FINISHED
GraphQLDownloadUpdateType.ERROR -> DownloadUpdateType.ERROR
GraphQLDownloadUpdateType.POSITION -> DownloadUpdateType.POSITION
GraphQLDownloadUpdateType.UNKNOWN__ -> null
},
it.download.downloadFragment.toDownloadQueueItem(),
)
},
)
}
override fun downloadStatus(): Flow<DownloadStatus> =
apolloClient.query(
DownloadStatusQuery(),
)
.toFlow()
.map {
val data = it.dataAssertNoErrors.downloadStatus
DownloadStatus(
data.state.toClient(),
data.queue.map { it.downloadFragment.toDownloadQueueItem() },
)
}
companion object {
fun DownloadFragment.toDownloadQueueItem(): DownloadQueueItem =
DownloadQueueItem(
position = this.position,
progress = this.progress.toFloat(),
state = when (this.state) {
GraphQLDownloadState.QUEUED -> DownloadState.QUEUED
GraphQLDownloadState.DOWNLOADING -> DownloadState.DOWNLOADING
GraphQLDownloadState.FINISHED -> DownloadState.FINISHED
GraphQLDownloadState.ERROR -> DownloadState.ERROR
GraphQLDownloadState.UNKNOWN__ -> DownloadState.ERROR
},
tries = this.tries,
chapter = DownloadChapter(
chapter.id.toLong(),
chapter.name,
chapter.pageCount,
),
manga = DownloadManga(
manga.id.toLong(),
manga.title,
manga.thumbnailUrl,
manga.thumbnailUrlLastFetched ?: 0,
),
)
fun GraphQLDownloaderState.toClient(): DownloaderState =
when (this) {
GraphQLDownloaderState.STARTED -> DownloaderState.STARTED
GraphQLDownloaderState.STOPPED -> DownloaderState.STOPPED
GraphQLDownloaderState.UNKNOWN__ -> DownloaderState.STOPPED
}
}
} }

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.extension package ca.gosyer.jui.data.extension
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.FetchExtensionsMutation import ca.gosyer.jui.data.graphql.FetchExtensionsMutation
import ca.gosyer.jui.data.graphql.InstallExtensionMutation import ca.gosyer.jui.data.graphql.InstallExtensionMutation
import ca.gosyer.jui.data.graphql.InstallExternalExtensionMutation import ca.gosyer.jui.data.graphql.InstallExternalExtensionMutation
@@ -23,10 +24,13 @@ import kotlinx.coroutines.flow.map
import okio.Source import okio.Source
class ExtensionRepositoryImpl( class ExtensionRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : ExtensionRepository { ) : ExtensionRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getExtensionList(): Flow<List<Extension>> = override fun getExtensionList(): Flow<List<Extension>> =
apolloClient.mutation( apolloClient.mutation(
FetchExtensionsMutation(), FetchExtensionsMutation(),

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.global package ca.gosyer.jui.data.global
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.GetGlobalMetaQuery import ca.gosyer.jui.data.graphql.GetGlobalMetaQuery
import ca.gosyer.jui.data.graphql.SetGlobalMetaMutation import ca.gosyer.jui.data.graphql.SetGlobalMetaMutation
import ca.gosyer.jui.domain.global.model.GlobalMeta import ca.gosyer.jui.domain.global.model.GlobalMeta
@@ -17,10 +18,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class GlobalRepositoryImpl( class GlobalRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : GlobalRepository { ) : GlobalRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getGlobalMeta(): Flow<GlobalMeta> = override fun getGlobalMeta(): Flow<GlobalMeta> =
apolloClient.query( apolloClient.query(
GetGlobalMetaQuery(), GetGlobalMetaQuery(),

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.library package ca.gosyer.jui.data.library
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.SetMangaInLibraryMutation import ca.gosyer.jui.data.graphql.SetMangaInLibraryMutation
import ca.gosyer.jui.domain.library.service.LibraryRepository import ca.gosyer.jui.domain.library.service.LibraryRepository
import ca.gosyer.jui.domain.server.Http import ca.gosyer.jui.domain.server.Http
@@ -9,10 +10,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class LibraryRepositoryImpl( class LibraryRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : LibraryRepository { ) : LibraryRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
fun setMangaInLibrary( fun setMangaInLibrary(
mangaId: Long, mangaId: Long,
inLibrary: Boolean, inLibrary: Boolean,

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.manga package ca.gosyer.jui.data.manga
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.GetMangaLibraryQuery import ca.gosyer.jui.data.graphql.GetMangaLibraryQuery
import ca.gosyer.jui.data.graphql.GetMangaQuery import ca.gosyer.jui.data.graphql.GetMangaQuery
import ca.gosyer.jui.data.graphql.GetThumbnailUrlQuery import ca.gosyer.jui.data.graphql.GetThumbnailUrlQuery
@@ -26,10 +27,13 @@ import ca.gosyer.jui.data.graphql.type.MangaStatus as GqlMangaStatus
import ca.gosyer.jui.data.graphql.type.UpdateStrategy as GqlUpdateStrategy import ca.gosyer.jui.data.graphql.type.UpdateStrategy as GqlUpdateStrategy
class MangaRepositoryImpl( class MangaRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : MangaRepository { ) : MangaRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getManga(mangaId: Long): Flow<Manga> = override fun getManga(mangaId: Long): Flow<Manga> =
apolloClient.query( apolloClient.query(
GetMangaQuery(mangaId.toInt()), GetMangaQuery(mangaId.toInt()),
@@ -70,7 +74,7 @@ class MangaRepositoryImpl(
.toFlow() .toFlow()
.map { .map {
val data = it.dataAssertNoErrors val data = it.dataAssertNoErrors
http.get(data.manga.thumbnailUrl!!).bodyAsChannel() http.value.get(data.manga.thumbnailUrl!!).bodyAsChannel()
} }
override fun updateMangaMeta( override fun updateMangaMeta(

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.settings package ca.gosyer.jui.data.settings
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.AboutServerQuery import ca.gosyer.jui.data.graphql.AboutServerQuery
import ca.gosyer.jui.data.graphql.AllSettingsQuery import ca.gosyer.jui.data.graphql.AllSettingsQuery
import ca.gosyer.jui.data.graphql.SetSettingsMutation import ca.gosyer.jui.data.graphql.SetSettingsMutation
@@ -43,8 +44,11 @@ import ca.gosyer.jui.domain.settings.model.WebUIFlavor as DomainWebUIFlavor
import ca.gosyer.jui.domain.settings.model.WebUIInterface as DomainWebUIInterface import ca.gosyer.jui.domain.settings.model.WebUIInterface as DomainWebUIInterface
class SettingsRepositoryImpl( class SettingsRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
) : SettingsRepository { ) : SettingsRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
private fun SettingsTypeFragment.toSettings() = private fun SettingsTypeFragment.toSettings() =
Settings( Settings(
authMode = authMode.toDomain(), authMode = authMode.toDomain(),

View File

@@ -1,5 +1,6 @@
package ca.gosyer.jui.data.source package ca.gosyer.jui.data.source
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.graphql.FetchLatestMangaMutation import ca.gosyer.jui.data.graphql.FetchLatestMangaMutation
import ca.gosyer.jui.data.graphql.FetchPopularMangaMutation import ca.gosyer.jui.data.graphql.FetchPopularMangaMutation
import ca.gosyer.jui.data.graphql.FetchSearchMangaMutation import ca.gosyer.jui.data.graphql.FetchSearchMangaMutation
@@ -33,10 +34,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class SourceRepositoryImpl( class SourceRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : SourceRepository { ) : SourceRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getSourceList(): Flow<List<Source>> = override fun getSourceList(): Flow<List<Source>> =
apolloClient.query( apolloClient.query(
GetSourceListQuery(), GetSourceListQuery(),

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.data.updates package ca.gosyer.jui.data.updates
import ca.gosyer.jui.data.ApolloAppClient
import ca.gosyer.jui.data.chapter.ChapterRepositoryImpl.Companion.toMangaAndChapter import ca.gosyer.jui.data.chapter.ChapterRepositoryImpl.Companion.toMangaAndChapter
import ca.gosyer.jui.data.graphql.GetChapterUpdatesQuery import ca.gosyer.jui.data.graphql.GetChapterUpdatesQuery
import ca.gosyer.jui.data.graphql.UpdateCategoryMutation import ca.gosyer.jui.data.graphql.UpdateCategoryMutation
@@ -19,10 +20,13 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class UpdatesRepositoryImpl( class UpdatesRepositoryImpl(
private val apolloClient: ApolloClient, private val apolloAppClient: ApolloAppClient,
private val http: Http, private val http: Http,
private val serverUrl: Url, private val serverUrl: Url,
) : UpdatesRepository { ) : UpdatesRepository {
val apolloClient: ApolloClient
get() = apolloAppClient.value
override fun getRecentUpdates(pageNum: Int): Flow<Updates> = override fun getRecentUpdates(pageNum: Int): Flow<Updates> =
apolloClient.query( apolloClient.query(
GetChapterUpdatesQuery(50, pageNum * 50), GetChapterUpdatesQuery(50, pageNum * 50),

View File

@@ -99,7 +99,7 @@ suspend fun main() {
serverService.initialized serverService.initialized
.filter { it == ServerResult.STARTED || it == ServerResult.UNUSED } .filter { it == ServerResult.STARTED || it == ServerResult.UNUSED }
.onEach { .onEach {
appComponent.downloadService.init() appComponent.downloadService.getSubscription().launchIn(GlobalScope)
appComponent.libraryUpdateService.init() appComponent.libraryUpdateService.init()
} }
.launchIn(GlobalScope) .launchIn(GlobalScope)

View File

@@ -118,11 +118,6 @@ interface SharedDomainComponent : CoreComponent {
val libraryUpdateServiceFactory: LibraryUpdateService val libraryUpdateServiceFactory: LibraryUpdateService
get() = LibraryUpdateService(serverPreferences, http) get() = LibraryUpdateService(serverPreferences, http)
@get:AppScope
@get:Provides
val downloadServiceFactory: DownloadService
get() = DownloadService(serverPreferences, http)
@get:AppScope @get:AppScope
@get:Provides @get:Provides
val serverListenersFactory: ServerListeners val serverListenersFactory: ServerListeners

View File

@@ -45,8 +45,8 @@ abstract class WebsocketService(
fun init() { fun init() {
errorConnectionCount = 0 errorConnectionCount = 0
job?.cancel() job?.cancel()
job = serverUrl job = client
.mapLatest { serverUrl -> .mapLatest { client ->
status.value = Status.STARTING status.value = Status.STARTING
while (true) { while (true) {
if (errorConnectionCount > 3) { if (errorConnectionCount > 3) {
@@ -54,6 +54,7 @@ abstract class WebsocketService(
throw CancellationException("Finish") throw CancellationException("Finish")
} }
runCatching { runCatching {
val serverUrl = serverUrl.value
client.ws( client.ws(
host = serverUrl.host, host = serverUrl.host,
port = serverUrl.port, port = serverUrl.port,

View File

@@ -6,19 +6,24 @@
package ca.gosyer.jui.domain.download.model package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Immutable data class DownloadQueueItem(
import ca.gosyer.jui.domain.chapter.model.Chapter val position: Int,
import ca.gosyer.jui.domain.manga.model.Manga val progress: Float,
import kotlinx.serialization.Serializable val state: DownloadState,
val tries: Int,
@Serializable val chapter: DownloadChapter,
@Immutable val manga: DownloadManga
data class DownloadChapter( )
val chapterIndex: Int,
val mangaId: Long, data class DownloadChapter(
val chapter: Chapter, val id: Long,
val manga: Manga, val name: String,
val state: DownloadState = DownloadState.Queued, val pageCount: Int,
val progress: Float = 0f, )
val tries: Int = 0,
data class DownloadManga(
val id: Long,
val title: String,
val thumbnailUrl: String?,
val thumbnailUrlLastFetched: Long = 0,
) )

View File

@@ -6,16 +6,9 @@
package ca.gosyer.jui.domain.download.model package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Stable enum class DownloadState {
import kotlinx.serialization.Serializable QUEUED,
DOWNLOADING,
@Serializable FINISHED,
@Stable ERROR
enum class DownloadState(
val state: Int,
) {
Queued(0),
Downloading(1),
Finished(2),
Error(3),
} }

View File

@@ -6,12 +6,7 @@
package ca.gosyer.jui.domain.download.model package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Immutable
import kotlinx.serialization.Serializable
@Serializable
@Immutable
data class DownloadStatus( data class DownloadStatus(
val status: DownloaderStatus, val status: DownloaderState,
val queue: List<DownloadChapter>, val queue: List<DownloadQueueItem>,
) )

View File

@@ -0,0 +1,17 @@
package ca.gosyer.jui.domain.download.model
data class DownloadUpdate(
val type: DownloadUpdateType? = null,
val download: DownloadQueueItem? = null
)
enum class DownloadUpdateType {
QUEUED,
DEQUEUED,
PAUSED,
STOPPED,
PROGRESS,
FINISHED,
ERROR,
POSITION
}

View File

@@ -0,0 +1,8 @@
package ca.gosyer.jui.domain.download.model
data class DownloadUpdates(
val initial: List<DownloadQueueItem>? = null,
val omittedUpdates: Boolean,
val state: DownloaderState,
val updates: List<DownloadUpdate>? = null
)

View File

@@ -6,12 +6,7 @@
package ca.gosyer.jui.domain.download.model package ca.gosyer.jui.domain.download.model
import androidx.compose.runtime.Stable enum class DownloaderState {
import kotlinx.serialization.Serializable STARTED,
STOPPED
@Serializable
@Stable
enum class DownloaderStatus {
Started,
Stopped,
} }

View File

@@ -6,6 +6,8 @@
package ca.gosyer.jui.domain.download.service package ca.gosyer.jui.domain.download.service
import ca.gosyer.jui.domain.download.model.DownloadStatus
import ca.gosyer.jui.domain.download.model.DownloadUpdates
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface DownloadRepository { interface DownloadRepository {
@@ -25,4 +27,8 @@ interface DownloadRepository {
): Flow<Unit> ): Flow<Unit>
fun batchDownload(chapterIds: List<Long>): Flow<Unit> fun batchDownload(chapterIds: List<Long>): Flow<Unit>
fun downloadSubscription(): Flow<DownloadUpdates>
fun downloadStatus(): Flow<DownloadStatus>
} }

View File

@@ -7,49 +7,144 @@
package ca.gosyer.jui.domain.download.service package ca.gosyer.jui.domain.download.service
import ca.gosyer.jui.domain.base.WebsocketService import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.model.DownloadChapter import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloadStatus import ca.gosyer.jui.domain.download.model.DownloadUpdateType
import ca.gosyer.jui.domain.download.model.DownloaderStatus import ca.gosyer.jui.domain.download.model.DownloaderState
import ca.gosyer.jui.domain.server.Http import com.diamondedge.logging.logging
import ca.gosyer.jui.domain.server.service.ServerPreferences import kotlinx.coroutines.flow.Flow
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
@Inject @Inject
class DownloadService( class DownloadService(
serverPreferences: ServerPreferences, private val downloadRepository: DownloadRepository,
client: Http, ) {
) : WebsocketService(serverPreferences, client) { private val log = logging()
override val status: MutableStateFlow<Status>
get() = DownloadService.status
override val query: String fun getSubscription(): Flow<Unit> {
get() = "/api/v1/downloads" return downloadRepository.downloadSubscription()
.onStart {
log.info { "Starting download status subscription" }
status.value = WebsocketService.Status.STARTING
}
.catch { error ->
log.error(error) { "Error in download status subscription" }
status.value = WebsocketService.Status.STOPPED
}
.map { updates ->
status.value = WebsocketService.Status.RUNNING
if (updates.omittedUpdates) {
log.info { "Omitted updates detected, fetching fresh download status" }
fetchDownloadStatus()
return@map
}
if (updates.initial != null) {
downloadQueue.value = updates.initial
}
downloaderStatus.value = updates.state
updates.updates?.forEach { update ->
when (update.type) {
DownloadUpdateType.QUEUED -> {
update.download?.let { download ->
downloadQueue.update {
it.toMutableList().apply {
add(download.position.coerceAtMost(it.size), download)
}
}
}
}
DownloadUpdateType.DEQUEUED -> {
downloadQueue.update {
it.filter { it.chapter.id != update.download?.chapter?.id }
}
}
DownloadUpdateType.PAUSED -> {
downloaderStatus.value = DownloaderState.STOPPED
}
DownloadUpdateType.STOPPED -> {
downloaderStatus.value = DownloaderState.STOPPED
}
DownloadUpdateType.ERROR -> {
update.download?.let { download ->
downloadQueue.update {
it.map { chapter ->
if (chapter.chapter.id == download.chapter.id) {
chapter.copy(state = download.state)
} else {
chapter
}
}
}
}
}
DownloadUpdateType.PROGRESS -> {
update.download?.let { download ->
downloadQueue.update {
it.map { chapter ->
if (chapter.chapter.id == download.chapter.id) {
chapter.copy(progress = download.progress)
} else {
chapter
}
}
}
}
}
DownloadUpdateType.FINISHED -> {
downloadQueue.update {
it.filter { it.chapter.id != update.download?.chapter?.id }
}
}
DownloadUpdateType.POSITION -> {
update.download?.let { download ->
downloadQueue.update {
val index = it.indexOfFirst { it.chapter.id == download.chapter.id }
if (index != -1) {
it.toMutableList().apply {
removeAt(index)
add(download.position.coerceAtMost(it.size), download)
}.toList()
} else it
}
override suspend fun onReceived(frame: Frame.Text) { }
val status = json.decodeFromString<DownloadStatus>(frame.readText()) }
downloaderStatus.value = status.status null -> {
downloadQueue.value = status.queue // todo Handle null case
}
}
}
}
}
private suspend fun fetchDownloadStatus() {
val status = downloadRepository.downloadStatus().firstOrNull()
if (status != null) {
downloadQueue.value = status.queue
downloaderStatus.value = status.status
}
} }
companion object { companion object {
val status = MutableStateFlow(Status.STARTING) val status = MutableStateFlow(WebsocketService.Status.STARTING)
val downloadQueue = MutableStateFlow(emptyList<DownloadChapter>()) val downloadQueue = MutableStateFlow(emptyList<DownloadQueueItem>())
val downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped) val downloaderStatus = MutableStateFlow(DownloaderState.STOPPED)
fun registerWatch(mangaId: Long) = fun registerWatch(mangaId: Long) =
downloadQueue downloadQueue
.map { .map {
it.filter { it.mangaId == mangaId } it.filter { it.manga.id == mangaId }
} }
fun registerWatches(mangaIds: Set<Long>) = fun registerWatches(mangaIds: Set<Long>) =
downloadQueue downloadQueue
.map { .map {
it.filter { it.mangaId in mangaIds } it.filter { it.manga.id in mangaIds }
} }
} }
} }

View File

@@ -29,49 +29,64 @@ import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.http.URLBuilder import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol import io.ktor.http.URLProtocol
import io.ktor.http.Url
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import io.ktor.client.plugins.auth.Auth as AuthPlugin import io.ktor.client.plugins.auth.Auth as AuthPlugin
typealias Http = HttpClient typealias Http = StateFlow<HttpClient>
expect val Engine: HttpClientEngineFactory<HttpClientEngineConfig> expect val Engine: HttpClientEngineFactory<HttpClientEngineConfig>
expect fun HttpClientConfig<HttpClientEngineConfig>.configurePlatform() expect fun HttpClientConfig<HttpClientEngineConfig>.configurePlatform()
fun httpClient( private fun getHttpClient(
serverPreferences: ServerPreferences, serverUrl: Url,
json: Json, proxy: Proxy,
): Http = proxyHttpHost: String,
HttpClient(Engine) { proxyHttpPort: Int,
proxySocksHost: String,
proxySocksPort: Int,
auth: Auth,
authUsername: String,
authPassword: String,
json: Json
): HttpClient {
return HttpClient(Engine) {
configurePlatform() configurePlatform()
expectSuccess = true expectSuccess = true
defaultRequest { defaultRequest {
url(serverPreferences.serverUrl().get().toString()) url(serverUrl.toString())
} }
engine { engine {
proxy = when (serverPreferences.proxy().get()) { this.proxy = when (proxy) {
Proxy.NO_PROXY -> null Proxy.NO_PROXY -> null
Proxy.HTTP_PROXY -> ProxyBuilder.http( Proxy.HTTP_PROXY -> ProxyBuilder.http(
URLBuilder( URLBuilder(
host = serverPreferences.proxyHttpHost().get(), host = proxyHttpHost,
port = serverPreferences.proxyHttpPort().get(), port = proxyHttpPort,
).build(), ).build(),
) )
Proxy.SOCKS_PROXY -> ProxyBuilder.socks( Proxy.SOCKS_PROXY -> ProxyBuilder.socks(
serverPreferences.proxySocksHost().get(), proxySocksHost,
serverPreferences.proxySocksPort().get(), proxySocksPort,
) )
} }
} }
when (serverPreferences.auth().get()) { when (auth) {
Auth.NONE -> Unit Auth.NONE -> Unit
Auth.BASIC -> AuthPlugin { Auth.BASIC -> AuthPlugin {
@@ -81,8 +96,8 @@ fun httpClient(
} }
credentials { credentials {
BasicAuthCredentials( BasicAuthCredentials(
serverPreferences.authUsername().get(), authUsername,
serverPreferences.authPassword().get(), authPassword,
) )
} }
} }
@@ -92,8 +107,8 @@ fun httpClient(
digest { digest {
credentials { credentials {
DigestAuthCredentials( DigestAuthCredentials(
serverPreferences.authUsername().get(), authUsername,
serverPreferences.authPassword().get(), authPassword,
) )
} }
} }
@@ -123,3 +138,48 @@ fun httpClient(
} }
} }
} }
}
@OptIn(DelicateCoroutinesApi::class)
fun httpClient(
serverPreferences: ServerPreferences,
json: Json,
): Http = combine(
serverPreferences.serverUrl().stateIn(GlobalScope),
serverPreferences.proxy().stateIn(GlobalScope),
serverPreferences.proxyHttpHost().stateIn(GlobalScope),
serverPreferences.proxyHttpPort().stateIn(GlobalScope),
serverPreferences.proxySocksHost().stateIn(GlobalScope),
serverPreferences.proxySocksPort().stateIn(GlobalScope),
serverPreferences.auth().stateIn(GlobalScope),
serverPreferences.authUsername().stateIn(GlobalScope),
serverPreferences.authPassword().stateIn(GlobalScope),
) {
getHttpClient(
it[0] as Url,
it[1] as Proxy,
it[2] as String,
it[3] as Int,
it[4] as String,
it[5] as Int,
it[6] as Auth,
it[7] as String,
it[8] as String,
json,
)
}.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getHttpClient(
serverPreferences.serverUrl().get(),
serverPreferences.proxy().get(),
serverPreferences.proxyHttpHost().get(),
serverPreferences.proxyHttpPort().get(),
serverPreferences.proxySocksHost().get(),
serverPreferences.proxySocksPort().get(),
serverPreferences.auth().get(),
serverPreferences.authUsername().get(),
serverPreferences.authPassword().get(),
json,
)
)

View File

@@ -39,7 +39,7 @@ class UpdateChecker(
fun asFlow(manualFetch: Boolean) = fun asFlow(manualFetch: Boolean) =
flow { flow {
if (!manualFetch && !updatePreferences.enabled().get()) return@flow if (!manualFetch && !updatePreferences.enabled().get()) return@flow
val latestRelease = client.get( val latestRelease = client.value.get(
"https://api.github.com/repos/$GITHUB_REPO/releases/latest", "https://api.github.com/repos/$GITHUB_REPO/releases/latest",
).body<GithubRelease>() ).body<GithubRelease>()

View File

@@ -33,7 +33,7 @@ actual fun ComponentRegistryBuilder.register(
contextWrapper: ContextWrapper, contextWrapper: ContextWrapper,
http: Http, http: Http,
) { ) {
setupDefaultComponents(contextWrapper, httpClient = { http }) setupDefaultComponents(contextWrapper, httpClient = { http.value })
} }
actual fun DiskCacheBuilder.configure( actual fun DiskCacheBuilder.configure(

View File

@@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp
import ca.gosyer.jui.domain.chapter.interactor.DeleteChapterDownload import ca.gosyer.jui.domain.chapter.interactor.DeleteChapterDownload
import ca.gosyer.jui.domain.chapter.model.Chapter import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.download.interactor.StopChapterDownload import ca.gosyer.jui.domain.download.interactor.StopChapterDownload
import ca.gosyer.jui.domain.download.model.DownloadChapter import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloadState import ca.gosyer.jui.domain.download.model.DownloadState
import ca.gosyer.jui.domain.manga.model.Manga import ca.gosyer.jui.domain.manga.model.Manga
import ca.gosyer.jui.i18n.MR import ca.gosyer.jui.i18n.MR
@@ -61,12 +61,12 @@ data class ChapterDownloadItem(
) )
val downloadState = _downloadState.asStateFlow() val downloadState = _downloadState.asStateFlow()
private val _downloadChapterFlow: MutableStateFlow<DownloadChapter?> = MutableStateFlow(null) private val _downloadChapterFlow: MutableStateFlow<DownloadQueueItem?> = MutableStateFlow(null)
val downloadChapterFlow = _downloadChapterFlow.asStateFlow() val downloadChapterFlow = _downloadChapterFlow.asStateFlow()
fun updateFrom(downloadingChapters: List<DownloadChapter>) { fun updateFrom(downloadingChapters: List<DownloadQueueItem>) {
val downloadingChapter = downloadingChapters.find { val downloadingChapter = downloadingChapters.find {
it.chapterIndex == chapter.index && it.mangaId == chapter.mangaId it.chapter.id == chapter.id
} }
if (downloadingChapter != null && downloadState.value != ChapterDownloadState.Downloading) { if (downloadingChapter != null && downloadState.value != ChapterDownloadState.Downloading) {
_downloadState.value = ChapterDownloadState.Downloading _downloadState.value = ChapterDownloadState.Downloading
@@ -155,11 +155,11 @@ private fun DownloadIconButton(onClick: () -> Unit) {
@Composable @Composable
private fun DownloadingIconButton( private fun DownloadingIconButton(
downloadChapter: DownloadChapter?, downloadChapter: DownloadQueueItem?,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
DropdownIconButton( DropdownIconButton(
downloadChapter?.mangaId to downloadChapter?.chapterIndex, downloadChapter?.chapter?.id,
{ {
DropdownMenuItem(onClick = onClick) { DropdownMenuItem(onClick = onClick) {
Text(stringResource(MR.strings.action_cancel)) Text(stringResource(MR.strings.action_cancel))
@@ -167,7 +167,7 @@ private fun DownloadingIconButton(
}, },
) { ) {
when (downloadChapter?.state) { when (downloadChapter?.state) {
null, DownloadState.Queued -> CircularProgressIndicator( null, DownloadState.QUEUED -> CircularProgressIndicator(
Modifier Modifier
.size(26.dp) .size(26.dp)
.padding(2.dp), .padding(2.dp),
@@ -175,7 +175,7 @@ private fun DownloadingIconButton(
2.dp, 2.dp,
) )
DownloadState.Downloading -> if (downloadChapter.progress != 0.0F) { DownloadState.DOWNLOADING -> if (downloadChapter.progress != 0.0F) {
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = downloadChapter.progress, targetValue = downloadChapter.progress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
@@ -206,7 +206,7 @@ private fun DownloadingIconButton(
) )
} }
DownloadState.Error -> Surface(shape = CircleShape, color = LocalContentColor.current) { DownloadState.ERROR -> Surface(shape = CircleShape, color = LocalContentColor.current) {
Icon( Icon(
Icons.Rounded.Error, Icons.Rounded.Error,
null, null,
@@ -217,7 +217,7 @@ private fun DownloadingIconButton(
) )
} }
DownloadState.Finished -> Surface(shape = CircleShape, color = LocalContentColor.current) { DownloadState.FINISHED -> Surface(shape = CircleShape, color = LocalContentColor.current) {
Icon( Icon(
Icons.Rounded.Check, Icons.Rounded.Check,
null, null,

View File

@@ -6,6 +6,7 @@
package ca.gosyer.jui.ui.base.image package ca.gosyer.jui.ui.base.image
import ca.gosyer.jui.domain.download.model.DownloadManga
import ca.gosyer.jui.domain.extension.model.Extension import ca.gosyer.jui.domain.extension.model.Extension
import ca.gosyer.jui.domain.manga.model.Manga import ca.gosyer.jui.domain.manga.model.Manga
import ca.gosyer.jui.domain.server.Http import ca.gosyer.jui.domain.server.Http
@@ -42,10 +43,13 @@ class ImageLoaderProvider(
fun get(imageCache: ImageCache): ImageLoader = fun get(imageCache: ImageCache): ImageLoader =
ImageLoader { ImageLoader {
components { components {
add(KtorUrlFetcher.Factory { http.value })
register(context, http) register(context, http)
add(MokoResourceFetcher.Factory()) add(MokoResourceFetcher.Factory())
add(MangaCoverMapper()) add(MangaCoverMapper())
add(MangaCoverKeyer()) add(MangaCoverKeyer())
add(DownloadMangaCoverMapper())
add(DownloadMangaCoverKeyer())
add(ExtensionIconMapper()) add(ExtensionIconMapper())
add(ExtensionIconKeyer()) add(ExtensionIconKeyer())
add(SourceIconMapper()) add(SourceIconMapper())
@@ -77,7 +81,28 @@ class ImageLoaderProvider(
options: Options, options: Options,
): String? { ): String? {
if (data !is Manga) return null if (data !is Manga) return null
return "${data.sourceId}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}" return "${data.id}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}"
}
}
inner class DownloadMangaCoverMapper : Mapper<Url> {
override fun map(
data: Any,
options: Options,
): Url? {
if (data !is DownloadManga) return null
if (data.thumbnailUrl.isNullOrBlank()) return null
return Url(serverUrl.value.toString() + data.thumbnailUrl)
}
}
class DownloadMangaCoverKeyer : Keyer {
override fun key(
data: Any,
options: Options,
): String? {
if (data !is DownloadManga) return null
return "${data.id}-${data.thumbnailUrl}-${data.thumbnailUrlLastFetched}"
} }
} }

View File

@@ -0,0 +1,84 @@
package ca.gosyer.jui.ui.base.image
import com.seiko.imageloader.component.fetcher.FetchResult
import com.seiko.imageloader.component.fetcher.Fetcher
import com.seiko.imageloader.model.ImageSource
import com.seiko.imageloader.model.ImageSourceFrom
import com.seiko.imageloader.model.KtorRequestData
import com.seiko.imageloader.model.extraData
import com.seiko.imageloader.model.mimeType
import com.seiko.imageloader.model.toImageSource
import com.seiko.imageloader.option.Options
import io.ktor.client.HttpClient
import io.ktor.client.request.headers
import io.ktor.client.request.prepareRequest
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpMethod
import io.ktor.http.Url
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readRemaining
import kotlinx.io.readByteArray
import okio.Buffer
class KtorUrlFetcher private constructor(
private val httpUrl: Url,
private val httpClient: () -> HttpClient,
private val ktorRequestData: KtorRequestData?,
) : Fetcher {
override suspend fun fetch(): FetchResult {
return httpClient().prepareRequest {
url(httpUrl)
method = ktorRequestData?.method ?: HttpMethod.Get
ktorRequestData?.headers?.let {
headers {
it.forEach { (key, value) ->
append(key, value)
}
}
}
}.execute { response ->
if (!response.status.isSuccess()) {
throw KtorUrlRequestException("code:${response.status.value}, ${response.status.description}")
}
FetchResult.OfSource(
imageSource = channelToImageSource(response.bodyAsChannel()),
imageSourceFrom = ImageSourceFrom.Network,
extra = extraData {
mimeType(response.contentType()?.toString())
},
)
}
}
class Factory(
private val httpClient: () -> HttpClient,
) : Fetcher.Factory {
override fun create(data: Any, options: Options): Fetcher? {
if (data !is Url) return null
return KtorUrlFetcher(
httpUrl = data,
httpClient = httpClient,
ktorRequestData = options.extra["KEY_KTOR_REQUEST_DATA"] as? KtorRequestData,
)
}
}
}
private suspend fun channelToImageSource(channel: ByteReadChannel): ImageSource {
val buffer = Buffer()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(2048)
while (!packet.exhausted()) {
val bytes = packet.readByteArray()
buffer.write(bytes)
}
}
return buffer.toImageSource()
}
private class KtorUrlRequestException(msg: String) : RuntimeException(msg)

View File

@@ -7,13 +7,13 @@
package ca.gosyer.jui.ui.downloads package ca.gosyer.jui.ui.downloads
import ca.gosyer.jui.domain.base.WebsocketService.Actions import ca.gosyer.jui.domain.base.WebsocketService.Actions
import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.download.interactor.ClearDownloadQueue import ca.gosyer.jui.domain.download.interactor.ClearDownloadQueue
import ca.gosyer.jui.domain.download.interactor.QueueChapterDownload import ca.gosyer.jui.domain.download.interactor.QueueChapterDownload
import ca.gosyer.jui.domain.download.interactor.ReorderChapterDownload import ca.gosyer.jui.domain.download.interactor.ReorderChapterDownload
import ca.gosyer.jui.domain.download.interactor.StartDownloading import ca.gosyer.jui.domain.download.interactor.StartDownloading
import ca.gosyer.jui.domain.download.interactor.StopChapterDownload import ca.gosyer.jui.domain.download.interactor.StopChapterDownload
import ca.gosyer.jui.domain.download.interactor.StopDownloading import ca.gosyer.jui.domain.download.interactor.StopDownloading
import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.service.DownloadService import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.uicore.vm.ContextWrapper import ca.gosyer.jui.uicore.vm.ContextWrapper
import ca.gosyer.jui.uicore.vm.ViewModel import ca.gosyer.jui.uicore.vm.ViewModel
@@ -69,35 +69,35 @@ class DownloadsScreenViewModel(
scope.launch { clearDownloadQueue.await(onError = { toast(it.message.orEmpty()) }) } scope.launch { clearDownloadQueue.await(onError = { toast(it.message.orEmpty()) }) }
} }
fun stopDownload(chapter: Chapter) { fun stopDownload(chapter: DownloadChapter) {
scope.launch { stopChapterDownload.await(chapter.id, onError = { toast(it.message.orEmpty()) }) } scope.launch { stopChapterDownload.await(chapter.id, onError = { toast(it.message.orEmpty()) }) }
} }
fun moveUp(chapter: Chapter) { fun moveUp(chapter: DownloadChapter) {
scope.launch { scope.launch {
val index = val index =
downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index } downloadQueue.value.indexOfFirst { it.chapter.id == chapter.id }
if (index == -1 || index <= 0) return@launch if (index == -1 || index <= 0) return@launch
reorderChapterDownload.await(chapter.id, index - 1, onError = { toast(it.message.orEmpty()) }) reorderChapterDownload.await(chapter.id, index - 1, onError = { toast(it.message.orEmpty()) })
} }
} }
fun moveDown(chapter: Chapter) { fun moveDown(chapter: DownloadChapter) {
scope.launch { scope.launch {
val index = val index =
downloadQueue.value.indexOfFirst { it.mangaId == chapter.mangaId && it.chapterIndex == chapter.index } downloadQueue.value.indexOfFirst { it.chapter.id == chapter.id }
if (index == -1 || index >= downloadQueue.value.lastIndex) return@launch if (index == -1 || index >= downloadQueue.value.lastIndex) return@launch
reorderChapterDownload.await(chapter.id, index + 1, onError = { toast(it.message.orEmpty()) }) reorderChapterDownload.await(chapter.id, index + 1, onError = { toast(it.message.orEmpty()) })
} }
} }
fun moveToTop(chapter: Chapter) { fun moveToTop(chapter: DownloadChapter) {
scope.launch { scope.launch {
reorderChapterDownload.await(chapter.id, 0, onError = { toast(it.message.orEmpty()) }) reorderChapterDownload.await(chapter.id, 0, onError = { toast(it.message.orEmpty()) })
} }
} }
fun moveToBottom(chapter: Chapter) { fun moveToBottom(chapter: DownloadChapter) {
scope.launch { scope.launch {
reorderChapterDownload.await( reorderChapterDownload.await(
chapter.id, chapter.id,

View File

@@ -48,9 +48,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ca.gosyer.jui.domain.chapter.model.Chapter
import ca.gosyer.jui.domain.download.model.DownloadChapter import ca.gosyer.jui.domain.download.model.DownloadChapter
import ca.gosyer.jui.domain.download.model.DownloaderStatus import ca.gosyer.jui.domain.download.model.DownloadQueueItem
import ca.gosyer.jui.domain.download.model.DownloaderState
import ca.gosyer.jui.i18n.MR import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.navigation.ActionItem import ca.gosyer.jui.ui.base.navigation.ActionItem
import ca.gosyer.jui.ui.base.navigation.Toolbar import ca.gosyer.jui.ui.base.navigation.Toolbar
@@ -71,17 +71,17 @@ import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun DownloadsScreenContent( fun DownloadsScreenContent(
downloadQueue: ImmutableList<DownloadChapter>, downloadQueue: ImmutableList<DownloadQueueItem>,
downloadStatus: DownloaderStatus, downloadStatus: DownloaderState,
startDownloading: () -> Unit, startDownloading: () -> Unit,
pauseDownloading: () -> Unit, pauseDownloading: () -> Unit,
clearQueue: () -> Unit, clearQueue: () -> Unit,
onMangaClick: (Long) -> Unit, onMangaClick: (Long) -> Unit,
stopDownload: (Chapter) -> Unit, stopDownload: (DownloadChapter) -> Unit,
moveDownloadUp: (Chapter) -> Unit, moveDownloadUp: (DownloadChapter) -> Unit,
moveDownloadDown: (Chapter) -> Unit, moveDownloadDown: (DownloadChapter) -> Unit,
moveDownloadToTop: (Chapter) -> Unit, moveDownloadToTop: (DownloadChapter) -> Unit,
moveDownloadToBottom: (Chapter) -> Unit, moveDownloadToBottom: (DownloadChapter) -> Unit,
) { ) {
Scaffold( Scaffold(
modifier = Modifier.windowInsetsPadding( modifier = Modifier.windowInsetsPadding(
@@ -114,11 +114,11 @@ fun DownloadsScreenContent(
), ),
).asPaddingValues(), ).asPaddingValues(),
) { ) {
items(downloadQueue, key = { "${it.mangaId}-${it.chapterIndex}" }) { items(downloadQueue, key = { it.chapter.id }) {
DownloadsItem( DownloadsItem(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
item = it, item = it,
onClickCover = { onMangaClick(it.mangaId) }, onClickCover = { onMangaClick(it.manga.id) },
onClickCancel = stopDownload, onClickCancel = stopDownload,
onClickMoveUp = moveDownloadUp, onClickMoveUp = moveDownloadUp,
onClickMoveDown = moveDownloadDown, onClickMoveDown = moveDownloadDown,
@@ -147,13 +147,13 @@ fun DownloadsScreenContent(
@Composable @Composable
fun DownloadsItem( fun DownloadsItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: DownloadChapter, item: DownloadQueueItem,
onClickCover: () -> Unit, onClickCover: () -> Unit,
onClickCancel: (Chapter) -> Unit, onClickCancel: (DownloadChapter) -> Unit,
onClickMoveUp: (Chapter) -> Unit, onClickMoveUp: (DownloadChapter) -> Unit,
onClickMoveDown: (Chapter) -> Unit, onClickMoveDown: (DownloadChapter) -> Unit,
onClickMoveToTop: (Chapter) -> Unit, onClickMoveToTop: (DownloadChapter) -> Unit,
onClickMoveToBottom: (Chapter) -> Unit, onClickMoveToBottom: (DownloadChapter) -> Unit,
) { ) {
MangaListItem( MangaListItem(
modifier = modifier modifier = modifier
@@ -180,8 +180,8 @@ fun DownloadsItem(
text = item.manga.title, text = item.manga.title,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
val progress = if (item.chapter.pageCount != null && item.chapter.pageCount != -1) { val progress = if (item.chapter.pageCount > 0) {
" - " + "${(item.chapter.pageCount!! * item.progress).toInt()}/${item.chapter.pageCount}" " - " + "${(item.chapter.pageCount * item.progress).toInt()}/${item.chapter.pageCount}"
} else { } else {
"" ""
} }
@@ -200,7 +200,7 @@ fun DownloadsItem(
) )
} }
DropdownIconButton( DropdownIconButton(
item.mangaId to item.chapterIndex, item.chapter.id,
{ {
DropdownMenuItem(onClick = { onClickCancel(item.chapter) }) { DropdownMenuItem(onClick = { onClickCancel(item.chapter) }) {
Text(stringResource(MR.strings.action_cancel)) Text(stringResource(MR.strings.action_cancel))
@@ -231,13 +231,13 @@ fun DownloadsItem(
@Stable @Stable
@Composable @Composable
private fun getActionItems( private fun getActionItems(
downloadStatus: DownloaderStatus, downloadStatus: DownloaderState,
startDownloading: () -> Unit, startDownloading: () -> Unit,
pauseDownloading: () -> Unit, pauseDownloading: () -> Unit,
clearQueue: () -> Unit, clearQueue: () -> Unit,
): ImmutableList<ActionItem> = ): ImmutableList<ActionItem> =
listOf( listOf(
if (downloadStatus == DownloaderStatus.Started) { if (downloadStatus == DownloaderState.STARTED) {
ActionItem( ActionItem(
stringResource(MR.strings.action_pause), stringResource(MR.strings.action_pause),
Icons.Rounded.Pause, Icons.Rounded.Pause,

View File

@@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import ca.gosyer.jui.domain.base.WebsocketService import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.model.DownloaderStatus import ca.gosyer.jui.domain.download.model.DownloaderState
import ca.gosyer.jui.i18n.MR import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.LocalViewModels import ca.gosyer.jui.ui.base.LocalViewModels
import ca.gosyer.jui.uicore.resources.stringResource import ca.gosyer.jui.uicore.resources.stringResource
@@ -43,7 +43,7 @@ fun DownloadsExtraInfo() {
WebsocketService.Status.RUNNING -> { WebsocketService.Status.RUNNING -> {
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
val remainingDownloads = stringResource(MR.strings.downloads_remaining, list.size) val remainingDownloads = stringResource(MR.strings.downloads_remaining, list.size)
if (downloaderStatus == DownloaderStatus.Stopped) { if (downloaderStatus == DownloaderState.STOPPED) {
stringResource(MR.strings.downloads_paused) + "" + remainingDownloads stringResource(MR.strings.downloads_paused) + "" + remainingDownloads
} else { } else {
remainingDownloads remainingDownloads

View File

@@ -96,7 +96,7 @@ class UpdatesScreenViewModel(
} }
.buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) .buffer(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
.onEach { (mangaIds, queue) -> .onEach { (mangaIds, queue) ->
val chapters = queue.filter { it.mangaId in mangaIds } val chapters = queue.filter { it.manga.id in mangaIds }
updates.value.filterIsInstance<UpdatesUI.Item>().forEach { updates.value.filterIsInstance<UpdatesUI.Item>().forEach {
it.chapterDownloadItem.updateFrom(chapters) it.chapterDownloadItem.updateFrom(chapters)
} }

View File

@@ -24,7 +24,7 @@ actual fun ComponentRegistryBuilder.register(
contextWrapper: ContextWrapper, contextWrapper: ContextWrapper,
http: Http, http: Http,
) { ) {
setupDefaultComponents(httpClient = { http }) setupDefaultComponents(httpClient = { http.value })
} }
actual fun DiskCacheBuilder.configure( actual fun DiskCacheBuilder.configure(

View File

@@ -9,11 +9,13 @@ package ca.gosyer.jui.ui.downloads
import ca.gosyer.jui.domain.base.WebsocketService import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.service.DownloadService import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.uicore.vm.ContextWrapper import ca.gosyer.jui.uicore.vm.ContextWrapper
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
internal actual fun startDownloadService( internal actual fun startDownloadService(
contextWrapper: ContextWrapper, contextWrapper: ContextWrapper,
downloadService: DownloadService, downloadService: DownloadService,
actions: WebsocketService.Actions, actions: WebsocketService.Actions,
) { ) {
downloadService.init() downloadService.getSubscription().launchIn(GlobalScope)
} }

View File

@@ -28,7 +28,7 @@ suspend fun imageFromUrl(
url: String, url: String,
block: HttpRequestBuilder.() -> Unit, block: HttpRequestBuilder.() -> Unit,
): ImageBitmap = ): ImageBitmap =
client.get(url) { client.value.get(url) {
expectSuccess = true expectSuccess = true
block() block()
}.toImageBitmap() }.toImageBitmap()

View File

@@ -29,7 +29,7 @@ actual fun ComponentRegistryBuilder.register(
contextWrapper: ContextWrapper, contextWrapper: ContextWrapper,
http: Http, http: Http,
) { ) {
setupDefaultComponents(httpClient = { http }) setupDefaultComponents(httpClient = { http.value })
} }
actual fun DiskCacheBuilder.configure( actual fun DiskCacheBuilder.configure(

View File

@@ -9,11 +9,13 @@ package ca.gosyer.jui.ui.downloads
import ca.gosyer.jui.domain.base.WebsocketService import ca.gosyer.jui.domain.base.WebsocketService
import ca.gosyer.jui.domain.download.service.DownloadService import ca.gosyer.jui.domain.download.service.DownloadService
import ca.gosyer.jui.uicore.vm.ContextWrapper import ca.gosyer.jui.uicore.vm.ContextWrapper
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
internal actual fun startDownloadService( internal actual fun startDownloadService(
contextWrapper: ContextWrapper, contextWrapper: ContextWrapper,
downloadService: DownloadService, downloadService: DownloadService,
actions: WebsocketService.Actions, actions: WebsocketService.Actions,
) { ) {
downloadService.init() downloadService.getSubscription().launchIn(GlobalScope)
} }