mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-10-23 04:17:16 +03:00
merge13
Includes everything from Tachiyomi v0.14.4 to this commit of Tachiyomi: bff98ca768
This commit is contained in:
parent
58260b43ca
commit
918e5bfb9c
38 changed files with 1382 additions and 1514 deletions
|
@ -23,8 +23,8 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId = "xyz.jmir.tachiyomi.mi"
|
||||
versionCode = 94
|
||||
versionName = "0.14.3"
|
||||
versionCode = 96
|
||||
versionName = "0.14.4"
|
||||
|
||||
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||
|
|
|
@ -269,14 +269,6 @@
|
|||
android:resource="@xml/updates_grid_glance_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name=".data.library.manga.MangaLibraryUpdateService"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".data.library.anime.AnimeLibraryUpdateService"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".data.download.manga.MangaDownloadService"
|
||||
android:exported="false" />
|
||||
|
|
|
@ -240,13 +240,16 @@ private fun ScaffoldLayout(
|
|||
)
|
||||
}.fastMap { it.measure(looseConstraints) }
|
||||
|
||||
val bottomBarHeight = bottomBarPlaceables.fastMaxBy { it.height }?.height
|
||||
val bottomBarHeight = bottomBarPlaceables
|
||||
.fastMaxBy { it.height }
|
||||
?.height
|
||||
?.takeIf { it != 0 }
|
||||
val fabOffsetFromBottom = fabPlacement?.let {
|
||||
max(bottomBarHeight ?: 0, bottomInset) + it.height + FabSpacing.roundToPx()
|
||||
}
|
||||
|
||||
val snackbarOffsetFromBottom = if (snackbarHeight != 0) {
|
||||
snackbarHeight + (fabOffsetFromBottom ?: bottomBarHeight ?: bottomInset)
|
||||
snackbarHeight + (fabOffsetFromBottom ?: max(bottomBarHeight ?: 0, bottomInset))
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
|
|
@ -53,6 +53,9 @@ fun AnimeEpisodeListItem(
|
|||
onClick: () -> Unit,
|
||||
onDownloadClick: ((EpisodeDownloadAction) -> Unit)?,
|
||||
) {
|
||||
val textAlpha = remember(seen) { if (seen) ReadItemAlpha else 1f }
|
||||
val textSubtitleAlpha = remember(seen) { if (seen) ReadItemAlpha else SecondaryItemAlpha }
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.selectedBackground(selected)
|
||||
|
@ -63,9 +66,6 @@ fun AnimeEpisodeListItem(
|
|||
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val textAlpha = remember(seen) { if (seen) ReadItemAlpha else 1f }
|
||||
val textSubtitleAlpha = remember(seen) { if (seen) ReadItemAlpha else SecondaryItemAlpha }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
var textHeight by remember { mutableStateOf(0) }
|
||||
if (bookmark) {
|
||||
|
@ -87,7 +87,9 @@ fun AnimeEpisodeListItem(
|
|||
modifier = Modifier.alpha(textAlpha),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Row(modifier = Modifier.alpha(textSubtitleAlpha)) {
|
||||
ProvideTextStyle(
|
||||
value = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp),
|
||||
|
@ -120,7 +122,6 @@ fun AnimeEpisodeListItem(
|
|||
}
|
||||
}
|
||||
|
||||
// Download view
|
||||
if (onDownloadClick != null) {
|
||||
EpisodeDownloadIndicator(
|
||||
enabled = downloadIndicatorEnabled,
|
||||
|
|
|
@ -53,6 +53,9 @@ fun MangaChapterListItem(
|
|||
onClick: () -> Unit,
|
||||
onDownloadClick: ((ChapterDownloadAction) -> Unit)?,
|
||||
) {
|
||||
val textAlpha = if (read) ReadItemAlpha else 1f
|
||||
val textSubtitleAlpha = if (read) ReadItemAlpha else SecondaryItemAlpha
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.selectedBackground(selected)
|
||||
|
@ -63,9 +66,6 @@ fun MangaChapterListItem(
|
|||
.padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val textAlpha = remember(read) { if (read) ReadItemAlpha else 1f }
|
||||
val textSubtitleAlpha = remember(read) { if (read) ReadItemAlpha else SecondaryItemAlpha }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
var textHeight by remember { mutableStateOf(0) }
|
||||
if (bookmark) {
|
||||
|
@ -87,7 +87,9 @@ fun MangaChapterListItem(
|
|||
modifier = Modifier.alpha(textAlpha),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Row(modifier = Modifier.alpha(textSubtitleAlpha)) {
|
||||
ProvideTextStyle(
|
||||
value = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp),
|
||||
|
@ -120,7 +122,6 @@ fun MangaChapterListItem(
|
|||
}
|
||||
}
|
||||
|
||||
// Download view
|
||||
if (onDownloadClick != null) {
|
||||
ChapterDownloadIndicator(
|
||||
enabled = downloadIndicatorEnabled,
|
||||
|
|
|
@ -61,6 +61,9 @@ fun AnimeLibraryContent(
|
|||
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
|
||||
|
||||
if (showPageTabs && categories.size > 1) {
|
||||
if (categories.size <= pagerState.currentPage) {
|
||||
pagerState.currentPage = categories.size - 1
|
||||
}
|
||||
LibraryTabs(
|
||||
categories = categories,
|
||||
currentPageIndex = pagerState.currentPage,
|
||||
|
|
|
@ -61,6 +61,9 @@ fun MangaLibraryContent(
|
|||
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
|
||||
|
||||
if (showPageTabs && categories.size > 1) {
|
||||
if (categories.size <= pagerState.currentPage) {
|
||||
pagerState.currentPage = categories.size - 1
|
||||
}
|
||||
LibraryTabs(
|
||||
categories = categories,
|
||||
currentPageIndex = pagerState.currentPage,
|
||||
|
|
|
@ -33,7 +33,7 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
|
|||
import eu.kanade.tachiyomi.data.cache.EpisodeCache
|
||||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadCache
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceValues
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
|
@ -317,13 +317,13 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||
preferenceItems = listOf(
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(R.string.pref_refresh_library_covers),
|
||||
onClick = { MangaLibraryUpdateService.start(context, target = MangaLibraryUpdateService.Target.COVERS) },
|
||||
onClick = { MangaLibraryUpdateJob.startNow(context, target = MangaLibraryUpdateJob.Target.COVERS) },
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(R.string.pref_refresh_library_tracking),
|
||||
subtitle = stringResource(R.string.pref_refresh_library_tracking_summary),
|
||||
enabled = trackManager.hasLoggedServices(),
|
||||
onClick = { MangaLibraryUpdateService.start(context, target = MangaLibraryUpdateService.Target.TRACKING) },
|
||||
onClick = { MangaLibraryUpdateJob.startNow(context, target = MangaLibraryUpdateJob.Target.TRACKING) },
|
||||
),
|
||||
Preference.PreferenceItem.TextPreference(
|
||||
title = stringResource(R.string.pref_reset_viewer_flags),
|
||||
|
|
|
@ -164,6 +164,8 @@ fun AnimeUpdatesUiItem(
|
|||
downloadProgressProvider: () -> Int,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val textAlpha = if (update.seen) ReadItemAlpha else 1f
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.selectedBackground(selected)
|
||||
|
@ -190,10 +192,6 @@ fun AnimeUpdatesUiItem(
|
|||
.padding(horizontal = MaterialTheme.padding.medium)
|
||||
.weight(1f),
|
||||
) {
|
||||
val bookmark = remember(update.bookmark) { update.bookmark }
|
||||
val seen = remember(update.seen) { update.seen }
|
||||
val textAlpha = remember(seen) { if (seen) ReadItemAlpha else 1f }
|
||||
|
||||
Text(
|
||||
text = update.animeTitle,
|
||||
maxLines = 1,
|
||||
|
@ -201,9 +199,10 @@ fun AnimeUpdatesUiItem(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.alpha(textAlpha),
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
var textHeight by remember { mutableStateOf(0) }
|
||||
if (bookmark) {
|
||||
if (update.bookmark) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmark,
|
||||
contentDescription = stringResource(R.string.action_filter_bookmarked),
|
||||
|
@ -234,6 +233,7 @@ fun AnimeUpdatesUiItem(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeDownloadIndicator(
|
||||
enabled = onDownloadEpisode != null,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
|
|
|
@ -162,6 +162,8 @@ fun MangaUpdatesUiItem(
|
|||
downloadProgressProvider: () -> Int,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val textAlpha = if (update.read) ReadItemAlpha else 1f
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.selectedBackground(selected)
|
||||
|
@ -188,10 +190,6 @@ fun MangaUpdatesUiItem(
|
|||
.padding(horizontal = MaterialTheme.padding.medium)
|
||||
.weight(1f),
|
||||
) {
|
||||
val bookmark = remember(update.bookmark) { update.bookmark }
|
||||
val read = remember(update.read) { update.read }
|
||||
val textAlpha = remember(read) { if (read) ReadItemAlpha else 1f }
|
||||
|
||||
Text(
|
||||
text = update.mangaTitle,
|
||||
maxLines = 1,
|
||||
|
@ -201,7 +199,7 @@ fun MangaUpdatesUiItem(
|
|||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
var textHeight by remember { mutableStateOf(0) }
|
||||
if (bookmark) {
|
||||
if (update.bookmark) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Bookmark,
|
||||
contentDescription = stringResource(R.string.action_filter_bookmarked),
|
||||
|
@ -232,6 +230,7 @@ fun MangaUpdatesUiItem(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChapterDownloadIndicator(
|
||||
enabled = onDownloadChapter != null,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
|
|
|
@ -406,6 +406,12 @@ object Migrations {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 96) {
|
||||
MangaLibraryUpdateJob.cancelAllWorks(context)
|
||||
AnimeLibraryUpdateJob.cancelAllWorks(context)
|
||||
MangaLibraryUpdateJob.setupTask(context)
|
||||
AnimeLibraryUpdateJob.setupTask(context)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -117,7 +117,7 @@ class AnimeDownloadManager(
|
|||
* @param downloads value to set the download queue to
|
||||
*/
|
||||
fun reorderQueue(downloads: List<AnimeDownload>) {
|
||||
if (downloader.queue.queue == downloads) return
|
||||
if (downloader.queue.state == downloads) return
|
||||
val wasRunning = downloader.isRunning
|
||||
|
||||
if (downloads.isEmpty()) {
|
||||
|
|
|
@ -162,7 +162,7 @@ class AnimeDownloader(
|
|||
return
|
||||
}
|
||||
|
||||
if (notifier.paused && !queue.isEmpty()) {
|
||||
if (notifier.paused && queue.isNotEmpty()) {
|
||||
notifier.onPaused()
|
||||
} else {
|
||||
notifier.onComplete()
|
||||
|
@ -614,7 +614,7 @@ class AnimeDownloader(
|
|||
file.renameTo("$filename.mp4")
|
||||
} catch (e: Exception) {
|
||||
response.close()
|
||||
if (!queue.contains(download)) file.delete()
|
||||
if (!queue.equals(download)) file.delete()
|
||||
// file.delete()
|
||||
throw e
|
||||
}
|
||||
|
|
|
@ -5,6 +5,14 @@ import eu.kanade.domain.items.episode.interactor.GetEpisode
|
|||
import eu.kanade.tachiyomi.animesource.model.Video
|
||||
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
|
||||
import eu.kanade.tachiyomi.source.anime.AnimeSourceManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import rx.subjects.PublishSubject
|
||||
import tachiyomi.domain.entries.anime.model.Anime
|
||||
import tachiyomi.domain.items.episode.model.Episode
|
||||
|
@ -32,24 +40,35 @@ data class AnimeDownload(
|
|||
@Transient
|
||||
var downloadedImages: Int = 0
|
||||
|
||||
@Volatile
|
||||
@Transient
|
||||
var status: State = State.NOT_DOWNLOADED
|
||||
private val _statusFlow = MutableStateFlow(State.NOT_DOWNLOADED)
|
||||
|
||||
@Transient
|
||||
val statusFlow = _statusFlow.asStateFlow()
|
||||
var status: State
|
||||
get() = _statusFlow.value
|
||||
set(status) {
|
||||
field = status
|
||||
statusSubject?.onNext(this)
|
||||
statusCallback?.invoke(this)
|
||||
_statusFlow.value = status
|
||||
}
|
||||
|
||||
@Transient
|
||||
var statusSubject: PublishSubject<AnimeDownload>? = null
|
||||
val progressFlow = flow {
|
||||
if (video == null) {
|
||||
emit(0)
|
||||
while (video == null) {
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
|
||||
val progressFlows = video!!.progressFlow
|
||||
emitAll(combine(progressFlows) { it.average().toInt() })
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce(50)
|
||||
|
||||
@Transient
|
||||
var progressSubject: PublishSubject<AnimeDownload>? = null
|
||||
|
||||
@Transient
|
||||
var statusCallback: ((AnimeDownload) -> Unit)? = null
|
||||
|
||||
@Transient
|
||||
var progressCallback: ((AnimeDownload) -> Unit)? = null
|
||||
|
||||
|
|
|
@ -7,11 +7,19 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import rx.Observable
|
||||
import rx.subjects.PublishSubject
|
||||
import tachiyomi.core.util.lang.launchNonCancellable
|
||||
|
@ -21,55 +29,38 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||
|
||||
class AnimeDownloadQueue(
|
||||
private val store: AnimeDownloadStore,
|
||||
val queue: MutableList<AnimeDownload> = CopyOnWriteArrayList(),
|
||||
) : List<AnimeDownload> by queue {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
private val statusSubject = PublishSubject.create<AnimeDownload>()
|
||||
) {
|
||||
private val _state = MutableStateFlow<List<AnimeDownload>>(emptyList())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val progressSubject = PublishSubject.create<AnimeDownload>()
|
||||
|
||||
private val _updates: Channel<Unit> = Channel(Channel.UNLIMITED)
|
||||
val updates = _updates.receiveAsFlow()
|
||||
.onStart { emit(Unit) }
|
||||
.map { queue }
|
||||
.shareIn(scope, SharingStarted.Eagerly, 1)
|
||||
|
||||
fun addAll(downloads: List<AnimeDownload>) {
|
||||
_state.update {
|
||||
downloads.forEach { download ->
|
||||
download.statusSubject = statusSubject
|
||||
download.progressSubject = progressSubject
|
||||
download.statusCallback = ::setVideoFor
|
||||
download.progressCallback = ::setProgressFor
|
||||
download.status = AnimeDownload.State.QUEUE
|
||||
}
|
||||
queue.addAll(downloads)
|
||||
store.addAll(downloads)
|
||||
scope.launchNonCancellable {
|
||||
_updates.send(Unit)
|
||||
it + downloads
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(download: AnimeDownload) {
|
||||
val removed = queue.remove(download)
|
||||
_state.update {
|
||||
store.remove(download)
|
||||
download.statusSubject = null
|
||||
download.progressSubject = null
|
||||
download.statusCallback = null
|
||||
download.progressCallback = null
|
||||
if (download.status == AnimeDownload.State.DOWNLOADING || download.status == AnimeDownload.State.QUEUE) {
|
||||
download.status = AnimeDownload.State.NOT_DOWNLOADED
|
||||
}
|
||||
if (removed) {
|
||||
scope.launchNonCancellable {
|
||||
_updates.send(Unit)
|
||||
}
|
||||
it - download
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(episode: Episode) {
|
||||
find { it.episode.id == episode.id }?.let { remove(it) }
|
||||
_state.value.find { it.episode.id == episode.id }?.let { remove(it) }
|
||||
}
|
||||
|
||||
fun remove(episodes: List<Episode>) {
|
||||
|
@ -77,50 +68,54 @@ class AnimeDownloadQueue(
|
|||
}
|
||||
|
||||
fun remove(anime: Anime) {
|
||||
filter { it.anime.id == anime.id }.forEach { remove(it) }
|
||||
_state.value.filter { it.anime.id == anime.id }.forEach { remove(it) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
queue.forEach { download ->
|
||||
download.statusSubject = null
|
||||
_state.update {
|
||||
it.forEach { download ->
|
||||
download.progressSubject = null
|
||||
download.statusCallback = null
|
||||
download.progressCallback = null
|
||||
if (download.status == AnimeDownload.State.DOWNLOADING || download.status == AnimeDownload.State.QUEUE) {
|
||||
download.status = AnimeDownload.State.NOT_DOWNLOADED
|
||||
}
|
||||
}
|
||||
queue.clear()
|
||||
store.clear()
|
||||
scope.launchNonCancellable {
|
||||
_updates.send(Unit)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun statusFlow(): Flow<AnimeDownload> = getStatusObservable().asFlow()
|
||||
|
||||
fun progressFlow(): Flow<AnimeDownload> = getProgressObservable().asFlow()
|
||||
|
||||
private fun getActiveDownloads(): Observable<AnimeDownload> =
|
||||
Observable.from(this).filter { download -> download.status == AnimeDownload.State.DOWNLOADING }
|
||||
|
||||
private fun getStatusObservable(): Observable<AnimeDownload> = statusSubject
|
||||
.startWith(getActiveDownloads())
|
||||
.onBackpressureBuffer()
|
||||
|
||||
private fun getProgressObservable(): Observable<AnimeDownload> {
|
||||
return progressSubject.onBackpressureLatest()
|
||||
fun statusFlow(): Flow<AnimeDownload> = state
|
||||
.flatMapLatest { downloads ->
|
||||
downloads
|
||||
.map { download ->
|
||||
download.statusFlow.drop(1).map { download }
|
||||
}
|
||||
.merge()
|
||||
}
|
||||
.onStart { emitAll(getActiveDownloads()) }
|
||||
|
||||
private fun setVideoFor(download: AnimeDownload) {
|
||||
if (download.status == AnimeDownload.State.DOWNLOADED || download.status == AnimeDownload.State.ERROR) {
|
||||
setVideoSubject(download.video, null)
|
||||
fun progressFlow(): Flow<AnimeDownload> = state
|
||||
.flatMapLatest { downloads ->
|
||||
downloads
|
||||
.map { download ->
|
||||
download.progressFlow.drop(1).map { download }
|
||||
}
|
||||
.merge()
|
||||
}
|
||||
.onStart { emitAll(getActiveDownloads()) }
|
||||
|
||||
private fun setVideoSubject(video: Video?, subject: PublishSubject<Video.State>?) {
|
||||
video?.statusSubject = subject
|
||||
}
|
||||
private fun getActiveDownloads(): Flow<AnimeDownload> =
|
||||
_state.value.filter { download -> download.status == AnimeDownload.State.DOWNLOADING }.asFlow()
|
||||
|
||||
fun count(predicate: (AnimeDownload) -> Boolean) = _state.value.count(predicate)
|
||||
fun filter(predicate: (AnimeDownload) -> Boolean) = _state.value.filter(predicate)
|
||||
fun find(predicate: (AnimeDownload) -> Boolean) = _state.value.find(predicate)
|
||||
fun <K> groupBy(keySelector: (AnimeDownload) -> K) = _state.value.groupBy(keySelector)
|
||||
fun isEmpty() = _state.value.isEmpty()
|
||||
fun isNotEmpty() = _state.value.isNotEmpty()
|
||||
fun none(predicate: (AnimeDownload) -> Boolean) = _state.value.none(predicate)
|
||||
fun toMutableList() = _state.value.toMutableList()
|
||||
|
||||
private fun setProgressFor(download: AnimeDownload) {
|
||||
if (download.status == AnimeDownload.State.DOWNLOADED || download.status == AnimeDownload.State.ERROR) {
|
||||
|
|
|
@ -148,7 +148,7 @@ class MangaDownloader(
|
|||
return
|
||||
}
|
||||
|
||||
if (notifier.paused && !queue.isEmpty()) {
|
||||
if (notifier.paused && queue.isNotEmpty()) {
|
||||
notifier.onPaused()
|
||||
} else {
|
||||
notifier.onComplete()
|
||||
|
|
|
@ -5,6 +5,14 @@ import eu.kanade.domain.items.chapter.interactor.GetChapter
|
|||
import eu.kanade.tachiyomi.source.manga.MangaSourceManager
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import rx.subjects.PublishSubject
|
||||
import tachiyomi.domain.entries.manga.model.Manga
|
||||
import tachiyomi.domain.items.chapter.model.Chapter
|
||||
|
@ -25,20 +33,31 @@ data class MangaDownload(
|
|||
@Transient
|
||||
var downloadedImages: Int = 0
|
||||
|
||||
@Volatile
|
||||
@Transient
|
||||
var status: State = State.NOT_DOWNLOADED
|
||||
private val _statusFlow = MutableStateFlow(State.NOT_DOWNLOADED)
|
||||
|
||||
@Transient
|
||||
val statusFlow = _statusFlow.asStateFlow()
|
||||
var status: State
|
||||
get() = _statusFlow.value
|
||||
set(status) {
|
||||
field = status
|
||||
statusSubject?.onNext(this)
|
||||
statusCallback?.invoke(this)
|
||||
_statusFlow.value = status
|
||||
}
|
||||
|
||||
@Transient
|
||||
var statusSubject: PublishSubject<MangaDownload>? = null
|
||||
val progressFlow = flow {
|
||||
if (pages == null) {
|
||||
emit(0)
|
||||
while (pages == null) {
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
var statusCallback: ((MangaDownload) -> Unit)? = null
|
||||
val progressFlows = pages!!.map(Page::progressFlow)
|
||||
emitAll(combine(progressFlows) { it.average().toInt() })
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce(50)
|
||||
|
||||
val progress: Int
|
||||
get() {
|
||||
|
|
|
@ -7,11 +7,19 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import rx.Observable
|
||||
import rx.subjects.PublishSubject
|
||||
import tachiyomi.core.util.lang.launchNonCancellable
|
||||
|
@ -21,49 +29,32 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||
|
||||
class MangaDownloadQueue(
|
||||
private val store: MangaDownloadStore,
|
||||
private val queue: MutableList<MangaDownload> = CopyOnWriteArrayList(),
|
||||
) : List<MangaDownload> by queue {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
private val statusSubject = PublishSubject.create<MangaDownload>()
|
||||
|
||||
private val _updates: Channel<Unit> = Channel(Channel.UNLIMITED)
|
||||
val updates = _updates.receiveAsFlow()
|
||||
.onStart { emit(Unit) }
|
||||
.map { queue }
|
||||
.shareIn(scope, SharingStarted.Eagerly, 1)
|
||||
) {
|
||||
private val _state = MutableStateFlow<List<MangaDownload>>(emptyList())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
fun addAll(downloads: List<MangaDownload>) {
|
||||
_state.update {
|
||||
downloads.forEach { download ->
|
||||
download.statusSubject = statusSubject
|
||||
download.statusCallback = ::setPagesFor
|
||||
download.status = MangaDownload.State.QUEUE
|
||||
}
|
||||
queue.addAll(downloads)
|
||||
store.addAll(downloads)
|
||||
scope.launchNonCancellable {
|
||||
_updates.send(Unit)
|
||||
it + downloads
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(download: MangaDownload) {
|
||||
val removed = queue.remove(download)
|
||||
_state.update {
|
||||
store.remove(download)
|
||||
download.statusSubject = null
|
||||
download.statusCallback = null
|
||||
if (download.status == MangaDownload.State.DOWNLOADING || download.status == MangaDownload.State.QUEUE) {
|
||||
download.status = MangaDownload.State.NOT_DOWNLOADED
|
||||
}
|
||||
if (removed) {
|
||||
scope.launchNonCancellable {
|
||||
_updates.send(Unit)
|
||||
}
|
||||
it - download
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(chapter: Chapter) {
|
||||
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||
_state.value.find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||
}
|
||||
|
||||
fun remove(chapters: List<Chapter>) {
|
||||
|
@ -71,61 +62,50 @@ class MangaDownloadQueue(
|
|||
}
|
||||
|
||||
fun remove(manga: Manga) {
|
||||
filter { it.manga.id == manga.id }.forEach { remove(it) }
|
||||
_state.value.filter { it.manga.id == manga.id }.forEach { remove(it) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
queue.forEach { download ->
|
||||
download.statusSubject = null
|
||||
download.statusCallback = null
|
||||
_state.update {
|
||||
it.forEach { download ->
|
||||
if (download.status == MangaDownload.State.DOWNLOADING || download.status == MangaDownload.State.QUEUE) {
|
||||
download.status = MangaDownload.State.NOT_DOWNLOADED
|
||||
}
|
||||
}
|
||||
queue.clear()
|
||||
store.clear()
|
||||
scope.launchNonCancellable {
|
||||
_updates.send(Unit)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun statusFlow(): Flow<MangaDownload> = getStatusObservable().asFlow()
|
||||
|
||||
fun progressFlow(): Flow<MangaDownload> = getProgressObservable().asFlow()
|
||||
|
||||
private fun getActiveDownloads(): Observable<MangaDownload> =
|
||||
Observable.from(this).filter { download -> download.status == MangaDownload.State.DOWNLOADING }
|
||||
|
||||
private fun getStatusObservable(): Observable<MangaDownload> = statusSubject
|
||||
.startWith(getActiveDownloads())
|
||||
.onBackpressureBuffer()
|
||||
|
||||
private fun getProgressObservable(): Observable<MangaDownload> {
|
||||
return statusSubject.onBackpressureBuffer()
|
||||
.startWith(getActiveDownloads())
|
||||
.flatMap { download ->
|
||||
if (download.status == MangaDownload.State.DOWNLOADING) {
|
||||
val pageStatusSubject = PublishSubject.create<Page.State>()
|
||||
setPagesSubject(download.pages, pageStatusSubject)
|
||||
return@flatMap pageStatusSubject
|
||||
.onBackpressureBuffer()
|
||||
.filter { it == Page.State.READY }
|
||||
.map { download }
|
||||
} else if (download.status == MangaDownload.State.DOWNLOADED || download.status == MangaDownload.State.ERROR) {
|
||||
setPagesSubject(download.pages, null)
|
||||
fun statusFlow(): Flow<MangaDownload> = state
|
||||
.flatMapLatest { downloads ->
|
||||
downloads
|
||||
.map { download ->
|
||||
download.statusFlow.drop(1).map { download }
|
||||
}
|
||||
Observable.just(download)
|
||||
}
|
||||
.filter { it.status == MangaDownload.State.DOWNLOADING }
|
||||
.merge()
|
||||
}
|
||||
.onStart { emitAll(getActiveDownloads()) }
|
||||
|
||||
private fun setPagesFor(download: MangaDownload) {
|
||||
if (download.status == MangaDownload.State.DOWNLOADED || download.status == MangaDownload.State.ERROR) {
|
||||
setPagesSubject(download.pages, null)
|
||||
fun progressFlow(): Flow<MangaDownload> = state
|
||||
.flatMapLatest { downloads ->
|
||||
downloads
|
||||
.map { download ->
|
||||
download.progressFlow.drop(1).map { download }
|
||||
}
|
||||
.merge()
|
||||
}
|
||||
.onStart { emitAll(getActiveDownloads()) }
|
||||
|
||||
private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Page.State>?) {
|
||||
pages?.forEach { it.statusSubject = subject }
|
||||
}
|
||||
private fun getActiveDownloads(): Flow<MangaDownload> =
|
||||
_state.value.filter { download -> download.status == MangaDownload.State.DOWNLOADING }.asFlow()
|
||||
|
||||
fun count(predicate: (MangaDownload) -> Boolean) = _state.value.count(predicate)
|
||||
fun filter(predicate: (MangaDownload) -> Boolean) = _state.value.filter(predicate)
|
||||
fun find(predicate: (MangaDownload) -> Boolean) = _state.value.find(predicate)
|
||||
fun <K> groupBy(keySelector: (MangaDownload) -> K) = _state.value.groupBy(keySelector)
|
||||
fun isEmpty() = _state.value.isEmpty()
|
||||
fun isNotEmpty() = _state.value.isNotEmpty()
|
||||
fun none(predicate: (MangaDownload) -> Boolean) = _state.value.none(predicate)
|
||||
fun toMutableList() = _state.value.toMutableList()
|
||||
}
|
||||
|
|
|
@ -1,44 +1,551 @@
|
|||
package eu.kanade.tachiyomi.data.library.anime
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import eu.kanade.domain.download.service.DownloadPreferences
|
||||
import eu.kanade.domain.entries.anime.interactor.GetAnime
|
||||
import eu.kanade.domain.entries.anime.interactor.GetLibraryAnime
|
||||
import eu.kanade.domain.entries.anime.interactor.UpdateAnime
|
||||
import eu.kanade.domain.entries.anime.model.copyFrom
|
||||
import eu.kanade.domain.entries.anime.model.toSAnime
|
||||
import eu.kanade.domain.items.episode.interactor.GetEpisodeByAnimeId
|
||||
import eu.kanade.domain.items.episode.interactor.SyncEpisodesWithSource
|
||||
import eu.kanade.domain.items.episode.interactor.SyncEpisodesWithTrackServiceTwoWay
|
||||
import eu.kanade.domain.library.service.LibraryPreferences
|
||||
import eu.kanade.domain.track.anime.interactor.GetAnimeTracks
|
||||
import eu.kanade.domain.track.anime.interactor.InsertAnimeTrack
|
||||
import eu.kanade.domain.track.anime.model.toDbTrack
|
||||
import eu.kanade.domain.track.anime.model.toDomainTrack
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.data.cache.AnimeCoverCache
|
||||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadService
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.ANIME_HAS_UNSEEN
|
||||
import eu.kanade.tachiyomi.data.preference.ANIME_NON_COMPLETED
|
||||
import eu.kanade.tachiyomi.data.preference.ANIME_NON_SEEN
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI
|
||||
import eu.kanade.tachiyomi.data.track.EnhancedAnimeTrackService
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.source.UnmeteredSource
|
||||
import eu.kanade.tachiyomi.source.anime.AnimeSourceManager
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.util.prepUpdateCover
|
||||
import eu.kanade.tachiyomi.util.shouldDownloadNewEpisodes
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
||||
import eu.kanade.tachiyomi.util.system.isConnectedToWifi
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.getAndSet
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.category.anime.interactor.GetAnimeCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.entries.anime.model.Anime
|
||||
import tachiyomi.domain.entries.anime.model.toAnimeUpdate
|
||||
import tachiyomi.domain.items.episode.model.Episode
|
||||
import tachiyomi.domain.items.episode.model.NoEpisodesException
|
||||
import tachiyomi.domain.library.anime.LibraryAnime
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
Worker(context, workerParams) {
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
private val sourceManager: AnimeSourceManager = Injekt.get()
|
||||
private val downloadPreferences: DownloadPreferences = Injekt.get()
|
||||
private val libraryPreferences: LibraryPreferences = Injekt.get()
|
||||
private val downloadManager: AnimeDownloadManager = Injekt.get()
|
||||
private val trackManager: TrackManager = Injekt.get()
|
||||
private val coverCache: AnimeCoverCache = Injekt.get()
|
||||
private val getLibraryAnime: GetLibraryAnime = Injekt.get()
|
||||
private val getAnime: GetAnime = Injekt.get()
|
||||
private val updateAnime: UpdateAnime = Injekt.get()
|
||||
private val getEpisodeByAnimeId: GetEpisodeByAnimeId = Injekt.get()
|
||||
private val getCategories: GetAnimeCategories = Injekt.get()
|
||||
private val syncEpisodesWithSource: SyncEpisodesWithSource = Injekt.get()
|
||||
private val getTracks: GetAnimeTracks = Injekt.get()
|
||||
private val insertTrack: InsertAnimeTrack = Injekt.get()
|
||||
private val syncEpisodesWithTrackServiceTwoWay: SyncEpisodesWithTrackServiceTwoWay = Injekt.get()
|
||||
|
||||
private val notifier = AnimeLibraryUpdateNotifier(context)
|
||||
|
||||
private var animeToUpdate: List<LibraryAnime> = mutableListOf()
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
|
||||
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
return if (AnimeLibraryUpdateService.start(context)) {
|
||||
if (tags.contains(WORK_NAME_AUTO)) {
|
||||
// Find a running manual worker. If exists, try again later
|
||||
val otherRunningWorker = withContext(Dispatchers.IO) {
|
||||
WorkManager.getInstance(context)
|
||||
.getWorkInfosByTag(WORK_NAME_MANUAL)
|
||||
.get()
|
||||
.find { it.state == WorkInfo.State.RUNNING }
|
||||
}
|
||||
if (otherRunningWorker != null) {
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setForeground(getForegroundInfo())
|
||||
} catch (e: IllegalStateException) {
|
||||
logcat(LogPriority.ERROR, e) { "Not allowed to set foreground job" }
|
||||
}
|
||||
|
||||
val target = inputData.getString(KEY_TARGET)?.let { Target.valueOf(it) } ?: Target.EPISODES
|
||||
|
||||
// If this is a chapter update; set the last update time to now
|
||||
if (target == Target.EPISODES) {
|
||||
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time)
|
||||
}
|
||||
|
||||
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
|
||||
addAnimeToQueue(categoryId)
|
||||
|
||||
return withIOContext {
|
||||
try {
|
||||
when (target) {
|
||||
Target.EPISODES -> updateEpisodeList()
|
||||
Target.COVERS -> updateCovers()
|
||||
Target.TRACKING -> updateTrackings()
|
||||
}
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
// Assume success although cancelled
|
||||
Result.success()
|
||||
} else {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
Result.failure()
|
||||
}
|
||||
} finally {
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notifier = AnimeLibraryUpdateNotifier(context)
|
||||
return ForegroundInfo(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds list of anime to be updated.
|
||||
*
|
||||
* @param categoryId the ID of the category to update, or -1 if no category specified.
|
||||
*/
|
||||
private fun addAnimeToQueue(categoryId: Long) {
|
||||
val libraryAnime = runBlocking { getLibraryAnime.await() }
|
||||
|
||||
val listToUpdate = if (categoryId != -1L) {
|
||||
libraryAnime.filter { it.category == categoryId }
|
||||
} else {
|
||||
val categoriesToUpdate = libraryPreferences.animeLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val includedAnime = if (categoriesToUpdate.isNotEmpty()) {
|
||||
libraryAnime.filter { it.category in categoriesToUpdate }
|
||||
} else {
|
||||
libraryAnime
|
||||
}
|
||||
|
||||
val categoriesToExclude = libraryPreferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedAnimeIds = if (categoriesToExclude.isNotEmpty()) {
|
||||
libraryAnime.filter { it.category in categoriesToExclude }.map { it.anime.id }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
includedAnime
|
||||
.filterNot { it.anime.id in excludedAnimeIds }
|
||||
.distinctBy { it.anime.id }
|
||||
}
|
||||
|
||||
animeToUpdate = listToUpdate
|
||||
.sortedBy { it.anime.title }
|
||||
|
||||
// Warn when excessively checking a single source
|
||||
val maxUpdatesFromSource = animeToUpdate
|
||||
.groupBy { it.anime.source }
|
||||
.filterKeys { sourceManager.get(it) !is UnmeteredSource }
|
||||
.maxOfOrNull { it.value.size } ?: 0
|
||||
if (maxUpdatesFromSource > ANIME_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
|
||||
notifier.showQueueSizeWarningNotification()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates anime in [animeToUpdate]. It's called in a background thread, so it's safe
|
||||
* to do heavy operations or network calls here.
|
||||
* For each anime it calls [updateAnime] and updates the notification showing the current
|
||||
* progress.
|
||||
*
|
||||
* @return an observable delivering the progress of each update.
|
||||
*/
|
||||
private suspend fun updateEpisodeList() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingAnime = CopyOnWriteArrayList<Anime>()
|
||||
val newUpdates = CopyOnWriteArrayList<Pair<Anime, Array<Episode>>>()
|
||||
val skippedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
|
||||
val hasDownloads = AtomicBoolean(false)
|
||||
val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
|
||||
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
|
||||
coroutineScope {
|
||||
animeToUpdate.groupBy { it.anime.source }.values
|
||||
.map { animeInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
animeInSource.forEach { libraryAnime ->
|
||||
val anime = libraryAnime.anime
|
||||
ensureActive()
|
||||
|
||||
// Don't continue to update if anime is not in library
|
||||
if (getAnime.await(anime.id)?.favorite != true) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingAnime,
|
||||
progressCount,
|
||||
anime,
|
||||
) {
|
||||
when {
|
||||
ANIME_NON_COMPLETED in restrictions && anime.status.toInt() == SAnime.COMPLETED ->
|
||||
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_completed))
|
||||
|
||||
ANIME_HAS_UNSEEN in restrictions && libraryAnime.unseenCount != 0L ->
|
||||
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_caught_up))
|
||||
|
||||
ANIME_NON_SEEN in restrictions && libraryAnime.totalEpisodes > 0L && !libraryAnime.hasStarted ->
|
||||
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_started))
|
||||
|
||||
anime.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
|
||||
skippedUpdates.add(anime to context.getString(R.string.skipped_reason_not_always_update))
|
||||
|
||||
else -> {
|
||||
try {
|
||||
val newEpisodes = updateAnime(anime)
|
||||
.sortedByDescending { it.sourceOrder }
|
||||
|
||||
if (newEpisodes.isNotEmpty()) {
|
||||
val categoryIds = getCategories.await(anime.id).map { it.id }
|
||||
if (anime.shouldDownloadNewEpisodes(categoryIds, downloadPreferences)) {
|
||||
downloadEpisodes(anime, newEpisodes)
|
||||
hasDownloads.set(true)
|
||||
}
|
||||
|
||||
libraryPreferences.newAnimeUpdatesCount().getAndSet { it + newEpisodes.size }
|
||||
|
||||
// Convert to the anime that contains new chapters
|
||||
newUpdates.add(anime to newEpisodes.toTypedArray())
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
val errorMessage = when (e) {
|
||||
is NoEpisodesException -> context.getString(R.string.no_chapters_error)
|
||||
// failedUpdates will already have the source, don't need to copy it into the message
|
||||
is AnimeSourceManager.AnimeSourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error)
|
||||
else -> e.message
|
||||
}
|
||||
failedUpdates.add(anime to errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryPreferences.autoUpdateTrackers().get()) {
|
||||
updateTrackings(anime, loggedServices)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
|
||||
if (newUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateNotifications(newUpdates)
|
||||
if (hasDownloads.get()) {
|
||||
AnimeDownloadService.start(context)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedUpdates.isNotEmpty()) {
|
||||
val errorFile = writeErrorFile(failedUpdates)
|
||||
notifier.showUpdateErrorNotification(
|
||||
failedUpdates.size,
|
||||
errorFile.getUriCompat(context),
|
||||
)
|
||||
}
|
||||
if (skippedUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateSkippedNotification(skippedUpdates.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadEpisodes(anime: Anime, episodes: List<Episode>) {
|
||||
// We don't want to start downloading while the library is updating, because websites
|
||||
// may don't like it and they could ban the user.
|
||||
downloadManager.downloadEpisodes(anime, episodes, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the episodes for the given anime and adds them to the database.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
* @return a pair of the inserted and removed episodes.
|
||||
*/
|
||||
private suspend fun updateAnime(anime: Anime): List<Episode> {
|
||||
val source = sourceManager.getOrStub(anime.source)
|
||||
|
||||
// Update anime metadata if needed
|
||||
if (libraryPreferences.autoUpdateMetadata().get()) {
|
||||
val networkAnime = source.getAnimeDetails(anime.toSAnime())
|
||||
updateAnime.awaitUpdateFromSource(anime, networkAnime, manualFetch = false, coverCache)
|
||||
}
|
||||
|
||||
val episodes = source.getEpisodeList(anime.toSAnime())
|
||||
|
||||
// Get anime from database to account for if it was removed during the update and
|
||||
// to get latest data so it doesn't get overwritten later on
|
||||
val dbAnime = getAnime.await(anime.id)?.takeIf { it.favorite } ?: return emptyList()
|
||||
|
||||
return syncEpisodesWithSource.await(episodes, dbAnime, source)
|
||||
}
|
||||
|
||||
private suspend fun updateCovers() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingAnime = CopyOnWriteArrayList<Anime>()
|
||||
|
||||
coroutineScope {
|
||||
animeToUpdate.groupBy { it.anime.source }
|
||||
.values
|
||||
.map { animeInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
animeInSource.forEach { libraryAnime ->
|
||||
val anime = libraryAnime.anime
|
||||
ensureActive()
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingAnime,
|
||||
progressCount,
|
||||
anime,
|
||||
) {
|
||||
val source = sourceManager.get(anime.source) ?: return@withUpdateNotification
|
||||
try {
|
||||
val networkAnime = source.getAnimeDetails(anime.toSAnime())
|
||||
val updatedAnime = anime.prepUpdateCover(coverCache, networkAnime, true)
|
||||
.copyFrom(networkAnime)
|
||||
try {
|
||||
updateAnime.await(updatedAnime.toAnimeUpdate())
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Anime doesn't exist anymore" }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates the metadata of the connected tracking services. It's called in a
|
||||
* background thread, so it's safe to do heavy operations or network calls here.
|
||||
*/
|
||||
private suspend fun updateTrackings() {
|
||||
coroutineScope {
|
||||
var progressCount = 0
|
||||
val loggedServices = trackManager.services.filter { it.isLogged }
|
||||
|
||||
animeToUpdate.forEach { libraryAnime ->
|
||||
val anime = libraryAnime.anime
|
||||
|
||||
ensureActive()
|
||||
|
||||
notifier.showProgressNotification(listOf(anime), progressCount++, animeToUpdate.size)
|
||||
|
||||
// Update the tracking details.
|
||||
updateTrackings(anime, loggedServices)
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateTrackings(anime: Anime, loggedServices: List<TrackService>) {
|
||||
getTracks.await(anime.id)
|
||||
.map { track ->
|
||||
supervisorScope {
|
||||
async {
|
||||
val service = trackManager.getService(track.syncId)
|
||||
if (service != null && service in loggedServices) {
|
||||
try {
|
||||
val updatedTrack = service.animeService.refresh(track.toDbTrack())
|
||||
insertTrack.await(updatedTrack.toDomainTrack()!!)
|
||||
|
||||
if (service is EnhancedAnimeTrackService) {
|
||||
val episodes = getEpisodeByAnimeId.await(anime.id)
|
||||
syncEpisodesWithTrackServiceTwoWay.await(episodes, track, service.animeService)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
private suspend fun withUpdateNotification(
|
||||
updatingAnime: CopyOnWriteArrayList<Anime>,
|
||||
completed: AtomicInteger,
|
||||
anime: Anime,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
coroutineScope {
|
||||
ensureActive()
|
||||
|
||||
updatingAnime.add(anime)
|
||||
notifier.showProgressNotification(
|
||||
updatingAnime,
|
||||
completed.get(),
|
||||
animeToUpdate.size,
|
||||
)
|
||||
|
||||
block()
|
||||
|
||||
ensureActive()
|
||||
|
||||
updatingAnime.remove(anime)
|
||||
completed.getAndIncrement()
|
||||
notifier.showProgressNotification(
|
||||
updatingAnime,
|
||||
completed.get(),
|
||||
animeToUpdate.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes basic file of update errors to cache dir.
|
||||
*/
|
||||
private fun writeErrorFile(errors: List<Pair<Anime, String?>>): File {
|
||||
try {
|
||||
if (errors.isNotEmpty()) {
|
||||
val file = context.createFileInCacheDir("aniyomi_update_errors.txt")
|
||||
file.bufferedWriter().use { out ->
|
||||
out.write(context.getString(R.string.library_errors_help, ERROR_LOG_HELP_URL) + "\n\n")
|
||||
// Error file format:
|
||||
// ! Error
|
||||
// # Source
|
||||
// - Anime
|
||||
errors.groupBy({ it.second }, { it.first }).forEach { (error, animes) ->
|
||||
out.write("\n! ${error}\n")
|
||||
animes.groupBy { it.source }.forEach { (srcId, animes) ->
|
||||
val source = sourceManager.getOrStub(srcId)
|
||||
out.write(" # $source\n")
|
||||
animes.forEach {
|
||||
out.write(" - ${it.title}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return File("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what should be updated within a service execution.
|
||||
*/
|
||||
enum class Target {
|
||||
EPISODES, // Anime episodes
|
||||
COVERS, // Anime covers
|
||||
TRACKING, // Tracking metadata
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AnimelibUpdate"
|
||||
private const val WORK_NAME_AUTO = "LibraryUpdate-auto"
|
||||
private const val WORK_NAME_MANUAL = "LibraryUpdate-manual"
|
||||
|
||||
fun setupTask(context: Context, prefInterval: Int? = null) {
|
||||
private const val ERROR_LOG_HELP_URL = "https://aniyomi.org/help/guides/troubleshooting"
|
||||
|
||||
private const val ANIME_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
||||
|
||||
/**
|
||||
* Key for category to update.
|
||||
*/
|
||||
private const val KEY_CATEGORY = "category"
|
||||
|
||||
/**
|
||||
* Key that defines what should be updated.
|
||||
*/
|
||||
private const val KEY_TARGET = "target"
|
||||
|
||||
fun cancelAllWorks(context: Context) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
||||
}
|
||||
|
||||
fun setupTask(
|
||||
context: Context,
|
||||
prefInterval: Int? = null,
|
||||
) {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val interval = prefInterval ?: preferences.libraryUpdateInterval().get()
|
||||
if (interval > 0) {
|
||||
|
@ -56,14 +563,56 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
TimeUnit.MINUTES,
|
||||
)
|
||||
.addTag(TAG)
|
||||
.addTag(WORK_NAME_AUTO)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
// Re-enqueue work because of common support suggestion to change
|
||||
// the settings on the desired time to schedule it at that time
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, request)
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
} else {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_AUTO)
|
||||
}
|
||||
}
|
||||
fun startNow(
|
||||
context: Context,
|
||||
category: Category? = null,
|
||||
target: Target = Target.EPISODES,
|
||||
): Boolean {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
val infos = wm.getWorkInfosByTag(TAG).get()
|
||||
if (infos.find { it.state == WorkInfo.State.RUNNING } != null) {
|
||||
// Already running either as a scheduled or manual job
|
||||
return false
|
||||
}
|
||||
|
||||
val inputData = workDataOf(
|
||||
KEY_CATEGORY to category?.id,
|
||||
KEY_TARGET to target.name,
|
||||
)
|
||||
val request = OneTimeWorkRequestBuilder<AnimeLibraryUpdateJob>()
|
||||
.addTag(TAG)
|
||||
.addTag(WORK_NAME_MANUAL)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
|
||||
.addStates(listOf(WorkInfo.State.RUNNING))
|
||||
.build()
|
||||
wm.getWorkInfos(workQuery).get()
|
||||
// Should only return one work but just in case
|
||||
.forEach {
|
||||
wm.cancelWorkById(it.id)
|
||||
|
||||
// Re-enqueue cancelled scheduled work
|
||||
if (it.tags.contains(WORK_NAME_AUTO)) {
|
||||
setupTask(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,611 +0,0 @@
|
|||
package eu.kanade.tachiyomi.data.library.anime
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.kanade.domain.download.service.DownloadPreferences
|
||||
import eu.kanade.domain.entries.anime.interactor.GetAnime
|
||||
import eu.kanade.domain.entries.anime.interactor.GetLibraryAnime
|
||||
import eu.kanade.domain.entries.anime.interactor.UpdateAnime
|
||||
import eu.kanade.domain.entries.anime.model.copyFrom
|
||||
import eu.kanade.domain.entries.anime.model.toSAnime
|
||||
import eu.kanade.domain.items.episode.interactor.GetEpisodeByAnimeId
|
||||
import eu.kanade.domain.items.episode.interactor.SyncEpisodesWithSource
|
||||
import eu.kanade.domain.items.episode.interactor.SyncEpisodesWithTrackServiceTwoWay
|
||||
import eu.kanade.domain.library.service.LibraryPreferences
|
||||
import eu.kanade.domain.track.anime.interactor.GetAnimeTracks
|
||||
import eu.kanade.domain.track.anime.interactor.InsertAnimeTrack
|
||||
import eu.kanade.domain.track.anime.model.toDbTrack
|
||||
import eu.kanade.domain.track.anime.model.toDomainTrack
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import eu.kanade.tachiyomi.data.cache.AnimeCoverCache
|
||||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadService
|
||||
import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateService.Companion.start
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ
|
||||
import eu.kanade.tachiyomi.data.track.AnimeTrackService
|
||||
import eu.kanade.tachiyomi.data.track.EnhancedAnimeTrackService
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.source.UnmeteredSource
|
||||
import eu.kanade.tachiyomi.source.anime.AnimeSourceManager
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.util.prepUpdateCover
|
||||
import eu.kanade.tachiyomi.util.shouldDownloadNewEpisodes
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.acquireWakeLock
|
||||
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
||||
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
|
||||
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.getAndSet
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.category.anime.interactor.GetAnimeCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.entries.anime.model.Anime
|
||||
import tachiyomi.domain.entries.anime.model.toAnimeUpdate
|
||||
import tachiyomi.domain.items.episode.model.Episode
|
||||
import tachiyomi.domain.items.episode.model.NoEpisodesException
|
||||
import tachiyomi.domain.library.anime.LibraryAnime
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* This class will take care of updating the episodes of the anime from the library. It can be
|
||||
* started calling the [start] method. If it's already running, it won't do anything.
|
||||
* While the library is updating, a [PowerManager.WakeLock] will be held until the update is
|
||||
* completed, preventing the device from going to sleep mode. A notification will display the
|
||||
* progress of the update, and if case of an unexpected error, this service will be silently
|
||||
* destroyed.
|
||||
*/
|
||||
class AnimeLibraryUpdateService(
|
||||
val sourceManager: AnimeSourceManager = Injekt.get(),
|
||||
val downloadPreferences: DownloadPreferences = Injekt.get(),
|
||||
val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||
val downloadManager: AnimeDownloadManager = Injekt.get(),
|
||||
val trackManager: TrackManager = Injekt.get(),
|
||||
val coverCache: AnimeCoverCache = Injekt.get(),
|
||||
private val getLibraryAnime: GetLibraryAnime = Injekt.get(),
|
||||
private val getAnime: GetAnime = Injekt.get(),
|
||||
private val updateAnime: UpdateAnime = Injekt.get(),
|
||||
private val getEpisodeByAnimeId: GetEpisodeByAnimeId = Injekt.get(),
|
||||
private val getCategories: GetAnimeCategories = Injekt.get(),
|
||||
private val syncEpisodesWithSource: SyncEpisodesWithSource = Injekt.get(),
|
||||
private val getTracks: GetAnimeTracks = Injekt.get(),
|
||||
private val insertTrack: InsertAnimeTrack = Injekt.get(),
|
||||
private val syncEpisodesWithTrackServiceTwoWay: SyncEpisodesWithTrackServiceTwoWay = Injekt.get(),
|
||||
) : Service() {
|
||||
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var notifier: AnimeLibraryUpdateNotifier
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
private var animeToUpdate: List<LibraryAnime> = mutableListOf()
|
||||
private var updateJob: Job? = null
|
||||
|
||||
/**
|
||||
* Defines what should be updated within a service execution.
|
||||
*/
|
||||
enum class Target {
|
||||
EPISODES, // Anime episodes
|
||||
COVERS, // Anime covers
|
||||
TRACKING, // Tracking metadata
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private var instance: AnimeLibraryUpdateService? = null
|
||||
|
||||
/**
|
||||
* Key for category to update.
|
||||
*/
|
||||
const val KEY_CATEGORY = "category"
|
||||
|
||||
/**
|
||||
* Key that defines what should be updated.
|
||||
*/
|
||||
const val KEY_TARGET = "target"
|
||||
|
||||
/**
|
||||
* Returns the status of the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @return true if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return context.isServiceRunning(AnimeLibraryUpdateService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the service. It will be started only if there isn't another instance already
|
||||
* running.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @param category a specific category to update, or null for global update.
|
||||
* @param target defines what should be updated.
|
||||
* @return true if service newly started, false otherwise
|
||||
*/
|
||||
fun start(context: Context, category: Category? = null, target: Target = Target.EPISODES): Boolean {
|
||||
if (isRunning(context)) return false
|
||||
val intent = Intent(context, AnimeLibraryUpdateService::class.java).apply {
|
||||
putExtra(KEY_TARGET, target)
|
||||
category?.let { putExtra(KEY_CATEGORY, it.id) }
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, AnimeLibraryUpdateService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dagger dependencies and acquire
|
||||
* the wake lock.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
notifier = AnimeLibraryUpdateNotifier(this)
|
||||
wakeLock = acquireWakeLock(javaClass.name)
|
||||
|
||||
startForeground(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys subscriptions and releases the wake
|
||||
* lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
updateJob?.cancel()
|
||||
scope?.cancel()
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
if (instance == this) {
|
||||
instance = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method needs to be implemented, but it's not used/needed.
|
||||
*/
|
||||
override fun onBind(intent: Intent): IBinder? = null
|
||||
|
||||
/**
|
||||
* Method called when the service receives an intent.
|
||||
*
|
||||
* @param intent the start intent from.
|
||||
* @param flags the flags of the command.
|
||||
* @param startId the start id of this command.
|
||||
* @return the start value of the command.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) return START_NOT_STICKY
|
||||
val target = intent.getSerializableExtraCompat<Target>(KEY_TARGET)
|
||||
?: return START_NOT_STICKY
|
||||
|
||||
instance = this
|
||||
|
||||
// Unsubscribe from any previous subscription if needed
|
||||
updateJob?.cancel()
|
||||
scope?.cancel()
|
||||
|
||||
// If this is a episode update; set the last update time to now
|
||||
if (target == Target.EPISODES) {
|
||||
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time)
|
||||
}
|
||||
|
||||
// Update favorite anime
|
||||
val categoryId = intent.getLongExtra(KEY_CATEGORY, -1L)
|
||||
addAnimeToQueue(categoryId)
|
||||
|
||||
// Destroy service when completed or in case of an error.
|
||||
val handler = CoroutineExceptionHandler { _, exception ->
|
||||
logcat(LogPriority.ERROR, exception)
|
||||
stopSelf(startId)
|
||||
}
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
updateJob = scope?.launch(handler) {
|
||||
when (target) {
|
||||
Target.EPISODES -> updateEpisodeList()
|
||||
Target.COVERS -> updateCovers()
|
||||
Target.TRACKING -> updateTrackings()
|
||||
}
|
||||
}
|
||||
updateJob?.invokeOnCompletion { stopSelf(startId) }
|
||||
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private val isUpdateJobActive: Boolean
|
||||
get() = (updateJob?.isActive == true)
|
||||
|
||||
/**
|
||||
* Adds list of anime to be updated.
|
||||
*
|
||||
* @param categoryId the ID of the category to update, or -1 if no category specified.
|
||||
*/
|
||||
private fun addAnimeToQueue(categoryId: Long) {
|
||||
val animelibAnime = runBlocking { getLibraryAnime.await() }
|
||||
|
||||
val listToUpdate = if (categoryId != -1L) {
|
||||
animelibAnime.filter { it.category == categoryId }
|
||||
} else {
|
||||
val categoriesToUpdate = libraryPreferences.animeLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val includedAnime = if (categoriesToUpdate.isNotEmpty()) {
|
||||
animelibAnime.filter { it.category in categoriesToUpdate }
|
||||
} else {
|
||||
animelibAnime
|
||||
}
|
||||
|
||||
val categoriesToExclude = libraryPreferences.animeLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedAnimeIds = if (categoriesToExclude.isNotEmpty()) {
|
||||
animelibAnime.filter { it.category in categoriesToExclude }.map { it.anime.id }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
includedAnime
|
||||
.filterNot { it.anime.id in excludedAnimeIds }
|
||||
.distinctBy { it.anime.id }
|
||||
}
|
||||
|
||||
animeToUpdate = listToUpdate
|
||||
.sortedBy { it.anime.title }
|
||||
|
||||
// Warn when excessively checking a single source
|
||||
val maxUpdatesFromSource = animeToUpdate
|
||||
.groupBy { it.anime.source }
|
||||
.filterKeys { sourceManager.get(it) !is UnmeteredSource }
|
||||
.maxOfOrNull { it.value.size } ?: 0
|
||||
// TODO: show warnings in stable
|
||||
if (maxUpdatesFromSource > ANIME_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
|
||||
notifier.showQueueSizeWarningNotification()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates the anime in [animeToUpdate]. It's called in a background thread, so it's safe
|
||||
* to do heavy operations or network calls here.
|
||||
* For each anime it calls [updateAnime] and updates the notification showing the current
|
||||
* progress.
|
||||
*
|
||||
* @return an observable delivering the progress of each update.
|
||||
*/
|
||||
private suspend fun updateEpisodeList() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingAnime = CopyOnWriteArrayList<Anime>()
|
||||
val newUpdates = CopyOnWriteArrayList<Pair<Anime, Array<Episode>>>()
|
||||
val skippedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
|
||||
val hasDownloads = AtomicBoolean(false)
|
||||
val loggedServices by lazy { trackManager.services.filter { it.isLogged && it is AnimeTrackService } }
|
||||
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
|
||||
withIOContext {
|
||||
animeToUpdate.groupBy { it.anime.source }.values
|
||||
.map { animeInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
animeInSource.forEach { animelibAnime ->
|
||||
val anime = animelibAnime.anime
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return@async
|
||||
}
|
||||
|
||||
// Don't continue to update if anime is not in library
|
||||
if (getAnime.await(anime.id)?.favorite != true) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingAnime,
|
||||
progressCount,
|
||||
anime,
|
||||
) {
|
||||
when {
|
||||
MANGA_NON_COMPLETED in restrictions && anime.status.toInt() == SAnime.COMPLETED ->
|
||||
skippedUpdates.add(anime to getString(R.string.skipped_reason_completed))
|
||||
|
||||
MANGA_HAS_UNREAD in restrictions && animelibAnime.unseenCount != 0L ->
|
||||
skippedUpdates.add(anime to getString(R.string.skipped_reason_not_caught_up))
|
||||
|
||||
MANGA_NON_READ in restrictions && animelibAnime.totalEpisodes > 0L && !animelibAnime.hasStarted ->
|
||||
skippedUpdates.add(anime to getString(R.string.skipped_reason_not_started))
|
||||
|
||||
anime.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
|
||||
skippedUpdates.add(anime to getString(R.string.skipped_reason_not_always_update))
|
||||
|
||||
else -> {
|
||||
try {
|
||||
val newEpisodes = updateAnime(anime)
|
||||
.sortedByDescending { it.sourceOrder }
|
||||
|
||||
if (newEpisodes.isNotEmpty()) {
|
||||
val categoryIds = getCategories.await(anime.id).map { it.id }
|
||||
if (anime.shouldDownloadNewEpisodes(categoryIds, downloadPreferences)) {
|
||||
downloadEpisodes(anime, newEpisodes)
|
||||
hasDownloads.set(true)
|
||||
}
|
||||
|
||||
libraryPreferences.newAnimeUpdatesCount().getAndSet { it + newEpisodes.size }
|
||||
|
||||
// Convert to the anime that contains new chapters
|
||||
newUpdates.add(anime to newEpisodes.toTypedArray())
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
val errorMessage = when (e) {
|
||||
is NoEpisodesException -> getString(R.string.no_episodes_error)
|
||||
// failedUpdates will already have the source, don't need to copy it into the message
|
||||
is AnimeSourceManager.AnimeSourceNotInstalledException -> getString(R.string.loader_not_implemented_error)
|
||||
else -> e.message
|
||||
}
|
||||
failedUpdates.add(anime to errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryPreferences.autoUpdateTrackers().get()) {
|
||||
updateTrackings(anime, loggedServices)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
|
||||
if (newUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateNotifications(newUpdates)
|
||||
if (hasDownloads.get()) {
|
||||
AnimeDownloadService.start(this)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedUpdates.isNotEmpty()) {
|
||||
val errorFile = writeErrorFile(failedUpdates)
|
||||
notifier.showUpdateErrorNotification(
|
||||
failedUpdates.size,
|
||||
errorFile.getUriCompat(this),
|
||||
)
|
||||
}
|
||||
if (skippedUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateSkippedNotification(skippedUpdates.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadEpisodes(anime: Anime, episodes: List<Episode>) {
|
||||
// We don't want to start downloading while the library is updating, because websites
|
||||
// may don't like it and they could ban the user.
|
||||
downloadManager.downloadEpisodes(anime, episodes, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the episodes for the given anime and adds them to the database.
|
||||
*
|
||||
* @param anime the anime to update.
|
||||
* @return a pair of the inserted and removed episodes.
|
||||
*/
|
||||
private suspend fun updateAnime(anime: Anime): List<Episode> {
|
||||
val source = sourceManager.getOrStub(anime.source)
|
||||
|
||||
// Update anime metadata if needed
|
||||
if (libraryPreferences.autoUpdateMetadata().get()) {
|
||||
val networkAnime = source.getAnimeDetails(anime.toSAnime())
|
||||
updateAnime.awaitUpdateFromSource(anime, networkAnime, manualFetch = false, coverCache)
|
||||
}
|
||||
|
||||
val episodes = source.getEpisodeList(anime.toSAnime())
|
||||
|
||||
// Get anime from database to account for if it was removed during the update and
|
||||
// to get latest data so it doesn't get overwritten later on
|
||||
val dbAnime = getAnime.await(anime.id)?.takeIf { it.favorite } ?: return emptyList()
|
||||
|
||||
return syncEpisodesWithSource.await(episodes, dbAnime, source)
|
||||
}
|
||||
|
||||
private suspend fun updateCovers() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingAnime = CopyOnWriteArrayList<Anime>()
|
||||
|
||||
withIOContext {
|
||||
animeToUpdate.groupBy { it.anime.source }
|
||||
.values
|
||||
.map { animeInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
animeInSource.forEach { animelibAnime ->
|
||||
val anime = animelibAnime.anime
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return@async
|
||||
}
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingAnime,
|
||||
progressCount,
|
||||
anime,
|
||||
) {
|
||||
val source = sourceManager.get(anime.source) ?: return@withUpdateNotification
|
||||
try {
|
||||
val networkAnime = source.getAnimeDetails(anime.toSAnime())
|
||||
val updatedAnime = anime.prepUpdateCover(coverCache, networkAnime, true)
|
||||
.copyFrom(networkAnime)
|
||||
try {
|
||||
updateAnime.await(updatedAnime.toAnimeUpdate())
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Manga doesn't exist anymore" }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates the metadata of the connected tracking services. It's called in a
|
||||
* background thread, so it's safe to do heavy operations or network calls here.
|
||||
*/
|
||||
private suspend fun updateTrackings() {
|
||||
var progressCount = 0
|
||||
val loggedServices = trackManager.services.filter { it.isLogged && it is AnimeTrackService }
|
||||
|
||||
animeToUpdate.forEach { animelibAnime ->
|
||||
val anime = animelibAnime.anime
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
|
||||
notifier.showProgressNotification(
|
||||
listOf(anime),
|
||||
progressCount++,
|
||||
animeToUpdate.size,
|
||||
)
|
||||
|
||||
// Update the tracking details.
|
||||
updateTrackings(anime, loggedServices)
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateTrackings(anime: Anime, loggedServices: List<TrackService>) {
|
||||
getTracks.await(anime.id)
|
||||
.map { track ->
|
||||
supervisorScope {
|
||||
async {
|
||||
val service = trackManager.getService(track.syncId)
|
||||
if (service != null && service in loggedServices) {
|
||||
try {
|
||||
val updatedTrack = service.animeService.refresh(track.toDbTrack())
|
||||
insertTrack.await(updatedTrack.toDomainTrack()!!)
|
||||
|
||||
if (service is EnhancedAnimeTrackService) {
|
||||
val episodes = getEpisodeByAnimeId.await(anime.id)
|
||||
syncEpisodesWithTrackServiceTwoWay.await(episodes, track, service.animeService)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
private suspend fun withUpdateNotification(
|
||||
updatingAnime: CopyOnWriteArrayList<Anime>,
|
||||
completed: AtomicInteger,
|
||||
anime: Anime,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return
|
||||
}
|
||||
|
||||
updatingAnime.add(anime)
|
||||
notifier.showProgressNotification(
|
||||
updatingAnime,
|
||||
completed.get(),
|
||||
animeToUpdate.size,
|
||||
)
|
||||
|
||||
block()
|
||||
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return
|
||||
}
|
||||
|
||||
updatingAnime.remove(anime)
|
||||
completed.getAndIncrement()
|
||||
notifier.showProgressNotification(
|
||||
updatingAnime,
|
||||
completed.get(),
|
||||
animeToUpdate.size,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes basic file of update errors to cache dir.
|
||||
*/
|
||||
private fun writeErrorFile(errors: List<Pair<Anime, String?>>): File {
|
||||
try {
|
||||
if (errors.isNotEmpty()) {
|
||||
val file = createFileInCacheDir("aniyomi_update_errors.txt")
|
||||
file.bufferedWriter().use { out ->
|
||||
out.write(getString(R.string.library_errors_help, ERROR_LOG_HELP_URL) + "\n\n")
|
||||
// Error file format:
|
||||
// ! Error
|
||||
// # Source
|
||||
// - Anime
|
||||
errors.groupBy({ it.second }, { it.first }).forEach { (error, animes) ->
|
||||
out.write("\n! ${error}\n")
|
||||
animes.groupBy { it.source }.forEach { (srcId, animes) ->
|
||||
val source = sourceManager.getOrStub(srcId)
|
||||
out.write(" # $source\n")
|
||||
animes.forEach {
|
||||
out.write(" - ${it.title}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return File("")
|
||||
}
|
||||
}
|
||||
|
||||
private const val ANIME_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
||||
private const val ERROR_LOG_HELP_URL = "https://aniyomi.org/help/guides/troubleshooting"
|
|
@ -1,44 +1,550 @@
|
|||
package eu.kanade.tachiyomi.data.library.manga
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import eu.kanade.domain.download.service.DownloadPreferences
|
||||
import eu.kanade.domain.entries.manga.interactor.GetLibraryManga
|
||||
import eu.kanade.domain.entries.manga.interactor.GetManga
|
||||
import eu.kanade.domain.entries.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.entries.manga.model.copyFrom
|
||||
import eu.kanade.domain.entries.manga.model.toSManga
|
||||
import eu.kanade.domain.items.chapter.interactor.GetChapterByMangaId
|
||||
import eu.kanade.domain.items.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.items.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
|
||||
import eu.kanade.domain.library.service.LibraryPreferences
|
||||
import eu.kanade.domain.track.manga.interactor.GetMangaTracks
|
||||
import eu.kanade.domain.track.manga.interactor.InsertMangaTrack
|
||||
import eu.kanade.domain.track.manga.model.toDbTrack
|
||||
import eu.kanade.domain.track.manga.model.toDomainTrack
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.cache.MangaCoverCache
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadService
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_NETWORK_NOT_METERED
|
||||
import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ
|
||||
import eu.kanade.tachiyomi.data.track.EnhancedMangaTrackService
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.source.UnmeteredSource
|
||||
import eu.kanade.tachiyomi.source.manga.MangaSourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.util.prepUpdateCover
|
||||
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
||||
import eu.kanade.tachiyomi.util.system.isConnectedToWifi
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.getAndSet
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.category.manga.interactor.GetMangaCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.entries.manga.model.Manga
|
||||
import tachiyomi.domain.entries.manga.model.toMangaUpdate
|
||||
import tachiyomi.domain.items.chapter.model.Chapter
|
||||
import tachiyomi.domain.items.chapter.model.NoChaptersException
|
||||
import tachiyomi.domain.library.manga.LibraryManga
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
Worker(context, workerParams) {
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
private val sourceManager: MangaSourceManager = Injekt.get()
|
||||
private val downloadPreferences: DownloadPreferences = Injekt.get()
|
||||
private val libraryPreferences: LibraryPreferences = Injekt.get()
|
||||
private val downloadManager: MangaDownloadManager = Injekt.get()
|
||||
private val trackManager: TrackManager = Injekt.get()
|
||||
private val coverCache: MangaCoverCache = Injekt.get()
|
||||
private val getLibraryManga: GetLibraryManga = Injekt.get()
|
||||
private val getManga: GetManga = Injekt.get()
|
||||
private val updateManga: UpdateManga = Injekt.get()
|
||||
private val getChapterByMangaId: GetChapterByMangaId = Injekt.get()
|
||||
private val getCategories: GetMangaCategories = Injekt.get()
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
|
||||
private val getTracks: GetMangaTracks = Injekt.get()
|
||||
private val insertTrack: InsertMangaTrack = Injekt.get()
|
||||
private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get()
|
||||
|
||||
private val notifier = MangaLibraryUpdateNotifier(context)
|
||||
|
||||
private var mangaToUpdate: List<LibraryManga> = mutableListOf()
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val restrictions = preferences.libraryUpdateDeviceRestriction().get()
|
||||
if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) {
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
return if (MangaLibraryUpdateService.start(context)) {
|
||||
if (tags.contains(WORK_NAME_AUTO)) {
|
||||
// Find a running manual worker. If exists, try again later
|
||||
val otherRunningWorker = withContext(Dispatchers.IO) {
|
||||
WorkManager.getInstance(context)
|
||||
.getWorkInfosByTag(WORK_NAME_MANUAL)
|
||||
.get()
|
||||
.find { it.state == WorkInfo.State.RUNNING }
|
||||
}
|
||||
if (otherRunningWorker != null) {
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setForeground(getForegroundInfo())
|
||||
} catch (e: IllegalStateException) {
|
||||
logcat(LogPriority.ERROR, e) { "Not allowed to set foreground job" }
|
||||
}
|
||||
|
||||
val target = inputData.getString(KEY_TARGET)?.let { Target.valueOf(it) } ?: Target.CHAPTERS
|
||||
|
||||
// If this is a chapter update; set the last update time to now
|
||||
if (target == Target.CHAPTERS) {
|
||||
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time)
|
||||
}
|
||||
|
||||
val categoryId = inputData.getLong(KEY_CATEGORY, -1L)
|
||||
addMangaToQueue(categoryId)
|
||||
|
||||
return withIOContext {
|
||||
try {
|
||||
when (target) {
|
||||
Target.CHAPTERS -> updateChapterList()
|
||||
Target.COVERS -> updateCovers()
|
||||
Target.TRACKING -> updateTrackings()
|
||||
}
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
// Assume success although cancelled
|
||||
Result.success()
|
||||
} else {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
Result.failure()
|
||||
}
|
||||
} finally {
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notifier = MangaLibraryUpdateNotifier(context)
|
||||
return ForegroundInfo(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds list of manga to be updated.
|
||||
*
|
||||
* @param categoryId the ID of the category to update, or -1 if no category specified.
|
||||
*/
|
||||
private fun addMangaToQueue(categoryId: Long) {
|
||||
val libraryManga = runBlocking { getLibraryManga.await() }
|
||||
|
||||
val listToUpdate = if (categoryId != -1L) {
|
||||
libraryManga.filter { it.category == categoryId }
|
||||
} else {
|
||||
val categoriesToUpdate = libraryPreferences.mangaLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val includedManga = if (categoriesToUpdate.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToUpdate }
|
||||
} else {
|
||||
libraryManga
|
||||
}
|
||||
|
||||
val categoriesToExclude = libraryPreferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
includedManga
|
||||
.filterNot { it.manga.id in excludedMangaIds }
|
||||
.distinctBy { it.manga.id }
|
||||
}
|
||||
|
||||
mangaToUpdate = listToUpdate
|
||||
.sortedBy { it.manga.title }
|
||||
|
||||
// Warn when excessively checking a single source
|
||||
val maxUpdatesFromSource = mangaToUpdate
|
||||
.groupBy { it.manga.source }
|
||||
.filterKeys { sourceManager.get(it) !is UnmeteredSource }
|
||||
.maxOfOrNull { it.value.size } ?: 0
|
||||
if (maxUpdatesFromSource > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
|
||||
notifier.showQueueSizeWarningNotification()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates manga in [mangaToUpdate]. It's called in a background thread, so it's safe
|
||||
* to do heavy operations or network calls here.
|
||||
* For each manga it calls [updateManga] and updates the notification showing the current
|
||||
* progress.
|
||||
*
|
||||
* @return an observable delivering the progress of each update.
|
||||
*/
|
||||
private suspend fun updateChapterList() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
||||
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
|
||||
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
val hasDownloads = AtomicBoolean(false)
|
||||
val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
|
||||
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
|
||||
coroutineScope {
|
||||
mangaToUpdate.groupBy { it.manga.source }.values
|
||||
.map { mangaInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
mangaInSource.forEach { libraryManga ->
|
||||
val manga = libraryManga.manga
|
||||
ensureActive()
|
||||
|
||||
// Don't continue to update if manga is not in library
|
||||
if (getManga.await(manga.id)?.favorite != true) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount,
|
||||
manga,
|
||||
) {
|
||||
when {
|
||||
MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED ->
|
||||
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed))
|
||||
|
||||
MANGA_HAS_UNREAD in restrictions && libraryManga.unreadCount != 0L ->
|
||||
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_caught_up))
|
||||
|
||||
MANGA_NON_READ in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted ->
|
||||
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_started))
|
||||
|
||||
manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
|
||||
skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_always_update))
|
||||
|
||||
else -> {
|
||||
try {
|
||||
val newChapters = updateManga(manga)
|
||||
.sortedByDescending { it.sourceOrder }
|
||||
|
||||
if (newChapters.isNotEmpty()) {
|
||||
val categoryIds = getCategories.await(manga.id).map { it.id }
|
||||
if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) {
|
||||
downloadChapters(manga, newChapters)
|
||||
hasDownloads.set(true)
|
||||
}
|
||||
|
||||
libraryPreferences.newMangaUpdatesCount().getAndSet { it + newChapters.size }
|
||||
|
||||
// Convert to the manga that contains new chapters
|
||||
newUpdates.add(manga to newChapters.toTypedArray())
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
val errorMessage = when (e) {
|
||||
is NoChaptersException -> context.getString(R.string.no_chapters_error)
|
||||
// failedUpdates will already have the source, don't need to copy it into the message
|
||||
is MangaSourceManager.SourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error)
|
||||
else -> e.message
|
||||
}
|
||||
failedUpdates.add(manga to errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryPreferences.autoUpdateTrackers().get()) {
|
||||
updateTrackings(manga, loggedServices)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
|
||||
if (newUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateNotifications(newUpdates)
|
||||
if (hasDownloads.get()) {
|
||||
MangaDownloadService.start(context)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedUpdates.isNotEmpty()) {
|
||||
val errorFile = writeErrorFile(failedUpdates)
|
||||
notifier.showUpdateErrorNotification(
|
||||
failedUpdates.size,
|
||||
errorFile.getUriCompat(context),
|
||||
)
|
||||
}
|
||||
if (skippedUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateSkippedNotification(skippedUpdates.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||
// We don't want to start downloading while the library is updating, because websites
|
||||
// may don't like it and they could ban the user.
|
||||
downloadManager.downloadChapters(manga, chapters, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the chapters for the given manga and adds them to the database.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
* @return a pair of the inserted and removed chapters.
|
||||
*/
|
||||
private suspend fun updateManga(manga: Manga): List<Chapter> {
|
||||
val source = sourceManager.getOrStub(manga.source)
|
||||
|
||||
// Update manga metadata if needed
|
||||
if (libraryPreferences.autoUpdateMetadata().get()) {
|
||||
val networkManga = source.getMangaDetails(manga.toSManga())
|
||||
updateManga.awaitUpdateFromSource(manga, networkManga, manualFetch = false, coverCache)
|
||||
}
|
||||
|
||||
val chapters = source.getChapterList(manga.toSManga())
|
||||
|
||||
// Get manga from database to account for if it was removed during the update and
|
||||
// to get latest data so it doesn't get overwritten later on
|
||||
val dbManga = getManga.await(manga.id)?.takeIf { it.favorite } ?: return emptyList()
|
||||
|
||||
return syncChaptersWithSource.await(chapters, dbManga, source)
|
||||
}
|
||||
|
||||
private suspend fun updateCovers() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
||||
|
||||
coroutineScope {
|
||||
mangaToUpdate.groupBy { it.manga.source }
|
||||
.values
|
||||
.map { mangaInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
mangaInSource.forEach { libraryManga ->
|
||||
val manga = libraryManga.manga
|
||||
ensureActive()
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount,
|
||||
manga,
|
||||
) {
|
||||
val source = sourceManager.get(manga.source) ?: return@withUpdateNotification
|
||||
try {
|
||||
val networkManga = source.getMangaDetails(manga.toSManga())
|
||||
val updatedManga = manga.prepUpdateCover(coverCache, networkManga, true)
|
||||
.copyFrom(networkManga)
|
||||
try {
|
||||
updateManga.await(updatedManga.toMangaUpdate())
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Manga doesn't exist anymore" }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates the metadata of the connected tracking services. It's called in a
|
||||
* background thread, so it's safe to do heavy operations or network calls here.
|
||||
*/
|
||||
private suspend fun updateTrackings() {
|
||||
coroutineScope {
|
||||
var progressCount = 0
|
||||
val loggedServices = trackManager.services.filter { it.isLogged }
|
||||
|
||||
mangaToUpdate.forEach { libraryManga ->
|
||||
val manga = libraryManga.manga
|
||||
|
||||
ensureActive()
|
||||
|
||||
notifier.showProgressNotification(listOf(manga), progressCount++, mangaToUpdate.size)
|
||||
|
||||
// Update the tracking details.
|
||||
updateTrackings(manga, loggedServices)
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateTrackings(manga: Manga, loggedServices: List<TrackService>) {
|
||||
getTracks.await(manga.id)
|
||||
.map { track ->
|
||||
supervisorScope {
|
||||
async {
|
||||
val service = trackManager.getService(track.syncId)
|
||||
if (service != null && service in loggedServices) {
|
||||
try {
|
||||
val updatedTrack = service.mangaService.refresh(track.toDbTrack())
|
||||
insertTrack.await(updatedTrack.toDomainTrack()!!)
|
||||
|
||||
if (service is EnhancedMangaTrackService) {
|
||||
val chapters = getChapterByMangaId.await(manga.id)
|
||||
syncChaptersWithTrackServiceTwoWay.await(chapters, track, service.mangaService)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
private suspend fun withUpdateNotification(
|
||||
updatingManga: CopyOnWriteArrayList<Manga>,
|
||||
completed: AtomicInteger,
|
||||
manga: Manga,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
coroutineScope {
|
||||
ensureActive()
|
||||
|
||||
updatingManga.add(manga)
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
|
||||
block()
|
||||
|
||||
ensureActive()
|
||||
|
||||
updatingManga.remove(manga)
|
||||
completed.getAndIncrement()
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes basic file of update errors to cache dir.
|
||||
*/
|
||||
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
|
||||
try {
|
||||
if (errors.isNotEmpty()) {
|
||||
val file = context.createFileInCacheDir("tachiyomi_update_errors.txt")
|
||||
file.bufferedWriter().use { out ->
|
||||
out.write(context.getString(R.string.library_errors_help, ERROR_LOG_HELP_URL) + "\n\n")
|
||||
// Error file format:
|
||||
// ! Error
|
||||
// # Source
|
||||
// - Manga
|
||||
errors.groupBy({ it.second }, { it.first }).forEach { (error, mangas) ->
|
||||
out.write("\n! ${error}\n")
|
||||
mangas.groupBy { it.source }.forEach { (srcId, mangas) ->
|
||||
val source = sourceManager.getOrStub(srcId)
|
||||
out.write(" # $source\n")
|
||||
mangas.forEach {
|
||||
out.write(" - ${it.title}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return File("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what should be updated within a service execution.
|
||||
*/
|
||||
enum class Target {
|
||||
CHAPTERS, // Manga chapters
|
||||
COVERS, // Manga covers
|
||||
TRACKING, // Tracking metadata
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LibraryUpdate"
|
||||
private const val WORK_NAME_AUTO = "LibraryUpdate-auto"
|
||||
private const val WORK_NAME_MANUAL = "LibraryUpdate-manual"
|
||||
|
||||
fun setupTask(context: Context, prefInterval: Int? = null) {
|
||||
private const val ERROR_LOG_HELP_URL = "https://aniyomi.org/help/guides/troubleshooting"
|
||||
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
||||
|
||||
/**
|
||||
* Key for category to update.
|
||||
*/
|
||||
private const val KEY_CATEGORY = "category"
|
||||
|
||||
/**
|
||||
* Key that defines what should be updated.
|
||||
*/
|
||||
private const val KEY_TARGET = "target"
|
||||
|
||||
fun cancelAllWorks(context: Context) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
||||
}
|
||||
|
||||
fun setupTask(
|
||||
context: Context,
|
||||
prefInterval: Int? = null,
|
||||
) {
|
||||
val preferences = Injekt.get<LibraryPreferences>()
|
||||
val interval = prefInterval ?: preferences.libraryUpdateInterval().get()
|
||||
if (interval > 0) {
|
||||
|
@ -62,18 +568,57 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
|
|||
TimeUnit.MINUTES,
|
||||
)
|
||||
.addTag(TAG)
|
||||
.addTag(WORK_NAME_AUTO)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
||||
.build()
|
||||
|
||||
// Re-enqueue work because of common support suggestion to change
|
||||
// the settings on the desired time to schedule it at that time
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
TAG,
|
||||
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
|
||||
request,
|
||||
)
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
} else {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_AUTO)
|
||||
}
|
||||
}
|
||||
|
||||
fun startNow(
|
||||
context: Context,
|
||||
category: Category? = null,
|
||||
target: Target = Target.CHAPTERS,
|
||||
): Boolean {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
val infos = wm.getWorkInfosByTag(TAG).get()
|
||||
if (infos.find { it.state == WorkInfo.State.RUNNING } != null) {
|
||||
// Already running either as a scheduled or manual job
|
||||
return false
|
||||
}
|
||||
|
||||
val inputData = workDataOf(
|
||||
KEY_CATEGORY to category?.id,
|
||||
KEY_TARGET to target.name,
|
||||
)
|
||||
val request = OneTimeWorkRequestBuilder<MangaLibraryUpdateJob>()
|
||||
.addTag(TAG)
|
||||
.addTag(WORK_NAME_MANUAL)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val wm = WorkManager.getInstance(context)
|
||||
val workQuery = WorkQuery.Builder.fromTags(listOf(TAG))
|
||||
.addStates(listOf(WorkInfo.State.RUNNING))
|
||||
.build()
|
||||
wm.getWorkInfos(workQuery).get()
|
||||
// Should only return one work but just in case
|
||||
.forEach {
|
||||
wm.cancelWorkById(it.id)
|
||||
|
||||
// Re-enqueue cancelled scheduled work
|
||||
if (it.tags.contains(WORK_NAME_AUTO)) {
|
||||
setupTask(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,621 +0,0 @@
|
|||
package eu.kanade.tachiyomi.data.library.manga
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import eu.kanade.domain.download.service.DownloadPreferences
|
||||
import eu.kanade.domain.entries.manga.interactor.GetLibraryManga
|
||||
import eu.kanade.domain.entries.manga.interactor.GetManga
|
||||
import eu.kanade.domain.entries.manga.interactor.UpdateManga
|
||||
import eu.kanade.domain.entries.manga.model.copyFrom
|
||||
import eu.kanade.domain.entries.manga.model.toSManga
|
||||
import eu.kanade.domain.items.chapter.interactor.GetChapterByMangaId
|
||||
import eu.kanade.domain.items.chapter.interactor.SyncChaptersWithSource
|
||||
import eu.kanade.domain.items.chapter.interactor.SyncChaptersWithTrackServiceTwoWay
|
||||
import eu.kanade.domain.library.service.LibraryPreferences
|
||||
import eu.kanade.domain.track.manga.interactor.GetMangaTracks
|
||||
import eu.kanade.domain.track.manga.interactor.InsertMangaTrack
|
||||
import eu.kanade.domain.track.manga.model.toDbTrack
|
||||
import eu.kanade.domain.track.manga.model.toDomainTrack
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.cache.MangaCoverCache
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadService
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED
|
||||
import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ
|
||||
import eu.kanade.tachiyomi.data.track.EnhancedMangaTrackService
|
||||
import eu.kanade.tachiyomi.data.track.MangaTrackService
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.source.UnmeteredSource
|
||||
import eu.kanade.tachiyomi.source.manga.MangaSourceManager
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.model.UpdateStrategy
|
||||
import eu.kanade.tachiyomi.util.prepUpdateCover
|
||||
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.acquireWakeLock
|
||||
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
|
||||
import eu.kanade.tachiyomi.util.system.getSerializableExtraCompat
|
||||
import eu.kanade.tachiyomi.util.system.isServiceRunning
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import logcat.LogPriority
|
||||
import tachiyomi.core.preference.getAndSet
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import tachiyomi.domain.category.manga.interactor.GetMangaCategories
|
||||
import tachiyomi.domain.category.model.Category
|
||||
import tachiyomi.domain.entries.manga.model.Manga
|
||||
import tachiyomi.domain.entries.manga.model.toMangaUpdate
|
||||
import tachiyomi.domain.items.chapter.model.Chapter
|
||||
import tachiyomi.domain.items.chapter.model.NoChaptersException
|
||||
import tachiyomi.domain.library.manga.LibraryManga
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* This class will take care of updating the chapters of the manga from the library. It can be
|
||||
* started calling the [start] method. If it's already running, it won't do anything.
|
||||
* While the library is updating, a [PowerManager.WakeLock] will be held until the update is
|
||||
* completed, preventing the device from going to sleep mode. A notification will display the
|
||||
* progress of the update, and if case of an unexpected error, this service will be silently
|
||||
* destroyed.
|
||||
*/
|
||||
class MangaLibraryUpdateService(
|
||||
val sourceManager: MangaSourceManager = Injekt.get(),
|
||||
val downloadPreferences: DownloadPreferences = Injekt.get(),
|
||||
val libraryPreferences: LibraryPreferences = Injekt.get(),
|
||||
val downloadManager: MangaDownloadManager = Injekt.get(),
|
||||
val trackManager: TrackManager = Injekt.get(),
|
||||
val coverCache: MangaCoverCache = Injekt.get(),
|
||||
private val getLibraryManga: GetLibraryManga = Injekt.get(),
|
||||
private val getManga: GetManga = Injekt.get(),
|
||||
private val updateManga: UpdateManga = Injekt.get(),
|
||||
private val getChapterByMangaId: GetChapterByMangaId = Injekt.get(),
|
||||
private val getCategories: GetMangaCategories = Injekt.get(),
|
||||
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get(),
|
||||
private val getTracks: GetMangaTracks = Injekt.get(),
|
||||
private val insertTrack: InsertMangaTrack = Injekt.get(),
|
||||
private val syncChaptersWithTrackServiceTwoWay: SyncChaptersWithTrackServiceTwoWay = Injekt.get(),
|
||||
) : Service() {
|
||||
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var notifier: MangaLibraryUpdateNotifier
|
||||
private var scope: CoroutineScope? = null
|
||||
|
||||
private var mangaToUpdate: List<LibraryManga> = mutableListOf()
|
||||
private var updateJob: Job? = null
|
||||
|
||||
/**
|
||||
* Defines what should be updated within a service execution.
|
||||
*/
|
||||
enum class Target {
|
||||
CHAPTERS, // Manga chapters
|
||||
COVERS, // Manga covers
|
||||
TRACKING, // Tracking metadata
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private var instance: MangaLibraryUpdateService? = null
|
||||
|
||||
/**
|
||||
* Key for category to update.
|
||||
*/
|
||||
const val KEY_CATEGORY = "category"
|
||||
|
||||
/**
|
||||
* Key that defines what should be updated.
|
||||
*/
|
||||
const val KEY_TARGET = "target"
|
||||
|
||||
/**
|
||||
* Returns the status of the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @return true if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return context.isServiceRunning(MangaLibraryUpdateService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the service. It will be started only if there isn't another instance already
|
||||
* running.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @param category a specific category to update, or null for global update.
|
||||
* @param target defines what should be updated.
|
||||
* @return true if service newly started, false otherwise
|
||||
*/
|
||||
fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS): Boolean {
|
||||
if (isRunning(context)) return false
|
||||
|
||||
val intent = Intent(context, MangaLibraryUpdateService::class.java).apply {
|
||||
putExtra(KEY_TARGET, target)
|
||||
category?.let { putExtra(KEY_CATEGORY, it.id) }
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, MangaLibraryUpdateService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dagger dependencies and acquire
|
||||
* the wake lock.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
notifier = MangaLibraryUpdateNotifier(this)
|
||||
wakeLock = acquireWakeLock(javaClass.name)
|
||||
|
||||
startForeground(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys subscriptions and releases the wake
|
||||
* lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
updateJob?.cancel()
|
||||
scope?.cancel()
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
if (instance == this) {
|
||||
instance = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method needs to be implemented, but it's not used/needed.
|
||||
*/
|
||||
override fun onBind(intent: Intent): IBinder? = null
|
||||
|
||||
/**
|
||||
* Method called when the service receives an intent.
|
||||
*
|
||||
* @param intent the start intent from.
|
||||
* @param flags the flags of the command.
|
||||
* @param startId the start id of this command.
|
||||
* @return the start value of the command.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) return START_NOT_STICKY
|
||||
val target = intent.getSerializableExtraCompat<Target>(KEY_TARGET)
|
||||
?: return START_NOT_STICKY
|
||||
|
||||
instance = this
|
||||
|
||||
// Unsubscribe from any previous subscription if needed
|
||||
updateJob?.cancel()
|
||||
scope?.cancel()
|
||||
|
||||
// If this is a chapter update; set the last update time to now
|
||||
if (target == Target.CHAPTERS) {
|
||||
libraryPreferences.libraryUpdateLastTimestamp().set(Date().time)
|
||||
}
|
||||
|
||||
// Update favorite manga
|
||||
val categoryId = intent.getLongExtra(KEY_CATEGORY, -1L)
|
||||
addMangaToQueue(categoryId)
|
||||
|
||||
// Destroy service when completed or in case of an error.
|
||||
val handler = CoroutineExceptionHandler { _, exception ->
|
||||
logcat(LogPriority.ERROR, exception)
|
||||
stopSelf(startId)
|
||||
}
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
updateJob = scope?.launch(handler) {
|
||||
when (target) {
|
||||
Target.CHAPTERS -> updateChapterList()
|
||||
Target.COVERS -> updateCovers()
|
||||
Target.TRACKING -> updateTrackings()
|
||||
}
|
||||
}
|
||||
updateJob?.invokeOnCompletion { stopSelf(startId) }
|
||||
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private val isUpdateJobActive: Boolean
|
||||
get() = (updateJob?.isActive == true)
|
||||
|
||||
/**
|
||||
* Adds list of manga to be updated.
|
||||
*
|
||||
* @param categoryId the ID of the category to update, or -1 if no category specified.
|
||||
*/
|
||||
private fun addMangaToQueue(categoryId: Long) {
|
||||
val libraryManga = runBlocking { getLibraryManga.await() }
|
||||
|
||||
val listToUpdate = if (categoryId != -1L) {
|
||||
libraryManga.filter { it.category == categoryId }
|
||||
} else {
|
||||
val categoriesToUpdate = libraryPreferences.mangaLibraryUpdateCategories().get().map { it.toLong() }
|
||||
val includedManga = if (categoriesToUpdate.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToUpdate }
|
||||
} else {
|
||||
libraryManga
|
||||
}
|
||||
|
||||
val categoriesToExclude = libraryPreferences.mangaLibraryUpdateCategoriesExclude().get().map { it.toLong() }
|
||||
val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) {
|
||||
libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
includedManga
|
||||
.filterNot { it.manga.id in excludedMangaIds }
|
||||
.distinctBy { it.manga.id }
|
||||
}
|
||||
|
||||
mangaToUpdate = listToUpdate
|
||||
.sortedBy { it.manga.title }
|
||||
|
||||
// Warn when excessively checking a single source
|
||||
val maxUpdatesFromSource = mangaToUpdate
|
||||
.groupBy { it.manga.source }
|
||||
.filterKeys { sourceManager.get(it) !is UnmeteredSource }
|
||||
.maxOfOrNull { it.value.size } ?: 0
|
||||
if (maxUpdatesFromSource > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) {
|
||||
notifier.showQueueSizeWarningNotification()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates manga in [mangaToUpdate]. It's called in a background thread, so it's safe
|
||||
* to do heavy operations or network calls here.
|
||||
* For each manga it calls [updateManga] and updates the notification showing the current
|
||||
* progress.
|
||||
*
|
||||
* @return an observable delivering the progress of each update.
|
||||
*/
|
||||
private suspend fun updateChapterList() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
||||
val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>()
|
||||
val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
|
||||
val hasDownloads = AtomicBoolean(false)
|
||||
val loggedServices by lazy { trackManager.services.filter { it.isLogged && it is MangaTrackService } }
|
||||
val restrictions = libraryPreferences.libraryUpdateItemRestriction().get()
|
||||
|
||||
withIOContext {
|
||||
mangaToUpdate.groupBy { it.manga.source }.values
|
||||
.map { mangaInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
mangaInSource.forEach { libraryManga ->
|
||||
val manga = libraryManga.manga
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return@async
|
||||
}
|
||||
|
||||
// Don't continue to update if manga is not in library
|
||||
if (getManga.await(manga.id)?.favorite != true) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount,
|
||||
manga,
|
||||
) {
|
||||
when {
|
||||
MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED ->
|
||||
skippedUpdates.add(manga to getString(R.string.skipped_reason_completed))
|
||||
|
||||
MANGA_HAS_UNREAD in restrictions && libraryManga.unreadCount != 0L ->
|
||||
skippedUpdates.add(manga to getString(R.string.skipped_reason_not_caught_up))
|
||||
|
||||
MANGA_NON_READ in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted ->
|
||||
skippedUpdates.add(manga to getString(R.string.skipped_reason_not_started))
|
||||
|
||||
manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE ->
|
||||
skippedUpdates.add(manga to getString(R.string.skipped_reason_not_always_update))
|
||||
|
||||
else -> {
|
||||
try {
|
||||
val newChapters = updateManga(manga)
|
||||
.sortedByDescending { it.sourceOrder }
|
||||
|
||||
if (newChapters.isNotEmpty()) {
|
||||
val categoryIds =
|
||||
getCategories.await(manga.id).map { it.id }
|
||||
if (manga.shouldDownloadNewChapters(
|
||||
categoryIds,
|
||||
downloadPreferences,
|
||||
)
|
||||
) {
|
||||
downloadChapters(manga, newChapters)
|
||||
hasDownloads.set(true)
|
||||
}
|
||||
|
||||
libraryPreferences.newMangaUpdatesCount()
|
||||
.getAndSet { it + newChapters.size }
|
||||
|
||||
// Convert to the manga that contains new chapters
|
||||
newUpdates.add(manga to newChapters.toTypedArray())
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
val errorMessage = when (e) {
|
||||
is NoChaptersException -> getString(R.string.no_chapters_error)
|
||||
// failedUpdates will already have the source, don't need to copy it into the message
|
||||
is MangaSourceManager.SourceNotInstalledException -> getString(
|
||||
R.string.loader_not_implemented_error,
|
||||
)
|
||||
else -> e.message
|
||||
}
|
||||
failedUpdates.add(manga to errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryPreferences.autoUpdateTrackers().get()) {
|
||||
updateTrackings(manga, loggedServices)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
|
||||
if (newUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateNotifications(newUpdates)
|
||||
if (hasDownloads.get()) {
|
||||
MangaDownloadService.start(this)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedUpdates.isNotEmpty()) {
|
||||
val errorFile = writeErrorFile(failedUpdates)
|
||||
notifier.showUpdateErrorNotification(
|
||||
failedUpdates.size,
|
||||
errorFile.getUriCompat(this),
|
||||
)
|
||||
}
|
||||
if (skippedUpdates.isNotEmpty()) {
|
||||
notifier.showUpdateSkippedNotification(skippedUpdates.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||
// We don't want to start downloading while the library is updating, because websites
|
||||
// may don't like it and they could ban the user.
|
||||
downloadManager.downloadChapters(manga, chapters, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the chapters for the given manga and adds them to the database.
|
||||
*
|
||||
* @param manga the manga to update.
|
||||
* @return a pair of the inserted and removed chapters.
|
||||
*/
|
||||
private suspend fun updateManga(manga: Manga): List<Chapter> {
|
||||
val source = sourceManager.getOrStub(manga.source)
|
||||
|
||||
// Update manga metadata if needed
|
||||
if (libraryPreferences.autoUpdateMetadata().get()) {
|
||||
val networkManga = source.getMangaDetails(manga.toSManga())
|
||||
updateManga.awaitUpdateFromSource(manga, networkManga, manualFetch = false, coverCache)
|
||||
}
|
||||
|
||||
val chapters = source.getChapterList(manga.toSManga())
|
||||
|
||||
// Get manga from database to account for if it was removed during the update and
|
||||
// to get latest data so it doesn't get overwritten later on
|
||||
val dbManga = getManga.await(manga.id)?.takeIf { it.favorite } ?: return emptyList()
|
||||
|
||||
return syncChaptersWithSource.await(chapters, dbManga, source)
|
||||
}
|
||||
|
||||
private suspend fun updateCovers() {
|
||||
val semaphore = Semaphore(5)
|
||||
val progressCount = AtomicInteger(0)
|
||||
val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>()
|
||||
|
||||
withIOContext {
|
||||
mangaToUpdate.groupBy { it.manga.source }
|
||||
.values
|
||||
.map { mangaInSource ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
mangaInSource.forEach { libraryManga ->
|
||||
val manga = libraryManga.manga
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return@async
|
||||
}
|
||||
|
||||
withUpdateNotification(
|
||||
currentlyUpdatingManga,
|
||||
progressCount,
|
||||
manga,
|
||||
) {
|
||||
val source = sourceManager.get(manga.source)
|
||||
?: return@withUpdateNotification
|
||||
try {
|
||||
val networkManga = source.getMangaDetails(manga.toSManga())
|
||||
val updatedManga =
|
||||
manga.prepUpdateCover(coverCache, networkManga, true)
|
||||
.copyFrom(networkManga)
|
||||
try {
|
||||
updateManga.await(updatedManga.toMangaUpdate())
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR) { "Manga doesn't exist anymore" }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that updates the metadata of the connected tracking services. It's called in a
|
||||
* background thread, so it's safe to do heavy operations or network calls here.
|
||||
*/
|
||||
private suspend fun updateTrackings() {
|
||||
var progressCount = 0
|
||||
val loggedServices = trackManager.services.filter { it.isLogged && it is MangaTrackService }
|
||||
|
||||
mangaToUpdate.forEach { libraryManga ->
|
||||
val manga = libraryManga.manga
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return
|
||||
}
|
||||
|
||||
notifier.showProgressNotification(listOf(manga), progressCount++, mangaToUpdate.size)
|
||||
|
||||
// Update the tracking details.
|
||||
updateTrackings(manga, loggedServices)
|
||||
}
|
||||
|
||||
notifier.cancelProgressNotification()
|
||||
}
|
||||
|
||||
private suspend fun updateTrackings(manga: Manga, loggedServices: List<TrackService>) {
|
||||
getTracks.await(manga.id)
|
||||
.map { track ->
|
||||
supervisorScope {
|
||||
async {
|
||||
val service = trackManager.getService(track.syncId)
|
||||
if (service != null && service in loggedServices) {
|
||||
try {
|
||||
val updatedTrack = service.mangaService.refresh(track.toDbTrack())
|
||||
insertTrack.await(updatedTrack.toDomainTrack()!!)
|
||||
|
||||
if (service is EnhancedMangaTrackService) {
|
||||
val chapters = getChapterByMangaId.await(manga.id)
|
||||
syncChaptersWithTrackServiceTwoWay.await(
|
||||
chapters,
|
||||
track,
|
||||
service.mangaService,
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// Ignore errors and continue
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
private suspend fun withUpdateNotification(
|
||||
updatingManga: CopyOnWriteArrayList<Manga>,
|
||||
completed: AtomicInteger,
|
||||
manga: Manga,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return
|
||||
}
|
||||
|
||||
updatingManga.add(manga)
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
|
||||
block()
|
||||
|
||||
if (!isUpdateJobActive) {
|
||||
notifier.cancelProgressNotification()
|
||||
return
|
||||
}
|
||||
|
||||
updatingManga.remove(manga)
|
||||
completed.getAndIncrement()
|
||||
notifier.showProgressNotification(
|
||||
updatingManga,
|
||||
completed.get(),
|
||||
mangaToUpdate.size,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes basic file of update errors to cache dir.
|
||||
*/
|
||||
private fun writeErrorFile(errors: List<Pair<Manga, String?>>): File {
|
||||
try {
|
||||
if (errors.isNotEmpty()) {
|
||||
val file = createFileInCacheDir("tachiyomi_update_errors.txt")
|
||||
file.bufferedWriter().use { out ->
|
||||
out.write(getString(R.string.library_errors_help, ERROR_LOG_HELP_URL) + "\n\n")
|
||||
// Error file format:
|
||||
// ! Error
|
||||
// # Source
|
||||
// - Manga
|
||||
errors.groupBy({ it.second }, { it.first }).forEach { (error, mangas) ->
|
||||
out.write("\n! ${error}\n")
|
||||
mangas.groupBy { it.source }.forEach { (srcId, mangas) ->
|
||||
val source = sourceManager.getOrStub(srcId)
|
||||
out.write(" # $source\n")
|
||||
mangas.forEach {
|
||||
out.write(" - ${it.title}\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return File("")
|
||||
}
|
||||
}
|
||||
|
||||
private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60
|
||||
private const val ERROR_LOG_HELP_URL = "https://aniyomi.org/help/guides/troubleshooting"
|
|
@ -22,8 +22,8 @@ import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
|
|||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadService
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadService
|
||||
import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.updater.AppUpdateService
|
||||
import eu.kanade.tachiyomi.source.anime.AnimeSourceManager
|
||||
import eu.kanade.tachiyomi.source.manga.MangaSourceManager
|
||||
|
@ -114,8 +114,8 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1),
|
||||
)
|
||||
// Cancel library update and dismiss notification
|
||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Notifications.ID_LIBRARY_PROGRESS)
|
||||
ACTION_CANCEL_ANIMELIB_UPDATE -> cancelAnimelibUpdate(context, Notifications.ID_LIBRARY_PROGRESS)
|
||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
|
||||
ACTION_CANCEL_ANIMELIB_UPDATE -> cancelAnimelibUpdate(context)
|
||||
// Cancel downloading app update
|
||||
ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context)
|
||||
// Open reader activity
|
||||
|
@ -298,11 +298,9 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||
* Method called when user wants to stop a library update
|
||||
*
|
||||
* @param context context of application
|
||||
* @param notificationId id of notification
|
||||
*/
|
||||
private fun cancelLibraryUpdate(context: Context, notificationId: Int) {
|
||||
MangaLibraryUpdateService.stop(context)
|
||||
ContextCompat.getMainExecutor(context).execute { dismissNotification(context, notificationId) }
|
||||
private fun cancelLibraryUpdate(context: Context) {
|
||||
MangaLibraryUpdateJob.stop(context)
|
||||
}
|
||||
|
||||
private fun cancelDownloadAppUpdate(context: Context) {
|
||||
|
@ -315,9 +313,8 @@ class NotificationReceiver : BroadcastReceiver() {
|
|||
* @param context context of application
|
||||
* @param notificationId id of notification
|
||||
*/
|
||||
private fun cancelAnimelibUpdate(context: Context, notificationId: Int) {
|
||||
AnimeLibraryUpdateService.stop(context)
|
||||
ContextCompat.getMainExecutor(context).execute { dismissNotification(context, notificationId) }
|
||||
private fun cancelAnimelibUpdate(context: Context) {
|
||||
AnimeLibraryUpdateJob.stop(context)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -11,6 +11,10 @@ const val MANGA_NON_COMPLETED = "manga_ongoing"
|
|||
const val MANGA_HAS_UNREAD = "manga_fully_read"
|
||||
const val MANGA_NON_READ = "manga_started"
|
||||
|
||||
const val ANIME_NON_COMPLETED = "anime_ongoing"
|
||||
const val ANIME_HAS_UNSEEN = "anime_fully_seen"
|
||||
const val ANIME_NON_SEEN = "anime_started"
|
||||
|
||||
const val FLAG_CATEGORIES = "1"
|
||||
const val FLAG_CHAPTERS = "2"
|
||||
const val FLAG_HISTORY = "4"
|
||||
|
|
|
@ -8,7 +8,6 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
|
@ -113,7 +112,6 @@ fun AnimeDownloadQueueScreen(
|
|||
}
|
||||
},
|
||||
expanded = fabExpanded,
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -110,8 +110,7 @@ class AnimeDownloadQueueScreenModel(
|
|||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
downloadManager.queue.updates
|
||||
.catch { logcat(LogPriority.ERROR, it) }
|
||||
downloadManager.queue.state
|
||||
.map { downloads ->
|
||||
downloads
|
||||
.groupBy { it.source }
|
||||
|
|
|
@ -8,7 +8,6 @@ import androidx.compose.animation.fadeIn
|
|||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
|
@ -113,7 +112,6 @@ fun DownloadQueueScreen(
|
|||
}
|
||||
},
|
||||
expanded = fabExpanded,
|
||||
modifier = Modifier.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -116,8 +116,7 @@ class MangaDownloadQueueScreenModel(
|
|||
|
||||
init {
|
||||
coroutineScope.launch {
|
||||
downloadManager.queue.updates
|
||||
.catch { logcat(LogPriority.ERROR, it) }
|
||||
downloadManager.queue.state
|
||||
.map { downloads ->
|
||||
downloads
|
||||
.groupBy { it.source }
|
||||
|
|
|
@ -45,7 +45,7 @@ import eu.kanade.presentation.library.LibraryToolbar
|
|||
import eu.kanade.presentation.library.anime.AnimeLibraryContent
|
||||
import eu.kanade.presentation.util.Tab
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.ui.browse.anime.source.globalsearch.GlobalAnimeSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.category.CategoriesTab
|
||||
import eu.kanade.tachiyomi.ui.entries.anime.AnimeScreen
|
||||
|
@ -107,7 +107,7 @@ object AnimeLibraryTab : Tab {
|
|||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val onClickRefresh: (Category?) -> Boolean = {
|
||||
val started = AnimeLibraryUpdateService.start(context, it)
|
||||
val started = AnimeLibraryUpdateJob.startNow(context, it)
|
||||
scope.launch {
|
||||
val msgRes = if (started) R.string.updating_category else R.string.update_already_running
|
||||
snackbarHostState.showSnackbar(context.getString(msgRes))
|
||||
|
|
|
@ -44,7 +44,7 @@ import eu.kanade.presentation.library.LibraryToolbar
|
|||
import eu.kanade.presentation.library.manga.MangaLibraryContent
|
||||
import eu.kanade.presentation.util.Tab
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.ui.browse.manga.source.globalsearch.GlobalMangaSearchScreen
|
||||
import eu.kanade.tachiyomi.ui.category.CategoriesTab
|
||||
import eu.kanade.tachiyomi.ui.entries.manga.MangaScreen
|
||||
|
@ -104,7 +104,7 @@ object MangaLibraryTab : Tab {
|
|||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val onClickRefresh: (Category?) -> Boolean = {
|
||||
val started = MangaLibraryUpdateService.start(context, it)
|
||||
val started = MangaLibraryUpdateJob.startNow(context, it)
|
||||
scope.launch {
|
||||
val msgRes = if (started) R.string.updating_category else R.string.update_already_running
|
||||
snackbarHostState.showSnackbar(context.getString(msgRes))
|
||||
|
|
|
@ -112,12 +112,12 @@ private class MoreScreenModel(
|
|||
coroutineScope.launchIO {
|
||||
combine(
|
||||
MangaDownloadService.isRunning,
|
||||
downloadManager.queue.updates,
|
||||
downloadManager.queue.state,
|
||||
) { isRunningManga, mangaDownloadQueue -> Pair(isRunningManga, mangaDownloadQueue.size) }
|
||||
.collectLatest { (isDownloadingManga, mangaDownloadQueueSize) ->
|
||||
combine(
|
||||
AnimeDownloadService.isRunning,
|
||||
animeDownloadManager.queue.updates,
|
||||
animeDownloadManager.queue.state,
|
||||
) { isRunningAnime, animeDownloadQueue -> Pair(isRunningAnime, animeDownloadQueue.size) }
|
||||
.collectLatest { (isDownloadingAnime, animeDownloadQueueSize) ->
|
||||
val isDownloading = isDownloadingAnime || isDownloadingManga
|
||||
|
@ -125,8 +125,7 @@ private class MoreScreenModel(
|
|||
val pendingDownloadExists = downloadQueueSize != 0
|
||||
_state.value = when {
|
||||
!pendingDownloadExists -> DownloadQueueState.Stopped
|
||||
!isDownloading && !pendingDownloadExists -> DownloadQueueState.Paused(0)
|
||||
!isDownloading && pendingDownloadExists -> DownloadQueueState.Paused(downloadQueueSize)
|
||||
!isDownloading -> DownloadQueueState.Paused(downloadQueueSize)
|
||||
else -> DownloadQueueState.Downloading(downloadQueueSize)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -177,10 +177,11 @@ class ReaderViewModel(
|
|||
}.run {
|
||||
if (readerPreferences.skipDupe().get()) {
|
||||
groupBy { it.chapterNumber }
|
||||
.mapValues { (_, chapters) ->
|
||||
chapters.find { it.id == chapterId || it.scanlator == selectedChapter.scanlator } ?: chapters.first()
|
||||
.map { (_, chapters) ->
|
||||
chapters.find { it.id == selectedChapter.id }
|
||||
?: chapters.find { it.scanlator == selectedChapter.scanlator }
|
||||
?: chapters.first()
|
||||
}
|
||||
.values
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadCache
|
|||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.anime.AnimeDownloadService
|
||||
import eu.kanade.tachiyomi.data.download.anime.model.AnimeDownload
|
||||
import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.anime.AnimeLibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.source.anime.AnimeSourceManager
|
||||
import eu.kanade.tachiyomi.util.lang.toDateKey
|
||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
||||
|
@ -130,7 +130,7 @@ class AnimeUpdatesScreenModel(
|
|||
}
|
||||
|
||||
fun updateLibrary(): Boolean {
|
||||
val started = AnimeLibraryUpdateService.start(Injekt.get<Application>())
|
||||
val started = AnimeLibraryUpdateJob.startNow(Injekt.get<Application>())
|
||||
coroutineScope.launch {
|
||||
_events.send(Event.LibraryUpdateTriggered(started))
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import eu.kanade.tachiyomi.data.download.manga.MangaDownloadCache
|
|||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.manga.MangaDownloadService
|
||||
import eu.kanade.tachiyomi.data.download.manga.model.MangaDownload
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.manga.MangaLibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.source.manga.MangaSourceManager
|
||||
import eu.kanade.tachiyomi.util.lang.toDateKey
|
||||
import eu.kanade.tachiyomi.util.lang.toRelativeString
|
||||
|
@ -130,7 +130,7 @@ class MangaUpdatesScreenModel(
|
|||
}
|
||||
|
||||
fun updateLibrary(): Boolean {
|
||||
val started = MangaLibraryUpdateService.start(Injekt.get<Application>())
|
||||
val started = MangaLibraryUpdateJob.startNow(Injekt.get<Application>())
|
||||
coroutineScope.launch {
|
||||
_events.send(Event.LibraryUpdateTriggered(started))
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.network
|
|||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.serialization.DeserializationStrategy
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
@ -134,13 +135,11 @@ fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: Progre
|
|||
}
|
||||
|
||||
inline fun <reified T> Response.parseAs(): T {
|
||||
return internalParseAs(typeOf<T>(), this)
|
||||
return decodeFromJsonResponse(serializer(), this)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun <T> internalParseAs(type: KType, response: Response): T {
|
||||
val deserializer = serializer(type) as KSerializer<T>
|
||||
fun <T> decodeFromJsonResponse(deserializer: DeserializationStrategy<T>, response: Response): T {
|
||||
return response.body.source().use {
|
||||
Injekt.get<Json>().decodeFromBufferedSource(deserializer, it)
|
||||
}
|
||||
|
|
|
@ -199,7 +199,7 @@
|
|||
<string name="pref_manage_notifications">Manage notifications</string>
|
||||
<string name="pref_app_language">App language</string>
|
||||
|
||||
<string name="pref_category_security">Security</string>
|
||||
<string name="pref_category_security">Security and privacy</string>
|
||||
<string name="lock_with_biometrics">Require unlock</string>
|
||||
<string name="lock_when_idle">Lock when idle</string>
|
||||
<string name="lock_always">Always</string>
|
||||
|
|
|
@ -33,7 +33,6 @@ open class Video(
|
|||
var status: State = State.QUEUE
|
||||
set(value) {
|
||||
field = value
|
||||
statusSubject?.onNext(value)
|
||||
}
|
||||
|
||||
@Transient
|
||||
|
@ -67,9 +66,6 @@ open class Video(
|
|||
field = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
var statusSubject: Subject<State, State>? = null
|
||||
|
||||
@Transient
|
||||
var progressSubject: Subject<State, State>? = null
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import rx.subjects.Subject
|
||||
|
||||
@Serializable
|
||||
open class Page(
|
||||
|
@ -28,7 +27,6 @@ open class Page(
|
|||
get() = _statusFlow.value
|
||||
set(value) {
|
||||
_statusFlow.value = value
|
||||
statusSubject?.onNext(value)
|
||||
}
|
||||
|
||||
@Transient
|
||||
|
@ -42,9 +40,6 @@ open class Page(
|
|||
_progressFlow.value = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
var statusSubject: Subject<State, State>? = null
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
progress = if (contentLength > 0) {
|
||||
(100 * bytesRead / contentLength).toInt()
|
||||
|
|
Loading…
Reference in a new issue