Includes everything from Tachiyomi v0.14.4 to this commit of Tachiyomi: bff98ca768
This commit is contained in:
LuftVerbot 2023-05-30 13:07:48 +02:00
parent 58260b43ca
commit 918e5bfb9c
38 changed files with 1382 additions and 1514 deletions

View file

@ -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()}\"")

View file

@ -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" />

View file

@ -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
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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),

View file

@ -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),

View file

@ -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),

View file

@ -406,6 +406,12 @@ object Migrations {
}
}
}
if (oldVersion < 96) {
MangaLibraryUpdateJob.cancelAllWorks(context)
AnimeLibraryUpdateJob.cancelAllWorks(context)
MangaLibraryUpdateJob.setupTask(context)
AnimeLibraryUpdateJob.setupTask(context)
}
return true
}
}

View file

@ -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()) {

View file

@ -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
}

View file

@ -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

View file

@ -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>) {
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)
_state.update {
downloads.forEach { download ->
download.progressSubject = progressSubject
download.progressCallback = ::setProgressFor
download.status = AnimeDownload.State.QUEUE
}
store.addAll(downloads)
it + downloads
}
}
fun remove(download: AnimeDownload) {
val removed = queue.remove(download)
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)
_state.update {
store.remove(download)
download.progressSubject = null
download.progressCallback = null
if (download.status == AnimeDownload.State.DOWNLOADING || download.status == AnimeDownload.State.QUEUE) {
download.status = AnimeDownload.State.NOT_DOWNLOADED
}
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
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
_state.update {
it.forEach { download ->
download.progressSubject = 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)
store.clear()
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()
}
private fun setVideoFor(download: AnimeDownload) {
if (download.status == AnimeDownload.State.DOWNLOADED || download.status == AnimeDownload.State.ERROR) {
setVideoSubject(download.video, null)
fun statusFlow(): Flow<AnimeDownload> = state
.flatMapLatest { downloads ->
downloads
.map { download ->
download.statusFlow.drop(1).map { download }
}
.merge()
}
}
.onStart { emitAll(getActiveDownloads()) }
private fun setVideoSubject(video: Video?, subject: PublishSubject<Video.State>?) {
video?.statusSubject = subject
}
fun progressFlow(): Flow<AnimeDownload> = state
.flatMapLatest { downloads ->
downloads
.map { download ->
download.progressFlow.drop(1).map { download }
}
.merge()
}
.onStart { emitAll(getActiveDownloads()) }
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) {

View file

@ -148,7 +148,7 @@ class MangaDownloader(
return
}
if (notifier.paused && !queue.isEmpty()) {
if (notifier.paused && queue.isNotEmpty()) {
notifier.onPaused()
} else {
notifier.onComplete()

View file

@ -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() {

View file

@ -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>) {
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)
_state.update {
downloads.forEach { download ->
download.status = MangaDownload.State.QUEUE
}
store.addAll(downloads)
it + downloads
}
}
fun remove(download: MangaDownload) {
val removed = queue.remove(download)
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)
_state.update {
store.remove(download)
if (download.status == MangaDownload.State.DOWNLOADING || download.status == MangaDownload.State.QUEUE) {
download.status = MangaDownload.State.NOT_DOWNLOADED
}
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
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)
}
}
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)
_state.update {
it.forEach { download ->
if (download.status == MangaDownload.State.DOWNLOADING || download.status == MangaDownload.State.QUEUE) {
download.status = MangaDownload.State.NOT_DOWNLOADED
}
Observable.just(download)
}
.filter { it.status == MangaDownload.State.DOWNLOADING }
}
private fun setPagesFor(download: MangaDownload) {
if (download.status == MangaDownload.State.DOWNLOADED || download.status == MangaDownload.State.ERROR) {
setPagesSubject(download.pages, null)
store.clear()
emptyList()
}
}
private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Page.State>?) {
pages?.forEach { it.statusSubject = subject }
}
fun statusFlow(): Flow<MangaDownload> = state
.flatMapLatest { downloads ->
downloads
.map { download ->
download.statusFlow.drop(1).map { download }
}
.merge()
}
.onStart { emitAll(getActiveDownloads()) }
fun progressFlow(): Flow<MangaDownload> = state
.flatMapLatest { downloads ->
downloads
.map { download ->
download.progressFlow.drop(1).map { download }
}
.merge()
}
.onStart { emitAll(getActiveDownloads()) }
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()
}

View file

@ -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)) {
Result.success()
} else {
Result.failure()
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,15 +563,57 @@ 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)
}
}
}
}
}

View file

@ -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"

View file

@ -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)) {
Result.success()
} else {
Result.failure()
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,19 +568,58 @@ 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)
}
}
}
}
}

View file

@ -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"

View file

@ -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)
}
/**

View file

@ -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"

View file

@ -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(),
)
}
},

View file

@ -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 }

View file

@ -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(),
)
}
},

View file

@ -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 }

View file

@ -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))

View file

@ -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))

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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>

View file

@ -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

View file

@ -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()