This commit is contained in:
Horis 2024-01-30 11:30:24 +08:00
parent 3bb0b42645
commit 32bd15a53d
9 changed files with 149 additions and 110 deletions

View File

@ -190,6 +190,19 @@ interface BookSourceDao {
)
fun getEnabledByGroup(group: String): List<BookSource>
@Query(
"""select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore,
trim(loginUrl) <> '' hasLoginUrl, lastUpdateTime, respondTime, weight, trim(exploreUrl) <> '' hasExploreUrl
from book_sources
where enabled = 1
and (bookSourceGroup = :group
or bookSourceGroup like :group || ',%'
or bookSourceGroup like '%,' || :group
or bookSourceGroup like '%,' || :group || ',%')
order by customOrder asc"""
)
fun getEnabledPartByGroup(group: String): List<BookSourcePart>
@Query("select * from book_sources where bookUrlPattern != 'NONE' and bookSourceType = :type order by customOrder asc")
fun getEnabledByType(type: Int): List<BookSource>
@ -215,6 +228,13 @@ interface BookSourceDao {
@get:Query("select * from book_sources where enabled = 1 order by customOrder")
val allEnabled: List<BookSource>
@get:Query(
"""select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore,
trim(loginUrl) <> '' hasLoginUrl, lastUpdateTime, respondTime, weight, trim(exploreUrl) <> '' hasExploreUrl
from book_sources where enabled = 1 order by customOrder asc"""
)
val allEnabledPart: List<BookSourcePart>
@get:Query("select * from book_sources where enabled = 0 order by customOrder")
val allDisabled: List<BookSource>
@ -242,6 +262,13 @@ interface BookSourceDao {
@Query("select * from book_sources where bookSourceUrl = :key")
fun getBookSource(key: String): BookSource?
@Query(
"""select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore,
trim(loginUrl) <> '' hasLoginUrl, lastUpdateTime, respondTime, weight, trim(exploreUrl) <> '' hasExploreUrl
from book_sources where bookSourceUrl = :key"""
)
fun getBookSourcePart(key: String): BookSourcePart?
@Query("select count(*) from book_sources")
fun allCount(): Int

View File

@ -1,23 +1,34 @@
package io.legado.app.model.webBook
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.data.entities.SearchBook
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.CompositeCoroutine
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.utils.getPrefBoolean
import io.legado.app.utils.mapNotNullParallel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import splitties.init.appCtx
import java.util.concurrent.Executors
import kotlin.coroutines.coroutineContext
import kotlin.math.min
class SearchModel(private val scope: CoroutineScope, private val callBack: CallBack) {
@ -26,12 +37,10 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
private var mSearchId = 0L
private var searchPage = 1
private var searchKey: String = ""
private var tasks = CompositeCoroutine()
private var bookSourceList = arrayListOf<BookSource>()
private var bookSourceParts = emptyList<BookSourcePart>()
private var searchBooks = arrayListOf<SearchBook>()
private var searchJob: Job? = null
@Volatile
private var searchIndex = -1
private fun initSearchPool() {
searchPool?.close()
@ -40,97 +49,70 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
}
fun search(searchId: Long, key: String) {
callBack.onSearchStart()
if (searchId != mSearchId) {
if (key.isEmpty()) {
callBack.onSearchCancel()
return
} else {
this.searchKey = key
}
searchKey = key
if (mSearchId != 0L) {
close()
}
initSearchPool()
mSearchId = searchId
searchPage = 1
bookSourceList.clear()
searchBooks.clear()
callBack.onSearchSuccess(searchBooks)
bookSourceList.addAll(callBack.getSearchScope().getBookSources())
if (bookSourceList.isEmpty()) {
bookSourceParts = callBack.getSearchScope().getBookSourceParts()
if (bookSourceParts.isEmpty()) {
callBack.onSearchCancel(NoStackTraceException("启用书源为空"))
return
}
mSearchId = searchId
searchPage = 1
initSearchPool()
} else {
searchPage++
}
searchIndex = -1
for (i in 0 until threadCount) {
search(searchId)
startSearch()
}
private fun startSearch() {
val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch)
searchJob = scope.launch(searchPool!!) {
flow {
for (bs in bookSourceParts) {
bs.getBookSource()?.let {
emit(it)
}
}
}.onStart {
callBack.onSearchStart()
}.mapNotNullParallel(threadCount) {
try {
withTimeout(30000L) {
WebBook.searchBookAwait(it, searchKey, searchPage)
}
} catch (e: Throwable) {
currentCoroutineContext().ensureActive()
null
}
}.buffer(0).onEach { items ->
appDb.searchBookDao.insert(*items.toTypedArray())
mergeItems(items, precision)
currentCoroutineContext().ensureActive()
callBack.onSearchSuccess(searchBooks)
}.onCompletion {
if (it == null) callBack.onSearchFinish(searchBooks.isEmpty())
}.catch {
AppLog.put("书源搜索出错\n${it.localizedMessage}", it)
}.collect()
}
}
@Synchronized
private fun search(searchId: Long) {
if (searchIndex >= bookSourceList.lastIndex) {
return
}
searchIndex++
val source = bookSourceList.getOrNull(searchIndex) ?: return
val searchPool = searchPool ?: return
val task = WebBook.searchBook(
scope,
source,
searchKey,
searchPage,
context = searchPool,
start = CoroutineStart.LAZY,
executeContext = searchPool
).timeout(30000L)
.onSuccess {
ensureActive()
onSuccess(searchId, it)
}
.onFinally {
onFinally(searchId)
}
task.start()
tasks.add(task)
}
@Synchronized
private fun onSuccess(searchId: Long, items: ArrayList<SearchBook>) {
if (searchId == mSearchId) {
appDb.searchBookDao.insert(*items.toTypedArray())
val precision = appCtx.getPrefBoolean(PreferKey.precisionSearch)
mergeItems(scope, items, precision)
callBack.onSearchSuccess(searchBooks)
}
}
@Synchronized
private fun onFinally(searchId: Long) {
if (searchIndex < bookSourceList.lastIndex) {
search(searchId)
} else {
searchIndex++
}
if (searchIndex >= bookSourceList.lastIndex
+ min(bookSourceList.size, threadCount)
) {
callBack.onSearchFinish(searchBooks.isEmpty())
}
}
private fun mergeItems(scope: CoroutineScope, newDataS: List<SearchBook>, precision: Boolean) {
private suspend fun mergeItems(newDataS: List<SearchBook>, precision: Boolean) {
if (newDataS.isNotEmpty()) {
val copyData = ArrayList(searchBooks)
val equalData = arrayListOf<SearchBook>()
val containsData = arrayListOf<SearchBook>()
val otherData = arrayListOf<SearchBook>()
copyData.forEach {
if (!scope.isActive) return
coroutineContext.ensureActive()
if (it.name == searchKey || it.author == searchKey) {
equalData.add(it)
} else if (it.name.contains(searchKey) || it.author.contains(searchKey)) {
@ -140,11 +122,11 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
}
}
newDataS.forEach { nBook ->
if (!scope.isActive) return
coroutineContext.ensureActive()
if (nBook.name == searchKey || nBook.author == searchKey) {
var hasSame = false
equalData.forEach { pBook ->
if (!scope.isActive) return
coroutineContext.ensureActive()
if (pBook.name == nBook.name && pBook.author == nBook.author) {
pBook.addOrigin(nBook.origin)
hasSame = true
@ -156,7 +138,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
} else if (nBook.name.contains(searchKey) || nBook.author.contains(searchKey)) {
var hasSame = false
containsData.forEach { pBook ->
if (!scope.isActive) return
coroutineContext.ensureActive()
if (pBook.name == nBook.name && pBook.author == nBook.author) {
pBook.addOrigin(nBook.origin)
hasSame = true
@ -168,7 +150,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
} else if (!precision) {
var hasSame = false
otherData.forEach { pBook ->
if (!scope.isActive) return
coroutineContext.ensureActive()
if (pBook.name == nBook.name && pBook.author == nBook.author) {
pBook.addOrigin(nBook.origin)
hasSame = true
@ -179,13 +161,13 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
}
}
}
if (!scope.isActive) return
coroutineContext.ensureActive()
equalData.sortByDescending { it.origins.size }
equalData.addAll(containsData.sortedByDescending { it.origins.size })
if (!precision) {
equalData.addAll(otherData)
}
if (!scope.isActive) return
coroutineContext.ensureActive()
searchBooks = equalData
}
}
@ -196,7 +178,7 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
}
fun close() {
tasks.clear()
searchJob?.cancel()
searchPool?.close()
searchPool = null
mSearchId = 0L
@ -205,9 +187,9 @@ class SearchModel(private val scope: CoroutineScope, private val callBack: CallB
interface CallBack {
fun getSearchScope(): SearchScope
fun onSearchStart()
fun onSearchSuccess(searchBooks: ArrayList<SearchBook>)
fun onSearchSuccess(searchBooks: List<SearchBook>)
fun onSearchFinish(isEmpty: Boolean)
fun onSearchCancel(exception: Exception? = null)
fun onSearchCancel(exception: Throwable? = null)
}
}

View File

@ -35,8 +35,10 @@ import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import org.mozilla.javascript.WrappedException
@ -112,9 +114,7 @@ class CheckSourceService : BaseService() {
upNotification()
}.onEachParallel(threadCount) {
checkSource(it)
}.onCompletion {
stopSelf()
}.buffer(0).collect {
}.buffer(0).onEach {
finishCount++
notificationMsg = getString(
R.string.progress_show,
@ -124,7 +124,9 @@ class CheckSourceService : BaseService() {
)
upNotification()
appDb.bookSourceDao.update(it)
}
}.onCompletion {
stopSelf()
}.collect()
}
}

View File

@ -58,6 +58,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -338,8 +339,7 @@ class ExportBookService : BaseService() {
}.onEach { it.start() }
.buffer(threads)
.map { it.await() }
.withIndex()
.collect { (index, result) ->
.collectIndexed { index, result ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = index
append.invoke(result.first, result.second)
@ -685,8 +685,7 @@ class ExportBookService : BaseService() {
}.onEach { it.start() }
.buffer(threads)
.map { it.await() }
.withIndex()
.collect { (index, exportChapter) ->
.collectIndexed { index, exportChapter ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = index
epubBook.resources.addAll(exportChapter.resources)

View File

@ -306,6 +306,7 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
}
if (viewModel.isSearchLiveData.value == false
&& viewModel.searchKey.isNotEmpty()
&& viewModel.searchBookLiveData.value?.isNotEmpty() == true
) {
viewModel.search("")
}
@ -367,6 +368,7 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
* 开始搜索
*/
private fun startSearch() {
binding.refreshProgressBar.visible()
binding.refreshProgressBar.isAutoLoading = true
binding.fbStop.visible()
}
@ -376,6 +378,7 @@ class SearchActivity : VMBaseActivity<ActivityBookSearchBinding, SearchViewModel
*/
private fun searchFinally() {
binding.refreshProgressBar.isAutoLoading = false
binding.refreshProgressBar.gone()
binding.fbStop.invisible()
}

View File

@ -101,21 +101,21 @@ data class SearchScope(private var scope: String) {
/**
* 搜索范围书源
*/
fun getBookSources(): List<BookSource> {
val list = hashSetOf<BookSource>()
fun getBookSourceParts(): List<BookSourcePart> {
val list = hashSetOf<BookSourcePart>()
if (scope.isEmpty()) {
list.addAll(appDb.bookSourceDao.allEnabled)
list.addAll(appDb.bookSourceDao.allEnabledPart)
} else {
if (scope.contains("::")) {
scope.substringAfter("::").let {
appDb.bookSourceDao.getBookSource(it)?.let { source ->
appDb.bookSourceDao.getBookSourcePart(it)?.let { source ->
list.add(source)
}
}
} else {
val oldScope = scope.splitNotBlank(",")
val newScope = oldScope.filter {
val bookSources = appDb.bookSourceDao.getEnabledByGroup(it)
val bookSources = appDb.bookSourceDao.getEnabledPartByGroup(it)
list.addAll(bookSources)
bookSources.isNotEmpty()
}
@ -126,7 +126,7 @@ data class SearchScope(private var scope: String) {
}
if (list.isEmpty()) {
scope = ""
appDb.bookSourceDao.allEnabled.let {
appDb.bookSourceDao.allEnabledPart.let {
if (it.isNotEmpty()) {
stateLiveData.postValue(scope)
list.addAll(it)

View File

@ -40,7 +40,7 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
isSearchLiveData.postValue(true)
}
override fun onSearchSuccess(searchBooks: ArrayList<SearchBook>) {
override fun onSearchSuccess(searchBooks: List<SearchBook>) {
searchBookLiveData.postValue(searchBooks)
}
@ -49,7 +49,7 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
searchFinishLiveData.postValue(isEmpty)
}
override fun onSearchCancel(exception: Exception?) {
override fun onSearchCancel(exception: Throwable?) {
isSearchLiveData.postValue(false)
exception?.let {
context.toastOnUi(it.localizedMessage)
@ -95,6 +95,7 @@ class SearchViewModel(application: Application) : BaseViewModel(application) {
if ((searchKey == key) || key.isNotEmpty()) {
searchModel.cancelSearch()
searchID = System.currentTimeMillis()
searchBookLiveData.postValue(emptyList())
searchKey = key
}
if (searchKey.isEmpty()) {

View File

@ -1,15 +1,14 @@
package io.legado.app.utils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.DEFAULT_CONCURRENCY
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@OptIn(ExperimentalCoroutinesApi::class)
inline fun <T> Flow<T>.onEachParallel(
concurrency: Int = DEFAULT_CONCURRENCY,
concurrency: Int,
crossinline action: suspend (T) -> Unit
): Flow<T> = flatMapMerge(concurrency) { value ->
return@flatMapMerge flow {
@ -17,3 +16,24 @@ inline fun <T> Flow<T>.onEachParallel(
emit(value)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
inline fun <T, R> Flow<T>.mapParallel(
concurrency: Int,
crossinline transform: suspend (T) -> R,
): Flow<R> = flatMapMerge(concurrency) { value -> flow { emit(transform(value)) } }
inline fun <T, R> Flow<T>.mapNotNullParallel(
concurrency: Int,
crossinline transform: suspend (T) -> R?,
): Flow<R> = mapParallel(concurrency, transform).filterNotNull()
inline fun <T> Flow<T>.onEachIndexed(
crossinline action: suspend (index: Int, T) -> Unit,
): Flow<T> = flow {
var index = 0
collect { value ->
action(index++, value)
emit(value)
}
}

View File

@ -7,11 +7,16 @@ import io.legado.app.data.entities.SearchBook
import io.legado.app.help.config.AppConfig
import io.legado.app.model.webBook.SearchModel
import io.legado.app.ui.book.search.SearchScope
import io.legado.app.utils.*
import kotlinx.coroutines.*
import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.isJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import splitties.init.appCtx
import java.io.IOException
class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
@ -81,12 +86,12 @@ class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
}
override fun onSearchSuccess(searchBooks: ArrayList<SearchBook>) {
override fun onSearchSuccess(searchBooks: List<SearchBook>) {
send(GSON.toJson(searchBooks))
}
override fun onSearchFinish(isEmpty: Boolean) = close(normalClosure, SEARCH_FINISH, false)
override fun onSearchCancel(exception: Exception?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false)
override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false)
}