Last commit merged: 86edce0d87
This commit is contained in:
LuftVerbot 2023-11-23 22:44:56 +01:00
parent b8cdb9d55e
commit 325e625aef
92 changed files with 1022 additions and 729 deletions

View file

@ -5,7 +5,7 @@ I acknowledge that:
- I have updated: - I have updated:
- To the latest version of the app (stable is v0.12.3.10) - To the latest version of the app (stable is v0.12.3.10)
- All extensions - All extensions
- I have gone through the FAQ (https://aniyomi.org/help/faq/) and troubleshooting guide (https://aniyomi.org/help/guides/troubleshooting/) - I have gone through the FAQ (https://aniyomi.org/docs/faq/general) and troubleshooting guide (https://aniyomi.org/docs/guides/troubleshooting/)
- If this is an issue with an anime extension, that I should be opening an issue in https://github.com/aniyomiorg/aniyomi-extensions - If this is an issue with an anime extension, that I should be opening an issue in https://github.com/aniyomiorg/aniyomi-extensions
- I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue - I have searched the existing issues and this is new ticket **NOT** a duplicate or related to another open or closed issue
- I will fill out the title and the information in this template - I will fill out the title and the information in this template

View file

@ -4,7 +4,7 @@ contact_links:
url: https://github.com/aniyomiorg/aniyomi-extensions/issues/new/choose url: https://github.com/aniyomiorg/aniyomi-extensions/issues/new/choose
about: Issues and requests for extensions and sources should be opened in the aniyomi-extensions repository instead about: Issues and requests for extensions and sources should be opened in the aniyomi-extensions repository instead
- name: 📦 Aniyomi extensions - name: 📦 Aniyomi extensions
url: https://aniyomi.org/extensions url: https://aniyomi.org/extensions/
about: Anime extensions and sources about: Anime extensions and sources
- name: 🧑‍💻 Aniyomi help discord - name: 🧑‍💻 Aniyomi help discord
url: https://discord.gg/F32UjdJZrR url: https://discord.gg/F32UjdJZrR

View file

@ -95,7 +95,7 @@ body:
required: true required: true
- label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/aniyomiorg/aniyomi-extensions/issues/new/choose). - label: If this is an issue with an extension, I should be opening an issue in the [extensions repository](https://github.com/aniyomiorg/aniyomi-extensions/issues/new/choose).
required: true required: true
- label: I have gone through the [FAQ](https://aniyomi.org/help/faq/) and [troubleshooting guide](https://aniyomi.org/help/guides/troubleshooting/). - label: I have gone through the [FAQ](https://aniyomi.org/docs/faq/general) and [troubleshooting guide](https://aniyomi.org/docs/guides/troubleshooting/).
required: true required: true
- label: I have updated the app to version **[0.12.3.10](https://github.com/aniyomiorg/aniyomi/releases/latest)**. - label: I have updated the app to version **[0.12.3.10](https://github.com/aniyomiorg/aniyomi/releases/latest)**.
required: true required: true

View file

@ -120,3 +120,14 @@ jobs:
prerelease: false prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-website:
needs: [ build ]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'aniyomiorg/aniyomi'
steps:
- name: Trigger Netlify build hook
run: curl -s -X POST -d {} "https://api.netlify.com/build_hooks/${TOKEN}"
env:
TOKEN: ${{ secrets.NETLIFY_HOOK_RELEASE }}

View file

@ -33,7 +33,7 @@ jobs:
"regex": ".*(?:fail(?:ed|ure|s)?|can\\s*(?:no|')?t|(?:not|un).*able|(?<!n[o']?t )blocked by|error) (?:to )?(?:get past|by ?pass|penetrate)?.*cloud ?fl?are.*", "regex": ".*(?:fail(?:ed|ure|s)?|can\\s*(?:no|')?t|(?:not|un).*able|(?<!n[o']?t )blocked by|error) (?:to )?(?:get past|by ?pass|penetrate)?.*cloud ?fl?are.*",
"ignoreCase": true, "ignoreCase": true,
"labels": ["Cloudflare protected"], "labels": ["Cloudflare protected"],
"message": "Refer to the **Solving Cloudflare issues** section at https://aniyomi.org/help/guides/troubleshooting/#solving-cloudflare-issues. If it doesn't work, migrate to other sources or wait until they lower their protection." "message": "Refer to the **Solving Cloudflare issues** section at https://aniyomi.org/docs/guides/troubleshooting/#cloudflare. If it doesn't work, migrate to other sources or wait until they lower their protection."
} }
] ]
auto-close-ignore-label: do-not-autoclose auto-close-ignore-label: do-not-autoclose

View file

@ -30,7 +30,7 @@ Before you start, please note that the ability to use following technologies is
# Translations # Translations
Translations are done externally via Weblate. See [our website](https://aniyomi.org/help/contribution/#translation) for more details. Translations are done externally via Weblate. See [our website](https://aniyomi.org/docs/contribute#translation) for more details.
# Forks # Forks

View file

@ -31,7 +31,7 @@ Please make sure to read the full guidelines. Your issue may be closed without w
<details><summary>Issues</summary> <details><summary>Issues</summary>
1. **Before reporting a new issue, take a look at the already opened [issues](https://github.com/aniyomiorg/aniyomi/issues).** 1. **Before reporting a new issue, take a look at the already opened [issues](https://aniyomi.org/changelogs/).**
2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/841701076242530374?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/F32UjdJZrR) 2. If you are unsure, ask here: [![Discord](https://img.shields.io/discord/841701076242530374?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/F32UjdJZrR)
</details> </details>

View file

@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response import okhttp3.Response
import rx.Observable
import tachiyomi.core.preference.Preference import tachiyomi.core.preference.Preference
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -27,15 +26,6 @@ interface DataSaver {
} }
} }
fun HttpSource.fetchImage(page: Page, dataSaver: DataSaver): Observable<Response> {
val imageUrl = page.imageUrl ?: return fetchImage(page)
page.imageUrl = dataSaver.compress(imageUrl)
return fetchImage(page)
.doOnNext {
page.imageUrl = imageUrl
}
}
suspend fun HttpSource.getImage(page: Page, dataSaver: DataSaver): Response { suspend fun HttpSource.getImage(page: Page, dataSaver: DataSaver): Response {
val imageUrl = page.imageUrl ?: return getImage(page) val imageUrl = page.imageUrl ?: return getImage(page)
page.imageUrl = dataSaver.compress(imageUrl) page.imageUrl = dataSaver.compress(imageUrl)

View file

@ -77,6 +77,7 @@ import tachiyomi.domain.category.manga.interactor.SetMangaDisplayMode
import tachiyomi.domain.category.manga.interactor.SetSortModeForMangaCategory import tachiyomi.domain.category.manga.interactor.SetSortModeForMangaCategory
import tachiyomi.domain.category.manga.interactor.UpdateMangaCategory import tachiyomi.domain.category.manga.interactor.UpdateMangaCategory
import tachiyomi.domain.category.manga.repository.MangaCategoryRepository import tachiyomi.domain.category.manga.repository.MangaCategoryRepository
import tachiyomi.domain.entries.anime.interactor.AnimeFetchInterval
import tachiyomi.domain.entries.anime.interactor.GetAnime import tachiyomi.domain.entries.anime.interactor.GetAnime
import tachiyomi.domain.entries.anime.interactor.GetAnimeFavorites import tachiyomi.domain.entries.anime.interactor.GetAnimeFavorites
import tachiyomi.domain.entries.anime.interactor.GetAnimeWithEpisodes import tachiyomi.domain.entries.anime.interactor.GetAnimeWithEpisodes
@ -85,17 +86,16 @@ import tachiyomi.domain.entries.anime.interactor.GetLibraryAnime
import tachiyomi.domain.entries.anime.interactor.NetworkToLocalAnime import tachiyomi.domain.entries.anime.interactor.NetworkToLocalAnime
import tachiyomi.domain.entries.anime.interactor.ResetAnimeViewerFlags import tachiyomi.domain.entries.anime.interactor.ResetAnimeViewerFlags
import tachiyomi.domain.entries.anime.interactor.SetAnimeEpisodeFlags import tachiyomi.domain.entries.anime.interactor.SetAnimeEpisodeFlags
import tachiyomi.domain.entries.anime.interactor.SetAnimeFetchInterval
import tachiyomi.domain.entries.anime.repository.AnimeRepository import tachiyomi.domain.entries.anime.repository.AnimeRepository
import tachiyomi.domain.entries.manga.interactor.GetDuplicateLibraryManga import tachiyomi.domain.entries.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.entries.manga.interactor.GetLibraryManga import tachiyomi.domain.entries.manga.interactor.GetLibraryManga
import tachiyomi.domain.entries.manga.interactor.GetManga import tachiyomi.domain.entries.manga.interactor.GetManga
import tachiyomi.domain.entries.manga.interactor.GetMangaFavorites import tachiyomi.domain.entries.manga.interactor.GetMangaFavorites
import tachiyomi.domain.entries.manga.interactor.GetMangaWithChapters import tachiyomi.domain.entries.manga.interactor.GetMangaWithChapters
import tachiyomi.domain.entries.manga.interactor.MangaFetchInterval
import tachiyomi.domain.entries.manga.interactor.NetworkToLocalManga import tachiyomi.domain.entries.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.entries.manga.interactor.ResetMangaViewerFlags import tachiyomi.domain.entries.manga.interactor.ResetMangaViewerFlags
import tachiyomi.domain.entries.manga.interactor.SetMangaChapterFlags import tachiyomi.domain.entries.manga.interactor.SetMangaChapterFlags
import tachiyomi.domain.entries.manga.interactor.SetMangaFetchInterval
import tachiyomi.domain.entries.manga.repository.MangaRepository import tachiyomi.domain.entries.manga.repository.MangaRepository
import tachiyomi.domain.history.anime.interactor.GetAnimeHistory import tachiyomi.domain.history.anime.interactor.GetAnimeHistory
import tachiyomi.domain.history.anime.interactor.GetNextEpisodes import tachiyomi.domain.history.anime.interactor.GetNextEpisodes
@ -188,7 +188,7 @@ class DomainModule : InjektModule {
addFactory { GetNextEpisodes(get(), get(), get()) } addFactory { GetNextEpisodes(get(), get(), get()) }
addFactory { ResetAnimeViewerFlags(get()) } addFactory { ResetAnimeViewerFlags(get()) }
addFactory { SetAnimeEpisodeFlags(get()) } addFactory { SetAnimeEpisodeFlags(get()) }
addFactory { SetAnimeFetchInterval(get()) } addFactory { AnimeFetchInterval(get()) }
addFactory { SetAnimeDefaultEpisodeFlags(get(), get(), get()) } addFactory { SetAnimeDefaultEpisodeFlags(get(), get(), get()) }
addFactory { SetAnimeViewerFlags(get()) } addFactory { SetAnimeViewerFlags(get()) }
addFactory { NetworkToLocalAnime(get()) } addFactory { NetworkToLocalAnime(get()) }
@ -204,7 +204,7 @@ class DomainModule : InjektModule {
addFactory { GetNextChapters(get(), get(), get()) } addFactory { GetNextChapters(get(), get(), get()) }
addFactory { ResetMangaViewerFlags(get()) } addFactory { ResetMangaViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) } addFactory { SetMangaChapterFlags(get()) }
addFactory { SetMangaFetchInterval(get()) } addFactory { MangaFetchInterval(get()) }
addFactory { addFactory {
SetMangaDefaultChapterFlags( SetMangaDefaultChapterFlags(
get(), get(),

View file

@ -3,7 +3,7 @@ package eu.kanade.domain.entries.anime.interactor
import eu.kanade.domain.entries.anime.model.hasCustomCover import eu.kanade.domain.entries.anime.model.hasCustomCover
import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.data.cache.AnimeCoverCache import eu.kanade.tachiyomi.data.cache.AnimeCoverCache
import tachiyomi.domain.entries.anime.interactor.SetAnimeFetchInterval import tachiyomi.domain.entries.anime.interactor.AnimeFetchInterval
import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.anime.model.AnimeUpdate import tachiyomi.domain.entries.anime.model.AnimeUpdate
import tachiyomi.domain.entries.anime.repository.AnimeRepository import tachiyomi.domain.entries.anime.repository.AnimeRepository
@ -15,7 +15,7 @@ import java.util.Date
class UpdateAnime( class UpdateAnime(
private val animeRepository: AnimeRepository, private val animeRepository: AnimeRepository,
private val setAnimeFetchInterval: SetAnimeFetchInterval, private val animeFetchInterval: AnimeFetchInterval,
) { ) {
suspend fun await(animeUpdate: AnimeUpdate): Boolean { suspend fun await(animeUpdate: AnimeUpdate): Boolean {
@ -79,9 +79,9 @@ class UpdateAnime(
suspend fun awaitUpdateFetchInterval( suspend fun awaitUpdateFetchInterval(
anime: Anime, anime: Anime,
dateTime: ZonedDateTime = ZonedDateTime.now(), dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setAnimeFetchInterval.getWindow(dateTime), window: Pair<Long, Long> = animeFetchInterval.getWindow(dateTime),
): Boolean { ): Boolean {
return setAnimeFetchInterval.toAnimeUpdateOrNull(anime, dateTime, window) return animeFetchInterval.toAnimeUpdateOrNull(anime, dateTime, window)
?.let { animeRepository.updateAnime(it) } ?.let { animeRepository.updateAnime(it) }
?: false ?: false
} }

View file

@ -3,7 +3,7 @@ package eu.kanade.domain.entries.manga.interactor
import eu.kanade.domain.entries.manga.model.hasCustomCover import eu.kanade.domain.entries.manga.model.hasCustomCover
import eu.kanade.tachiyomi.data.cache.MangaCoverCache import eu.kanade.tachiyomi.data.cache.MangaCoverCache
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import tachiyomi.domain.entries.manga.interactor.SetMangaFetchInterval import tachiyomi.domain.entries.manga.interactor.MangaFetchInterval
import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.entries.manga.model.MangaUpdate import tachiyomi.domain.entries.manga.model.MangaUpdate
import tachiyomi.domain.entries.manga.repository.MangaRepository import tachiyomi.domain.entries.manga.repository.MangaRepository
@ -15,7 +15,7 @@ import java.util.Date
class UpdateManga( class UpdateManga(
private val mangaRepository: MangaRepository, private val mangaRepository: MangaRepository,
private val setMangaFetchInterval: SetMangaFetchInterval, private val mangaFetchInterval: MangaFetchInterval,
) { ) {
suspend fun await(mangaUpdate: MangaUpdate): Boolean { suspend fun await(mangaUpdate: MangaUpdate): Boolean {
@ -79,9 +79,9 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval( suspend fun awaitUpdateFetchInterval(
manga: Manga, manga: Manga,
dateTime: ZonedDateTime = ZonedDateTime.now(), dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setMangaFetchInterval.getWindow(dateTime), window: Pair<Long, Long> = mangaFetchInterval.getWindow(dateTime),
): Boolean { ): Boolean {
return setMangaFetchInterval.toMangaUpdateOrNull(manga, dateTime, window) return mangaFetchInterval.toMangaUpdateOrNull(manga, dateTime, window)
?.let { mangaRepository.updateManga(it) } ?.let { mangaRepository.updateManga(it) }
?: false ?: false
} }

View file

@ -28,7 +28,7 @@ class UiPreferences(
fun themeDarkAmoled() = preferenceStore.getBoolean("pref_theme_dark_amoled_key", false) fun themeDarkAmoled() = preferenceStore.getBoolean("pref_theme_dark_amoled_key", false)
fun relativeTime() = preferenceStore.getInt("relative_time", 7) fun relativeTime() = preferenceStore.getBoolean("relative_time_v2", true)
fun dateFormat() = preferenceStore.getString("app_date_format", "") fun dateFormat() = preferenceStore.getString("app_date_format", "")

View file

@ -13,13 +13,18 @@ import java.util.Date
fun RelativeDateHeader( fun RelativeDateHeader(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
date: Date, date: Date,
relativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
) { ) {
val context = LocalContext.current val context = LocalContext.current
ListGroupHeader( ListGroupHeader(
modifier = modifier, modifier = modifier,
text = remember { text = remember {
date.toRelativeString(context, dateFormat) date.toRelativeString(
context,
relativeTime,
dateFormat,
)
}, },
) )
} }

View file

@ -159,7 +159,7 @@ fun EntryBottomActionMenu(
val previousUnviewed = if (isManga) R.string.action_mark_previous_as_read else R.string.action_mark_previous_as_seen val previousUnviewed = if (isManga) R.string.action_mark_previous_as_read else R.string.action_mark_previous_as_seen
Button( Button(
title = stringResource(previousUnviewed), title = stringResource(previousUnviewed),
icon = ImageVector.vectorResource(id = R.drawable.ic_done_prev_24dp), icon = ImageVector.vectorResource(R.drawable.ic_done_prev_24dp),
toConfirm = confirm[4], toConfirm = confirm[4],
onLongClick = { onLongClickItem(4) }, onLongClick = { onLongClickItem(4) },
onClick = onMarkPreviousAsViewedClicked, onClick = onMarkPreviousAsViewedClicked,

View file

@ -98,6 +98,7 @@ fun AnimeScreen(
state: AnimeScreenModel.State.Success, state: AnimeScreenModel.State.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
fetchInterval: Int?, fetchInterval: Int?,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
isTabletUi: Boolean, isTabletUi: Boolean,
episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction,
@ -161,6 +162,7 @@ fun AnimeScreen(
AnimeScreenSmallImpl( AnimeScreenSmallImpl(
state = state, state = state,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
fetchInterval = fetchInterval, fetchInterval = fetchInterval,
episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeStartAction = episodeSwipeStartAction,
@ -201,6 +203,7 @@ fun AnimeScreen(
AnimeScreenLargeImpl( AnimeScreenLargeImpl(
state = state, state = state,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeStartAction = episodeSwipeStartAction,
episodeSwipeEndAction = episodeSwipeEndAction, episodeSwipeEndAction = episodeSwipeEndAction,
showNextEpisodeAirTime = showNextEpisodeAirTime, showNextEpisodeAirTime = showNextEpisodeAirTime,
@ -245,6 +248,7 @@ fun AnimeScreen(
private fun AnimeScreenSmallImpl( private fun AnimeScreenSmallImpl(
state: AnimeScreenModel.State.Success, state: AnimeScreenModel.State.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
fetchInterval: Int?, fetchInterval: Int?,
episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction,
@ -316,11 +320,9 @@ private fun AnimeScreenSmallImpl(
} }
val animatedTitleAlpha by animateFloatAsState( val animatedTitleAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0) 1f else 0f, if (firstVisibleItemIndex > 0) 1f else 0f,
label = "titleAlpha",
) )
val animatedBgAlpha by animateFloatAsState( val animatedBgAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f,
label = "bgAlpha",
) )
EntryToolbar( EntryToolbar(
title = state.anime.title, title = state.anime.title,
@ -497,6 +499,7 @@ private fun AnimeScreenSmallImpl(
sharedEpisodeItems( sharedEpisodeItems(
anime = state.anime, anime = state.anime,
episodes = episodes, episodes = episodes,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeStartAction = episodeSwipeStartAction,
episodeSwipeEndAction = episodeSwipeEndAction, episodeSwipeEndAction = episodeSwipeEndAction,
@ -516,6 +519,7 @@ private fun AnimeScreenSmallImpl(
fun AnimeScreenLargeImpl( fun AnimeScreenLargeImpl(
state: AnimeScreenModel.State.Success, state: AnimeScreenModel.State.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
fetchInterval: Int?, fetchInterval: Int?,
episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction,
@ -762,6 +766,7 @@ fun AnimeScreenLargeImpl(
sharedEpisodeItems( sharedEpisodeItems(
anime = state.anime, anime = state.anime,
episodes = episodes, episodes = episodes,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
episodeSwipeStartAction = episodeSwipeStartAction, episodeSwipeStartAction = episodeSwipeStartAction,
episodeSwipeEndAction = episodeSwipeEndAction, episodeSwipeEndAction = episodeSwipeEndAction,
@ -832,6 +837,7 @@ private fun SharedAnimeBottomActionMenu(
private fun LazyListScope.sharedEpisodeItems( private fun LazyListScope.sharedEpisodeItems(
anime: Anime, anime: Anime,
episodes: List<EpisodeItem>, episodes: List<EpisodeItem>,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction,
episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction,
@ -860,7 +866,11 @@ private fun LazyListScope.sharedEpisodeItems(
date = episodeItem.episode.dateUpload date = episodeItem.episode.dateUpload
.takeIf { it > 0L } .takeIf { it > 0L }
?.let { ?.let {
Date(it).toRelativeString(context, dateFormat) Date(it).toRelativeString(
context,
dateRelativeTime,
dateFormat,
)
}, },
watchProgress = episodeItem.episode.lastSecondSeen watchProgress = episodeItem.episode.lastSecondSeen
.takeIf { !episodeItem.episode.seen && it > 0L } .takeIf { !episodeItem.episode.seen && it > 0L }

View file

@ -91,6 +91,7 @@ fun MangaScreen(
state: MangaScreenModel.State.Success, state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
fetchInterval: Int?, fetchInterval: Int?,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
isTabletUi: Boolean, isTabletUi: Boolean,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -151,6 +152,7 @@ fun MangaScreen(
MangaScreenSmallImpl( MangaScreenSmallImpl(
state = state, state = state,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
fetchInterval = fetchInterval, fetchInterval = fetchInterval,
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
@ -188,6 +190,7 @@ fun MangaScreen(
MangaScreenLargeImpl( MangaScreenLargeImpl(
state = state, state = state,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction, chapterSwipeEndAction = chapterSwipeEndAction,
dateFormat = dateFormat, dateFormat = dateFormat,
@ -228,6 +231,7 @@ fun MangaScreen(
private fun MangaScreenSmallImpl( private fun MangaScreenSmallImpl(
state: MangaScreenModel.State.Success, state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
fetchInterval: Int?, fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -297,11 +301,9 @@ private fun MangaScreenSmallImpl(
} }
val animatedTitleAlpha by animateFloatAsState( val animatedTitleAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0) 1f else 0f, if (firstVisibleItemIndex > 0) 1f else 0f,
label = "titleAlpha",
) )
val animatedBgAlpha by animateFloatAsState( val animatedBgAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f,
label = "bgAlpha",
) )
EntryToolbar( EntryToolbar(
title = state.manga.title, title = state.manga.title,
@ -446,6 +448,7 @@ private fun MangaScreenSmallImpl(
sharedChapterItems( sharedChapterItems(
manga = state.manga, manga = state.manga,
chapters = chapters, chapters = chapters,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction, chapterSwipeEndAction = chapterSwipeEndAction,
@ -464,6 +467,7 @@ private fun MangaScreenSmallImpl(
fun MangaScreenLargeImpl( fun MangaScreenLargeImpl(
state: MangaScreenModel.State.Success, state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
fetchInterval: Int?, fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -679,6 +683,7 @@ fun MangaScreenLargeImpl(
sharedChapterItems( sharedChapterItems(
manga = state.manga, manga = state.manga,
chapters = chapters, chapters = chapters,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction, chapterSwipeEndAction = chapterSwipeEndAction,
@ -741,6 +746,7 @@ private fun SharedMangaBottomActionMenu(
private fun LazyListScope.sharedChapterItems( private fun LazyListScope.sharedChapterItems(
manga: Manga, manga: Manga,
chapters: List<ChapterItem>, chapters: List<ChapterItem>,
dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
@ -769,7 +775,11 @@ private fun LazyListScope.sharedChapterItems(
date = chapterItem.chapter.dateUpload date = chapterItem.chapter.dateUpload
.takeIf { it > 0L } .takeIf { it > 0L }
?.let { ?.let {
Date(it).toRelativeString(context, dateFormat) Date(it).toRelativeString(
context,
dateRelativeTime,
dateFormat,
)
}, },
readProgress = chapterItem.chapter.lastPageRead readProgress = chapterItem.chapter.lastPageRead
.takeIf { !chapterItem.chapter.read && it > 0L } .takeIf { !chapterItem.chapter.read && it > 0L }

View file

@ -13,7 +13,6 @@ import tachiyomi.domain.history.anime.model.AnimeHistoryWithRelations
import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.text.DateFormat
@Composable @Composable
fun AnimeHistoryContent( fun AnimeHistoryContent(
@ -24,7 +23,8 @@ fun AnimeHistoryContent(
onClickDelete: (AnimeHistoryWithRelations) -> Unit, onClickDelete: (AnimeHistoryWithRelations) -> Unit,
preferences: UiPreferences = Injekt.get(), preferences: UiPreferences = Injekt.get(),
) { ) {
val dateFormat: DateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } val relativeTime = remember { preferences.relativeTime().get() }
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) }
FastScrollLazyColumn( FastScrollLazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@ -44,6 +44,7 @@ fun AnimeHistoryContent(
RelativeDateHeader( RelativeDateHeader(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
date = item.date, date = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
) )
} }

View file

@ -11,7 +11,6 @@ import tachiyomi.domain.history.manga.model.MangaHistoryWithRelations
import tachiyomi.presentation.core.components.FastScrollLazyColumn import tachiyomi.presentation.core.components.FastScrollLazyColumn
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.text.DateFormat
@Composable @Composable
fun MangaHistoryContent( fun MangaHistoryContent(
@ -22,7 +21,8 @@ fun MangaHistoryContent(
onClickDelete: (MangaHistoryWithRelations) -> Unit, onClickDelete: (MangaHistoryWithRelations) -> Unit,
preferences: UiPreferences = Injekt.get(), preferences: UiPreferences = Injekt.get(),
) { ) {
val dateFormat: DateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } val relativeTime = remember { preferences.relativeTime().get() }
val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) }
FastScrollLazyColumn( FastScrollLazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@ -42,6 +42,7 @@ fun MangaHistoryContent(
RelativeDateHeader( RelativeDateHeader(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
date = item.date, date = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat, dateFormat = dateFormat,
) )
} }

View file

@ -72,7 +72,7 @@ fun MoreScreen(
textRes = R.string.fdroid_warning, textRes = R.string.fdroid_warning,
modifier = Modifier.clickable { modifier = Modifier.clickable {
uriHandler.openUri( uriHandler.openUri(
"https://aniyomi.org/help/faq/#how-do-i-migrate-from-the-f-droid-version", "https://aniyomi.org/docs/faq/general#how-do-i-update-from-the-f-droid-builds",
) )
}, },
) )

View file

@ -129,6 +129,11 @@ object SettingsAppearanceScreen : SearchableSettings {
} }
val now = remember { Date().time } val now = remember { Date().time }
val dateFormat by uiPreferences.dateFormat().collectAsState()
val formattedNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat).format(now)
}
LaunchedEffect(currentLanguage) { LaunchedEffect(currentLanguage) {
val locale = if (currentLanguage.isEmpty()) { val locale = if (currentLanguage.isEmpty()) {
LocaleListCompat.getEmptyLocaleList() LocaleListCompat.getEmptyLocaleList()
@ -199,6 +204,15 @@ object SettingsAppearanceScreen : SearchableSettings {
"${it.ifEmpty { stringResource(R.string.label_default) }} ($formattedDate)" "${it.ifEmpty { stringResource(R.string.label_default) }} ($formattedDate)"
}, },
), ),
Preference.PreferenceItem.SwitchPreference(
pref = uiPreferences.relativeTime(),
title = stringResource(R.string.pref_relative_format),
subtitle = stringResource(
R.string.pref_relative_format_summary,
stringResource(R.string.relative_time_today),
formattedNow,
),
),
), ),
) )
} }

View file

@ -149,7 +149,7 @@ object AboutScreen : Screen() {
title = stringResource(R.string.help_translate), title = stringResource(R.string.help_translate),
onPreferenceClick = { onPreferenceClick = {
uriHandler.openUri( uriHandler.openUri(
"https://aniyomi.org/help/contribution/#translation", "https://aniyomi.org/docs/contribute#translation",
) )
}, },
) )
@ -165,7 +165,7 @@ object AboutScreen : Screen() {
item { item {
TextPreferenceWidget( TextPreferenceWidget(
title = stringResource(R.string.privacy_policy), title = stringResource(R.string.privacy_policy),
onPreferenceClick = { uriHandler.openUri("https://aniyomi.org/privacy") }, onPreferenceClick = { uriHandler.openUri("https://aniyomi.org/privacy/") },
) )
} }

View file

@ -40,9 +40,9 @@ class OpenSourceLicensesScreen : Screen() {
), ),
onLibraryClick = { onLibraryClick = {
val libraryLicenseScreen = OpenSourceLibraryLicenseScreen( val libraryLicenseScreen = OpenSourceLibraryLicenseScreen(
name = it.name, name = it.library.name,
website = it.website, website = it.library.website,
license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(), license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
) )
navigator.push(libraryLicenseScreen) navigator.push(libraryLicenseScreen)
}, },

View file

@ -1,25 +1,25 @@
package eu.kanade.presentation.reader package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.domain.entries.manga.model.orientationType import eu.kanade.domain.entries.manga.model.orientationType
import eu.kanade.presentation.components.AdaptiveSheet import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import tachiyomi.presentation.core.components.SettingsChipRow import tachiyomi.presentation.core.components.SettingsIconGrid
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.IconToggleButton
private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it } private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it }
@ -36,22 +36,20 @@ fun OrientationModeSelectDialog(
) )
} }
AdaptiveSheet( AdaptiveSheet(onDismissRequest = onDismissRequest) {
onDismissRequest = onDismissRequest, Box(modifier = Modifier.padding(vertical = 16.dp)) {
) { SettingsIconGrid(R.string.rotation_type) {
Row( items(orientationTypeOptions) { (stringRes, mode) ->
modifier = Modifier.padding(vertical = 16.dp), IconToggleButton(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), checked = mode == orientationType,
) { onCheckedChange = {
SettingsChipRow(R.string.rotation_type) { screenModel.onChangeOrientation(mode)
orientationTypeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == orientationType,
onClick = {
screenModel.onChangeOrientation(it)
onChange(stringRes) onChange(stringRes)
onDismissRequest()
}, },
label = { Text(stringResource(stringRes)) }, modifier = Modifier.fillMaxWidth(),
imageVector = ImageVector.vectorResource(mode.iconRes),
title = stringResource(stringRes),
) )
} }
} }

View file

@ -1,24 +1,25 @@
package eu.kanade.presentation.reader package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.res.vectorResource
import eu.kanade.domain.entries.manga.model.readingModeType import eu.kanade.domain.entries.manga.model.readingModeType
import eu.kanade.presentation.components.AdaptiveSheet import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import tachiyomi.presentation.core.components.SettingsChipRow import tachiyomi.presentation.core.components.SettingsIconGrid
import tachiyomi.presentation.core.components.material.IconToggleButton
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it } private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it }
@ -36,22 +37,20 @@ fun ReadingModeSelectDialog(
) )
} }
AdaptiveSheet( AdaptiveSheet(onDismissRequest = onDismissRequest) {
onDismissRequest = onDismissRequest, Box(modifier = Modifier.padding(vertical = MaterialTheme.padding.medium)) {
) { SettingsIconGrid(R.string.pref_category_reading_mode) {
Row( items(readingModeOptions) { (stringRes, mode) ->
modifier = Modifier.padding(vertical = 16.dp), IconToggleButton(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), checked = mode == readingMode,
) { onCheckedChange = {
SettingsChipRow(R.string.pref_category_reading_mode) { screenModel.onChangeReadingMode(mode)
readingModeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == readingMode,
onClick = {
screenModel.onChangeReadingMode(it)
onChange(stringRes) onChange(stringRes)
onDismissRequest()
}, },
label = { Text(stringResource(stringRes)) }, modifier = Modifier.fillMaxWidth(),
imageVector = ImageVector.vectorResource(mode.iconRes),
title = stringResource(stringRes),
) )
} }
} }

View file

@ -229,6 +229,7 @@ fun SearchResultItem(
val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.clip(shape) .clip(shape)
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)

View file

@ -38,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds
fun AnimeUpdateScreen( fun AnimeUpdateScreen(
state: AnimeUpdatesScreenModel.State, state: AnimeUpdatesScreenModel.State,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
relativeTime: Boolean,
contentPadding: PaddingValues, contentPadding: PaddingValues,
lastUpdated: Long, lastUpdated: Long,
onClickCover: (AnimeUpdatesItem) -> Unit, onClickCover: (AnimeUpdatesItem) -> Unit,
@ -100,7 +101,7 @@ fun AnimeUpdateScreen(
animeUpdatesLastUpdatedItem(lastUpdated) animeUpdatesLastUpdatedItem(lastUpdated)
} }
animeUpdatesUiItems( animeUpdatesUiItems(
uiModels = state.getUiModel(context), uiModels = state.getUiModel(context, relativeTime),
selectionMode = state.selectionMode, selectionMode = state.selectionMode,
onUpdateSelected = onUpdateSelected, onUpdateSelected = onUpdateSelected,
onClickCover = onClickCover, onClickCover = onClickCover,

View file

@ -35,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds
fun MangaUpdateScreen( fun MangaUpdateScreen(
state: MangaUpdatesScreenModel.State, state: MangaUpdatesScreenModel.State,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
relativeTime: Boolean,
contentPadding: PaddingValues, contentPadding: PaddingValues,
lastUpdated: Long, lastUpdated: Long,
onClickCover: (MangaUpdatesItem) -> Unit, onClickCover: (MangaUpdatesItem) -> Unit,
@ -97,7 +98,7 @@ fun MangaUpdateScreen(
} }
mangaUpdatesUiItems( mangaUpdatesUiItems(
uiModels = state.getUiModel(context), uiModels = state.getUiModel(context, relativeTime),
selectionMode = state.selectionMode, selectionMode = state.selectionMode,
onUpdateSelected = onUpdateSelected, onUpdateSelected = onUpdateSelected,
onClickCover = onClickCover, onClickCover = onClickCover,

View file

@ -176,7 +176,7 @@ fun WebViewScreenContent(
textRes = R.string.information_cloudflare_help, textRes = R.string.information_cloudflare_help,
modifier = Modifier.clickable { modifier = Modifier.clickable {
uriHandler.openUri( uriHandler.openUri(
"https://aniyomi.org/docs/guides/troubleshooting/#solving-cloudflare-issues", "https://aniyomi.org/docs/guides/troubleshooting/#cloudflare",
) )
}, },
) )

View file

@ -505,6 +505,12 @@ object Migrations {
pref.getAndSet { it - "battery_not_low" } pref.getAndSet { it - "battery_not_low" }
} }
} }
if (oldVersion < 106) {
val pref = preferenceStore.getInt("relative_time", 7)
if (pref.get() == 0) {
uiPreferences.relativeTime().set(false)
}
}
return true return true
} }
} }

View file

@ -30,9 +30,9 @@ import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import tachiyomi.domain.entries.anime.interactor.SetAnimeFetchInterval import tachiyomi.domain.entries.anime.interactor.AnimeFetchInterval
import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.manga.interactor.SetMangaFetchInterval import tachiyomi.domain.entries.manga.interactor.MangaFetchInterval
import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.items.chapter.model.Chapter import tachiyomi.domain.items.chapter.model.Chapter
import tachiyomi.domain.items.chapter.repository.ChapterRepository import tachiyomi.domain.items.chapter.repository.ChapterRepository
@ -54,15 +54,15 @@ class BackupRestorer(
) { ) {
private val updateManga: UpdateManga = Injekt.get() private val updateManga: UpdateManga = Injekt.get()
private val chapterRepository: ChapterRepository = Injekt.get() private val chapterRepository: ChapterRepository = Injekt.get()
private val setMangaFetchInterval: SetMangaFetchInterval = Injekt.get() private val mangaFetchInterval: MangaFetchInterval = Injekt.get()
private val updateAnime: UpdateAnime = Injekt.get() private val updateAnime: UpdateAnime = Injekt.get()
private val episodeRepository: EpisodeRepository = Injekt.get() private val episodeRepository: EpisodeRepository = Injekt.get()
private val setAnimeFetchInterval: SetAnimeFetchInterval = Injekt.get() private val animeFetchInterval: AnimeFetchInterval = Injekt.get()
private var now = ZonedDateTime.now() private var now = ZonedDateTime.now()
private var currentMangaFetchWindow = setMangaFetchInterval.getWindow(now) private var currentMangaFetchWindow = mangaFetchInterval.getWindow(now)
private var currentAnimeFetchWindow = setAnimeFetchInterval.getWindow(now) private var currentAnimeFetchWindow = animeFetchInterval.getWindow(now)
private var backupManager = BackupManager(context) private var backupManager = BackupManager(context)
@ -152,8 +152,8 @@ class BackupRestorer(
animeSourceMapping = backupAnimeMaps.associate { it.sourceId to it.name } animeSourceMapping = backupAnimeMaps.associate { it.sourceId to it.name }
now = ZonedDateTime.now() now = ZonedDateTime.now()
currentMangaFetchWindow = setMangaFetchInterval.getWindow(now) currentMangaFetchWindow = mangaFetchInterval.getWindow(now)
currentAnimeFetchWindow = setAnimeFetchInterval.getWindow(now) currentAnimeFetchWindow = animeFetchInterval.getWindow(now)
return coroutineScope { return coroutineScope {
// Restore individual manga // Restore individual manga

View file

@ -674,7 +674,7 @@ class AnimeDownloader(
if (isHls(video) || isMpd(video)) { if (isHls(video) || isMpd(video)) {
return ffmpegDownload(video, download, tmpDir, filename) return ffmpegDownload(video, download, tmpDir, filename)
} else { } else {
val response = download.source.fetchVideo(video) val response = download.source.getVideo(video)
val file = tmpDir.findFile("$filename.tmp") ?: tmpDir.createFile("$filename.tmp") val file = tmpDir.findFile("$filename.tmp") ?: tmpDir.createFile("$filename.tmp")
// Write to file with pause/resume capability // Write to file with pause/resume capability

View file

@ -46,7 +46,6 @@ import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response import okhttp3.Response
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNow import tachiyomi.core.util.lang.launchNow
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
@ -396,7 +395,7 @@ class MangaDownloader(
if (page.imageUrl.isNullOrEmpty()) { if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LOAD_PAGE page.status = Page.State.LOAD_PAGE
try { try {
page.imageUrl = download.source.fetchImageUrl(page).awaitSingle() page.imageUrl = download.source.getImageUrl(page)
} catch (e: Throwable) { } catch (e: Throwable) {
page.status = Page.State.ERROR page.status = Page.State.ERROR
} }

View file

@ -48,9 +48,9 @@ import tachiyomi.core.util.system.logcat
import tachiyomi.domain.category.anime.interactor.GetAnimeCategories import tachiyomi.domain.category.anime.interactor.GetAnimeCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.entries.anime.interactor.AnimeFetchInterval
import tachiyomi.domain.entries.anime.interactor.GetAnime import tachiyomi.domain.entries.anime.interactor.GetAnime
import tachiyomi.domain.entries.anime.interactor.GetLibraryAnime import tachiyomi.domain.entries.anime.interactor.GetLibraryAnime
import tachiyomi.domain.entries.anime.interactor.SetAnimeFetchInterval
import tachiyomi.domain.entries.anime.model.Anime import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.anime.model.toAnimeUpdate import tachiyomi.domain.entries.anime.model.toAnimeUpdate
import tachiyomi.domain.items.episode.model.Episode import tachiyomi.domain.items.episode.model.Episode
@ -90,7 +90,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
private val getCategories: GetAnimeCategories = Injekt.get() private val getCategories: GetAnimeCategories = Injekt.get()
private val syncEpisodesWithSource: SyncEpisodesWithSource = Injekt.get() private val syncEpisodesWithSource: SyncEpisodesWithSource = Injekt.get()
private val refreshAnimeTracks: RefreshAnimeTracks = Injekt.get() private val refreshAnimeTracks: RefreshAnimeTracks = Injekt.get()
private val setAnimeFetchInterval: SetAnimeFetchInterval = Injekt.get() private val animeFetchInterval: AnimeFetchInterval = Injekt.get()
private val notifier = AnimeLibraryUpdateNotifier(context) private val notifier = AnimeLibraryUpdateNotifier(context)
@ -216,7 +216,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>() val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val hasDownloads = AtomicBoolean(false) val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get() val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = setAnimeFetchInterval.getWindow(ZonedDateTime.now()) val fetchWindow = animeFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope { coroutineScope {
animeToUpdate.groupBy { it.anime.source }.values animeToUpdate.groupBy { it.anime.source }.values
@ -537,7 +537,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
private const val WORK_NAME_AUTO = "AnimeLibraryUpdate-auto" private const val WORK_NAME_AUTO = "AnimeLibraryUpdate-auto"
private const val WORK_NAME_MANUAL = "AnimeLibraryUpdate-manual" private const val WORK_NAME_MANUAL = "AnimeLibraryUpdate-manual"
private const val ERROR_LOG_HELP_URL = "https://aniyomi.org/help/guides/troubleshooting" private const val ERROR_LOG_HELP_URL = "https://aniyomi.org/docs/guides/troubleshooting/"
private const val ANIME_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 private const val ANIME_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60

View file

@ -377,7 +377,7 @@ class AnimeLibraryUpdateNotifier(private val context: Context) {
companion object { companion object {
// TODO: Change when implemented on Aniyomi website // TODO: Change when implemented on Aniyomi website
const val HELP_WARNING_URL = "https://aniyomi.org/help/faq/#why-does-the-app-warn-about-large-bulk-updates-and-downloads" const val HELP_WARNING_URL = "https://aniyomi.org/docs/faq/library#why-am-i-warned-about-large-bulk-updates-and-downloads"
} }
} }
@ -386,4 +386,4 @@ private const val NOTIF_ANIME_TITLE_MAX_LEN = 45
private const val NOTIF_ANIME_ICON_SIZE = 192 private const val NOTIF_ANIME_ICON_SIZE = 192
// TODO: Change when implemented on Aniyomi website // TODO: Change when implemented on Aniyomi website
private const val HELP_SKIPPED_ANIME_URL = "https://aniyomi.org/help/faq/#why-does-global-update-skip-some-entries" private const val HELP_SKIPPED_ANIME_URL = "https://aniyomi.org/docs/faq/library#why-is-global-update-skipping-entries"

View file

@ -50,7 +50,7 @@ import tachiyomi.domain.category.model.Category
import tachiyomi.domain.download.service.DownloadPreferences import tachiyomi.domain.download.service.DownloadPreferences
import tachiyomi.domain.entries.manga.interactor.GetLibraryManga import tachiyomi.domain.entries.manga.interactor.GetLibraryManga
import tachiyomi.domain.entries.manga.interactor.GetManga import tachiyomi.domain.entries.manga.interactor.GetManga
import tachiyomi.domain.entries.manga.interactor.SetMangaFetchInterval import tachiyomi.domain.entries.manga.interactor.MangaFetchInterval
import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.entries.manga.model.toMangaUpdate import tachiyomi.domain.entries.manga.model.toMangaUpdate
import tachiyomi.domain.items.chapter.model.Chapter import tachiyomi.domain.items.chapter.model.Chapter
@ -90,7 +90,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
private val getCategories: GetMangaCategories = Injekt.get() private val getCategories: GetMangaCategories = Injekt.get()
private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get() private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get()
private val refreshMangaTracks: RefreshMangaTracks = Injekt.get() private val refreshMangaTracks: RefreshMangaTracks = Injekt.get()
private val setMangaFetchInterval: SetMangaFetchInterval = Injekt.get() private val mangaFetchInterval: MangaFetchInterval = Injekt.get()
private val notifier = MangaLibraryUpdateNotifier(context) private val notifier = MangaLibraryUpdateNotifier(context)
@ -216,7 +216,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>() val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false) val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get() val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = setMangaFetchInterval.getWindow(ZonedDateTime.now()) val fetchWindow = mangaFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope { coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values mangaToUpdate.groupBy { it.manga.source }.values

View file

@ -380,7 +380,7 @@ class MangaLibraryUpdateNotifier(private val context: Context) {
companion object { companion object {
// TODO: Change when implemented on Aniyomi website // TODO: Change when implemented on Aniyomi website
const val HELP_WARNING_URL = "https://aniyomi.org/help/faq/#why-does-the-app-warn-about-large-bulk-updates-and-downloads" const val HELP_WARNING_URL = "https://aniyomi.org/docs/faq/library#why-am-i-warned-about-large-bulk-updates-and-downloads"
} }
} }
@ -389,4 +389,4 @@ private const val NOTIF_MANGA_TITLE_MAX_LEN = 45
private const val NOTIF_MANGA_ICON_SIZE = 192 private const val NOTIF_MANGA_ICON_SIZE = 192
// TODO: Change when implemented on Aniyomi website // TODO: Change when implemented on Aniyomi website
private const val HELP_SKIPPED_MANGA_URL = "https://aniyomi.org/help/faq/#why-does-global-update-skip-some-entries" private const val HELP_SKIPPED_MANGA_URL = "https://aniyomi.org/docs/faq/library#why-is-global-update-skipping-entries"

View file

@ -32,28 +32,56 @@ class ImageSaver(
fun save(image: Image): Uri { fun save(image: Image): Uri {
val data = image.data val data = image.data
val type = ImageUtil.findImageType(data) ?: throw Exception("Not an image") val type = ImageUtil.findImageType(data) ?: throw IllegalArgumentException("Not an image")
val filename = DiskUtil.buildValidFilename("${image.name}.${type.extension}") val filename = DiskUtil.buildValidFilename("${image.name}.${type.extension}")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || image.location !is Location.Pictures) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || image.location !is Location.Pictures) {
return save(data(), image.location.directory(context), filename) return save(data(), image.location.directory(context), filename)
} }
return saveApi29(image, type, filename, data)
}
private fun save(inputStream: InputStream, directory: File, filename: String): Uri {
directory.mkdirs()
val destFile = File(directory, filename)
inputStream.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.scanMedia(context, destFile.toUri())
return destFile.getUriCompat(context)
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveApi29(
image: Image,
type: ImageUtil.ImageType,
filename: String,
data: () -> InputStream,
): Uri {
val pictureDir = val pictureDir =
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val folderRelativePath = "${Environment.DIRECTORY_PICTURES}/${context.getString(
R.string.app_name,
)}/"
val imageLocation = (image.location as Location.Pictures).relativePath val imageLocation = (image.location as Location.Pictures).relativePath
val relativePath = listOf(
Environment.DIRECTORY_PICTURES,
context.getString(R.string.app_name),
imageLocation,
).joinToString(File.separator)
val contentValues = contentValuesOf( val contentValues = contentValuesOf(
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
MediaStore.Images.Media.DISPLAY_NAME to image.name, MediaStore.Images.Media.DISPLAY_NAME to image.name,
MediaStore.Images.Media.MIME_TYPE to type.mime, MediaStore.Images.Media.MIME_TYPE to type.mime,
MediaStore.Images.Media.RELATIVE_PATH to folderRelativePath + imageLocation,
) )
val picture = findUriOrDefault(folderRelativePath, "$imageLocation$filename") { val picture = findUriOrDefault(relativePath, filename) {
context.contentResolver.insert( context.contentResolver.insert(
pictureDir, pictureDir,
contentValues, contentValues,
@ -76,24 +104,8 @@ class ImageSaver(
return picture return picture
} }
private fun save(inputStream: InputStream, directory: File, filename: String): Uri {
directory.mkdirs()
val destFile = File(directory, filename)
inputStream.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
DiskUtil.scanMedia(context, destFile.toUri())
return destFile.getUriCompat(context)
}
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
private fun findUriOrDefault(relativePath: String, imagePath: String, default: () -> Uri): Uri { private fun findUriOrDefault(path: String, filename: String, default: () -> Uri): Uri {
val projection = arrayOf( val projection = arrayOf(
MediaStore.MediaColumns._ID, MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.DISPLAY_NAME,
@ -104,19 +116,19 @@ class ImageSaver(
val selection = "${MediaStore.MediaColumns.RELATIVE_PATH}=? AND ${MediaStore.MediaColumns.DISPLAY_NAME}=?" val selection = "${MediaStore.MediaColumns.RELATIVE_PATH}=? AND ${MediaStore.MediaColumns.DISPLAY_NAME}=?"
// Need to make sure it ends with the separator
val normalizedPath = "${path.removeSuffix(File.separator)}${File.separator}"
context.contentResolver.query( context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection, projection,
selection, selection,
arrayOf(relativePath, imagePath), arrayOf(normalizedPath, filename),
null, null,
).use { cursor -> ).use { cursor ->
if (cursor != null && cursor.count >= 1) { if (cursor != null && cursor.count >= 1) {
cursor.moveToFirst().let { if (cursor.moveToFirst()) {
val id = cursor.getLong( val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID),
)
return ContentUris.withAppendedId( return ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id, id,
@ -124,6 +136,7 @@ class ImageSaver(
} }
} }
} }
return default() return default()
} }
} }

View file

@ -161,7 +161,7 @@ internal class AppUpdateNotifier(private val context: Context) {
setContentIntent( setContentIntent(
NotificationHandler.openUrl( NotificationHandler.openUrl(
context, context,
"https://aniyomi.org/help/faq/#how-do-i-migrate-from-the-f-droid-version", "https://aniyomi.org/docs/faq/general#how-do-i-update-from-the-f-droid-builds",
), ),
) )
} }

View file

@ -105,7 +105,7 @@ class AnimeExtensionDetailsScreenModel(
val extension = state.value.extension ?: return "" val extension = state.value.extension ?: return ""
if (!extension.hasReadme) { if (!extension.hasReadme) {
return "https://aniyomi.org/help/faq/#extensions" return "https://aniyomi.org/docs/faq/browse/extensions"
} }
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.animeextension.") val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.animeextension.")

View file

@ -31,10 +31,8 @@ import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -115,25 +113,20 @@ class BrowseAnimeSourceScreenModel(
/** /**
* Flow of Pager flow tied to [State.listing] * Flow of Pager flow tied to [State.listing]
*/ */
private val hideInLibraryItems = sourcePreferences.hideInAnimeLibraryItems().get()
val animePagerFlowFlow = state.map { it.listing } val animePagerFlowFlow = state.map { it.listing }
.distinctUntilChanged() .distinctUntilChanged()
.map { listing -> .map { listing ->
Pager( Pager(PagingConfig(pageSize = 25)) {
PagingConfig(pageSize = 25),
) {
getRemoteAnime.subscribe(sourceId, listing.query ?: "", listing.filters) getRemoteAnime.subscribe(sourceId, listing.query ?: "", listing.filters)
}.flow.map { pagingData -> }.flow.map { pagingData ->
pagingData.map { pagingData.map {
networkToLocalAnime.await(it.toDomainAnime(sourceId)) networkToLocalAnime.await(it.toDomainAnime(sourceId))
.let { localAnime -> .let { localAnime -> getAnime.subscribe(localAnime.url, localAnime.source) }
getAnime.subscribe(localAnime.url, localAnime.source)
}
.filterNotNull() .filterNotNull()
.filter { localAnime ->
!sourcePreferences.hideInAnimeLibraryItems().get() || !localAnime.favorite
}
.stateIn(ioCoroutineScope) .stateIn(ioCoroutineScope)
} }
.filter { !hideInLibraryItems || !it.value.favorite }
} }
.cachedIn(ioCoroutineScope) .cachedIn(ioCoroutineScope)
} }
@ -301,7 +294,6 @@ class BrowseAnimeSourceScreenModel(
track.anime_id = anime.id track.anime_id = anime.id
(service as TrackService).animeService.bind(track) (service as TrackService).animeService.bind(track)
insertTrack.await(track.toDomainTrack()!!) insertTrack.await(track.toDomainTrack()!!)
syncEpisodeProgressWithTrack.await( syncEpisodeProgressWithTrack.await(
anime.id, anime.id,
track.toDomainTrack()!!, track.toDomainTrack()!!,

View file

@ -105,7 +105,7 @@ class MangaExtensionDetailsScreenModel(
val extension = state.value.extension ?: return "" val extension = state.value.extension ?: return ""
if (!extension.hasReadme) { if (!extension.hasReadme) {
return "https://aniyomi.org/help/faq/#extensions" return "https://aniyomi.org/docs/faq/browse/extensions"
} }
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")

View file

@ -9,6 +9,7 @@ import androidx.compose.ui.unit.dp
import androidx.paging.Pager import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.filter
import androidx.paging.map import androidx.paging.map
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope import cafe.adriel.voyager.core.model.coroutineScope
@ -30,7 +31,6 @@ import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -113,25 +113,20 @@ class BrowseMangaSourceScreenModel(
/** /**
* Flow of Pager flow tied to [State.listing] * Flow of Pager flow tied to [State.listing]
*/ */
private val hideInLibraryItems = sourcePreferences.hideInMangaLibraryItems().get()
val mangaPagerFlowFlow = state.map { it.listing } val mangaPagerFlowFlow = state.map { it.listing }
.distinctUntilChanged() .distinctUntilChanged()
.map { listing -> .map { listing ->
Pager( Pager(PagingConfig(pageSize = 25)) {
PagingConfig(pageSize = 25),
) {
getRemoteManga.subscribe(sourceId, listing.query ?: "", listing.filters) getRemoteManga.subscribe(sourceId, listing.query ?: "", listing.filters)
}.flow.map { pagingData -> }.flow.map { pagingData ->
pagingData.map { pagingData.map {
networkToLocalManga.await(it.toDomainManga(sourceId)) networkToLocalManga.await(it.toDomainManga(sourceId))
.let { localManga -> .let { localManga -> getManga.subscribe(localManga.url, localManga.source) }
getManga.subscribe(localManga.url, localManga.source)
}
.filterNotNull() .filterNotNull()
.filter { localManga ->
!sourcePreferences.hideInMangaLibraryItems().get() || !localManga.favorite
}
.stateIn(ioCoroutineScope) .stateIn(ioCoroutineScope)
} }
.filter { !hideInLibraryItems || !it.value.favorite }
} }
.cachedIn(ioCoroutineScope) .cachedIn(ioCoroutineScope)
} }
@ -301,7 +296,6 @@ class BrowseMangaSourceScreenModel(
track.manga_id = manga.id track.manga_id = manga.id
(service as TrackService).mangaService.bind(track) (service as TrackService).mangaService.bind(track)
insertTrack.await(track.toDomainTrack()!!) insertTrack.await(track.toDomainTrack()!!)
syncChapterProgressWithTrack.await( syncChapterProgressWithTrack.await(
manga.id, manga.id,
track.toDomainTrack()!!, track.toDomainTrack()!!,

View file

@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.domain.entries.manga.interactor.GetManga import tachiyomi.domain.entries.manga.interactor.GetManga
import tachiyomi.domain.entries.manga.interactor.NetworkToLocalManga import tachiyomi.domain.entries.manga.interactor.NetworkToLocalManga
import tachiyomi.domain.entries.manga.model.Manga import tachiyomi.domain.entries.manga.model.Manga
@ -138,7 +137,7 @@ abstract class MangaSearchScreenModel(
} }
try { try {
val page = withContext(coroutineDispatcher) { val page = withContext(coroutineDispatcher) {
source.fetchSearchManga(1, query, source.getFilterList()).awaitSingle() source.getSearchManga(1, query, source.getFilterList())
} }
val titles = page.mangas.map { val titles = page.mangas.map {

View file

@ -78,13 +78,17 @@ class AnimeDownloadQueueScreenModel(
} }
reorder(newAnimeDownloads) reorder(newAnimeDownloads)
} }
R.id.move_to_top_series -> { R.id.move_to_top_series, R.id.move_to_bottom_series -> {
val (selectedSeries, otherSeries) = adapter?.currentItems val (selectedSeries, otherSeries) = adapter?.currentItems
?.filterIsInstance<AnimeDownloadItem>() ?.filterIsInstance<AnimeDownloadItem>()
?.map(AnimeDownloadItem::download) ?.map(AnimeDownloadItem::download)
?.partition { item.download.anime.id == it.anime.id } ?.partition { item.download.anime.id == it.anime.id }
?: Pair(emptyList(), emptyList()) ?: Pair(emptyList(), emptyList())
reorder(selectedSeries + otherSeries) if (menuItem.itemId == R.id.move_to_top_series) {
reorder(selectedSeries + otherSeries)
} else {
reorder(otherSeries + selectedSeries)
}
} }
R.id.cancel_download -> { R.id.cancel_download -> {
cancel(listOf(item.download)) cancel(listOf(item.download))

View file

@ -84,13 +84,17 @@ class MangaDownloadQueueScreenModel(
} }
reorder(newDownloads) reorder(newDownloads)
} }
R.id.move_to_top_series -> { R.id.move_to_top_series, R.id.move_to_bottom_series -> {
val (selectedSeries, otherSeries) = adapter?.currentItems val (selectedSeries, otherSeries) = adapter?.currentItems
?.filterIsInstance<MangaDownloadItem>() ?.filterIsInstance<MangaDownloadItem>()
?.map(MangaDownloadItem::download) ?.map(MangaDownloadItem::download)
?.partition { item.download.manga.id == it.manga.id } ?.partition { item.download.manga.id == it.manga.id }
?: Pair(emptyList(), emptyList()) ?: Pair(emptyList(), emptyList())
reorder(selectedSeries + otherSeries) if (menuItem.itemId == R.id.move_to_top_series) {
reorder(selectedSeries + otherSeries)
} else {
reorder(otherSeries + selectedSeries)
}
} }
R.id.cancel_download -> { R.id.cancel_download -> {
cancel(listOf(item.download)) cancel(listOf(item.download))

View file

@ -105,6 +105,7 @@ class AnimeScreen(
AnimeScreen( AnimeScreen(
state = successState, state = successState,
snackbarHostState = screenModel.snackbarHostState, snackbarHostState = screenModel.snackbarHostState,
dateRelativeTime = screenModel.relativeTime,
dateFormat = screenModel.dateFormat, dateFormat = screenModel.dateFormat,
fetchInterval = successState.anime.fetchInterval, fetchInterval = successState.anime.fetchInterval,
isTabletUi = isTabletUi(), isTabletUi = isTabletUi(),

View file

@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.core.preference.asState
import eu.kanade.core.util.addOrRemove import eu.kanade.core.util.addOrRemove
import eu.kanade.domain.entries.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.entries.anime.interactor.SetAnimeViewerFlags
import eu.kanade.domain.entries.anime.interactor.UpdateAnime import eu.kanade.domain.entries.anime.interactor.UpdateAnime
@ -132,6 +133,7 @@ class AnimeScreenModel(
val alwaysUseExternalPlayer = playerPreferences.alwaysUseExternalPlayer().get() val alwaysUseExternalPlayer = playerPreferences.alwaysUseExternalPlayer().get()
val useExternalDownloader = downloadPreferences.useExternalDownloader().get() val useExternalDownloader = downloadPreferences.useExternalDownloader().get()
val relativeTime by uiPreferences.relativeTime().asState(coroutineScope)
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.autoUpdateItemRestrictions().get() val isUpdateIntervalEnabled = LibraryPreferences.ENTRY_OUTSIDE_RELEASE_PERIOD in libraryPreferences.autoUpdateItemRestrictions().get()

View file

@ -100,6 +100,7 @@ class MangaScreen(
MangaScreen( MangaScreen(
state = successState, state = successState,
snackbarHostState = screenModel.snackbarHostState, snackbarHostState = screenModel.snackbarHostState,
dateRelativeTime = screenModel.relativeTime,
dateFormat = screenModel.dateFormat, dateFormat = screenModel.dateFormat,
fetchInterval = successState.manga.fetchInterval, fetchInterval = successState.manga.fetchInterval,
isTabletUi = isTabletUi(), isTabletUi = isTabletUi(),

View file

@ -127,6 +127,7 @@ class MangaScreenModel(
val chapterSwipeStartAction = libraryPreferences.swipeChapterEndAction().get() val chapterSwipeStartAction = libraryPreferences.swipeChapterEndAction().get()
val chapterSwipeEndAction = libraryPreferences.swipeChapterStartAction().get() val chapterSwipeEndAction = libraryPreferences.swipeChapterStartAction().get()
val relativeTime by uiPreferences.relativeTime().asState(coroutineScope)
val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get())) val dateFormat by mutableStateOf(UiPreferences.dateFormat(uiPreferences.dateFormat().get()))
val skipFiltered by readerPreferences.skipFiltered().asState(coroutineScope) val skipFiltered by readerPreferences.skipFiltered().asState(coroutineScope)

View file

@ -198,7 +198,7 @@ object AnimeLibraryTab : Tab() {
icon = Icons.Outlined.HelpOutline, icon = Icons.Outlined.HelpOutline,
onClick = { onClick = {
handler.openUri( handler.openUri(
"https://aniyomi.org/help/guides/getting-started", "https://aniyomi.org/docs/guides/getting-started",
) )
}, },
), ),

View file

@ -193,7 +193,7 @@ object MangaLibraryTab : Tab() {
icon = Icons.Outlined.HelpOutline, icon = Icons.Outlined.HelpOutline,
onClick = { onClick = {
handler.openUri( handler.openUri(
"https://aniyomi.org/help/guides/getting-started", "https://aniyomi.org/docs/guides/getting-started",
) )
}, },
), ),

View file

@ -340,7 +340,7 @@ class PlayerActivity : BaseActivity() {
displayMode = state.anime!!.displayMode, displayMode = state.anime!!.displayMode,
episodeList = viewModel.currentPlaylist, episodeList = viewModel.currentPlaylist,
currentEpisodeIndex = viewModel.getCurrentEpisodeIndex(), currentEpisodeIndex = viewModel.getCurrentEpisodeIndex(),
relativeTime = viewModel.relativeTime, dateRelativeTime = viewModel.relativeTime,
dateFormat = viewModel.dateFormat, dateFormat = viewModel.dateFormat,
onBookmarkClicked = viewModel::bookmarkEpisode, onBookmarkClicked = viewModel::bookmarkEpisode,
onEpisodeClicked = this::changeEpisode, onEpisodeClicked = this::changeEpisode,

View file

@ -50,7 +50,7 @@ fun EpisodeListDialog(
displayMode: Long, displayMode: Long,
currentEpisodeIndex: Int, currentEpisodeIndex: Int,
episodeList: List<Episode>, episodeList: List<Episode>,
relativeTime: Int, dateRelativeTime: Boolean,
dateFormat: DateFormat, dateFormat: DateFormat,
onBookmarkClicked: (Long?, Boolean) -> Unit, onBookmarkClicked: (Long?, Boolean) -> Unit,
onEpisodeClicked: (Long?) -> Unit, onEpisodeClicked: (Long?) -> Unit,
@ -92,7 +92,7 @@ fun EpisodeListDialog(
val date = episode.date_upload val date = episode.date_upload
.takeIf { it > 0L } .takeIf { it > 0L }
?.let { ?.let {
Date(it).toRelativeString(context, dateFormat) Date(it).toRelativeString(context, dateRelativeTime, dateFormat)
} ?: "" } ?: ""
EpisodeListItem( EpisodeListItem(

View file

@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -186,7 +185,7 @@ internal class HttpPageLoader(
try { try {
if (page.imageUrl.isNullOrEmpty()) { if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LOAD_PAGE page.status = Page.State.LOAD_PAGE
page.imageUrl = source.fetchImageUrl(page).awaitSingle() page.imageUrl = source.getImageUrl(page)
} }
val imageUrl = page.imageUrl!! val imageUrl = page.imageUrl!!

View file

@ -60,6 +60,7 @@ class AnimeUpdatesScreenModel(
private val getEpisode: GetEpisode = Injekt.get(), private val getEpisode: GetEpisode = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(), val snackbarHostState: SnackbarHostState = SnackbarHostState(),
uiPreferences: UiPreferences = Injekt.get(),
downloadPreferences: DownloadPreferences = Injekt.get(), downloadPreferences: DownloadPreferences = Injekt.get(),
) : StateScreenModel<AnimeUpdatesScreenModel.State>(State()) { ) : StateScreenModel<AnimeUpdatesScreenModel.State>(State()) {
@ -67,6 +68,7 @@ class AnimeUpdatesScreenModel(
val events: Flow<Event> = _events.receiveAsFlow() val events: Flow<Event> = _events.receiveAsFlow()
val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope) val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope)
val relativeTime by uiPreferences.relativeTime().asState(coroutineScope)
val useExternalDownloader = downloadPreferences.useExternalDownloader().get() val useExternalDownloader = downloadPreferences.useExternalDownloader().get()
@ -393,7 +395,7 @@ class AnimeUpdatesScreenModel(
val selected = items.filter { it.selected } val selected = items.filter { it.selected }
val selectionMode = selected.isNotEmpty() val selectionMode = selected.isNotEmpty()
fun getUiModel(context: Context): List<AnimeUpdatesUiModel> { fun getUiModel(context: Context, relativeTime: Boolean): List<AnimeUpdatesUiModel> {
val dateFormat by mutableStateOf( val dateFormat by mutableStateOf(
UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get()), UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get()),
) )
@ -405,7 +407,11 @@ class AnimeUpdatesScreenModel(
val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0) val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0)
when { when {
beforeDate.time != afterDate.time && afterDate.time != 0L -> { beforeDate.time != afterDate.time && afterDate.time != 0L -> {
val text = afterDate.toRelativeString(context, dateFormat) val text = afterDate.toRelativeString(
context = context,
relative = relativeTime,
dateFormat = dateFormat,
)
AnimeUpdatesUiModel.Header(text) AnimeUpdatesUiModel.Header(text)
} }
// Return null to avoid adding a separator between two items. // Return null to avoid adding a separator between two items.

View file

@ -59,6 +59,7 @@ fun Screen.animeUpdatesTab(
snackbarHostState = screenModel.snackbarHostState, snackbarHostState = screenModel.snackbarHostState,
contentPadding = contentPadding, contentPadding = contentPadding,
lastUpdated = screenModel.lastUpdated, lastUpdated = screenModel.lastUpdated,
relativeTime = screenModel.relativeTime,
onClickCover = { item -> navigator.push(AnimeScreen(item.update.animeId)) }, onClickCover = { item -> navigator.push(AnimeScreen(item.update.animeId)) },
onSelectAll = screenModel::toggleAllSelection, onSelectAll = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection, onInvertSelection = screenModel::invertSelection,

View file

@ -59,12 +59,14 @@ class MangaUpdatesScreenModel(
private val getChapter: GetChapter = Injekt.get(), private val getChapter: GetChapter = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(),
val snackbarHostState: SnackbarHostState = SnackbarHostState(), val snackbarHostState: SnackbarHostState = SnackbarHostState(),
uiPreferences: UiPreferences = Injekt.get(),
) : StateScreenModel<MangaUpdatesScreenModel.State>(State()) { ) : StateScreenModel<MangaUpdatesScreenModel.State>(State()) {
private val _events: Channel<Event> = Channel(Int.MAX_VALUE) private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
val events: Flow<Event> = _events.receiveAsFlow() val events: Flow<Event> = _events.receiveAsFlow()
val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope) val lastUpdated by libraryPreferences.lastUpdatedTimestamp().asState(coroutineScope)
val relativeTime by uiPreferences.relativeTime().asState(coroutineScope)
// First and last selected index in list // First and last selected index in list
private val selectedPositions: Array<Int> = arrayOf(-1, -1) private val selectedPositions: Array<Int> = arrayOf(-1, -1)
@ -374,7 +376,7 @@ class MangaUpdatesScreenModel(
val selected = items.filter { it.selected } val selected = items.filter { it.selected }
val selectionMode = selected.isNotEmpty() val selectionMode = selected.isNotEmpty()
fun getUiModel(context: Context): List<MangaUpdatesUiModel> { fun getUiModel(context: Context, relativeTime: Boolean): List<MangaUpdatesUiModel> {
val dateFormat by mutableStateOf( val dateFormat by mutableStateOf(
UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get()), UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get()),
) )
@ -386,7 +388,11 @@ class MangaUpdatesScreenModel(
val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0) val afterDate = after?.item?.update?.dateFetch?.toDateKey() ?: Date(0)
when { when {
beforeDate.time != afterDate.time && afterDate.time != 0L -> { beforeDate.time != afterDate.time && afterDate.time != 0L -> {
val text = afterDate.toRelativeString(context, dateFormat) val text = afterDate.toRelativeString(
context = context,
relative = relativeTime,
dateFormat = dateFormat,
)
MangaUpdatesUiModel.Header(text) MangaUpdatesUiModel.Header(text)
} }
// Return null to avoid adding a separator between two items. // Return null to avoid adding a separator between two items.

View file

@ -46,6 +46,7 @@ fun Screen.mangaUpdatesTab(
snackbarHostState = screenModel.snackbarHostState, snackbarHostState = screenModel.snackbarHostState,
contentPadding = contentPadding, contentPadding = contentPadding,
lastUpdated = screenModel.lastUpdated, lastUpdated = screenModel.lastUpdated,
relativeTime = screenModel.relativeTime,
onClickCover = { item -> navigator.push(MangaScreen(item.update.mangaId)) }, onClickCover = { item -> navigator.push(MangaScreen(item.update.mangaId)) },
onSelectAll = screenModel::toggleAllSelection, onSelectAll = screenModel::toggleAllSelection,
onInvertSelection = screenModel::invertSelection, onInvertSelection = screenModel::invertSelection,

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.os.Build import android.os.Build
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.WebViewUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import eu.kanade.tachiyomi.util.system.toShareIntent import eu.kanade.tachiyomi.util.system.toShareIntent
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
@ -35,6 +36,7 @@ class CrashLogUtil(private val context: Context) {
Device name: ${Build.DEVICE} Device name: ${Build.DEVICE}
Device model: ${Build.MODEL} Device model: ${Build.MODEL}
Device product name: ${Build.PRODUCT} Device product name: ${Build.PRODUCT}
WebView user agent: ${WebViewUtil.getInferredUserAgent(context)}
""".trimIndent() """.trimIndent()
} }
} }

View file

@ -8,7 +8,6 @@ import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
import java.util.TimeZone
fun Date.toDateTimestampString(dateFormatter: DateFormat): String { fun Date.toDateTimestampString(dateFormatter: DateFormat): String {
val date = dateFormatter.format(this) val date = dateFormatter.format(this)
@ -46,80 +45,18 @@ fun Long.toDateKey(): Date {
return cal.time return cal.time
} }
/**
* Convert epoch long to Calendar instance
*
* @return Calendar instance at supplied epoch time. Null if epoch was 0.
*/
fun Long.toCalendar(): Calendar? {
if (this == 0L) {
return null
}
val cal = Calendar.getInstance()
cal.timeInMillis = this
return cal
}
/**
* Convert local time millisecond value to Calendar instance in UTC
*
* @return UTC Calendar instance at supplied time. Null if time is 0.
*/
fun Long.toUtcCalendar(): Calendar? {
if (this == 0L) {
return null
}
val rawCalendar = Calendar.getInstance().apply {
timeInMillis = this@toUtcCalendar
}
return Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
clear()
set(
rawCalendar.get(Calendar.YEAR),
rawCalendar.get(Calendar.MONTH),
rawCalendar.get(Calendar.DAY_OF_MONTH),
rawCalendar.get(Calendar.HOUR_OF_DAY),
rawCalendar.get(Calendar.MINUTE),
rawCalendar.get(Calendar.SECOND),
)
}
}
/**
* Convert UTC time millisecond to Calendar instance in local time zone
*
* @return local Calendar instance at supplied UTC time. Null if time is 0.
*/
fun Long.toLocalCalendar(): Calendar? {
if (this == 0L) {
return null
}
val rawCalendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply {
timeInMillis = this@toLocalCalendar
}
return Calendar.getInstance().apply {
clear()
set(
rawCalendar.get(Calendar.YEAR),
rawCalendar.get(Calendar.MONTH),
rawCalendar.get(Calendar.DAY_OF_MONTH),
rawCalendar.get(Calendar.HOUR_OF_DAY),
rawCalendar.get(Calendar.MINUTE),
rawCalendar.get(Calendar.SECOND),
)
}
}
private const val MILLISECONDS_IN_DAY = 86_400_000L private const val MILLISECONDS_IN_DAY = 86_400_000L
fun Date.toRelativeString( fun Date.toRelativeString(
context: Context, context: Context,
relative: Boolean = true,
dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT), dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT),
): String { ): String {
if (!relative) {
return dateFormat.format(this)
}
val now = Date() val now = Date()
val difference = now.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY) - this.timeWithOffset.floorNearest( val difference = now.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY) - this.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY)
MILLISECONDS_IN_DAY,
)
val days = difference.floorDiv(MILLISECONDS_IN_DAY).toInt() val days = difference.floorDiv(MILLISECONDS_IN_DAY).toInt()
return when { return when {
difference < 0 -> dateFormat.format(this) difference < 0 -> dateFormat.format(this)

View file

@ -13,6 +13,10 @@
android:id="@+id/move_to_bottom" android:id="@+id/move_to_bottom"
android:title="@string/action_move_to_bottom" /> android:title="@string/action_move_to_bottom" />
<item
android:id="@+id/move_to_bottom_series"
android:title="@string/action_move_to_bottom_all_for_series" />
<item <item
android:id="@+id/cancel_download" android:id="@+id/cancel_download"
android:title="@string/action_cancel" /> android:title="@string/action_cancel" />

View file

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.core package eu.kanade.tachiyomi.core
object Constants { object Constants {
const val URL_HELP = "https://aniyomi.org/help/" const val URL_HELP = "https://aniyomi.org/docs/guides/troubleshooting/"
const val MANGA_EXTRA = "manga" const val MANGA_EXTRA = "manga"

View file

@ -58,6 +58,15 @@ fun Call.asObservable(): Observable<Response> {
} }
} }
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code)
}
}
}
// Based on https://github.com/gildor/kotlin-coroutines-okhttp // Based on https://github.com/gildor/kotlin-coroutines-okhttp
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response { private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
@ -95,6 +104,9 @@ suspend fun Call.await(): Response {
return await(callStack) return await(callStack)
} }
/**
* @since extensions-lib 1.5
*/
suspend fun Call.awaitSuccess(): Response { suspend fun Call.awaitSuccess(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) } val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
val response = await(callStack) val response = await(callStack)
@ -105,15 +117,6 @@ suspend fun Call.awaitSuccess(): Response {
return response return response
} }
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw HttpException(response.code)
}
}
}
fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call { fun OkHttpClient.newCachelessCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder() val progressClient = newBuilder()
.cache(null) .cache(null)

View file

@ -14,7 +14,24 @@ import kotlin.coroutines.resume
object WebViewUtil { object WebViewUtil {
const val SPOOF_PACKAGE_NAME = "org.chromium.chrome" const val SPOOF_PACKAGE_NAME = "org.chromium.chrome"
const val MINIMUM_WEBVIEW_VERSION = 111 const val MINIMUM_WEBVIEW_VERSION = 114
/**
* Uses the WebView's user agent string to create something similar to what Chrome on Android
* would return.
*
* Example of WebView user agent string:
* Mozilla/5.0 (Linux; Android 13; Pixel 7 Build/TQ3A.230901.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/116.0.0.0 Mobile Safari/537.36
*
* Example of Chrome on Android:
* Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.3
*/
fun getInferredUserAgent(context: Context): String {
return WebView(context)
.getDefaultUserAgentString()
.replace("; Android .*?\\)".toRegex(), "; Android 10; K)")
.replace("Version/.* Chrome/".toRegex(), "Chrome/")
}
fun supportsWebView(context: Context): Boolean { fun supportsWebView(context: Context): Boolean {
try { try {

View file

@ -16,18 +16,21 @@ class AnimeSourceSearchPagingSource(
val filters: AnimeFilterList, val filters: AnimeFilterList,
) : AnimeSourcePagingSource(source) { ) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage { override suspend fun requestNextPage(currentPage: Int): AnimesPage {
// Replace with getSearchAnime
return source.fetchSearchAnime(currentPage, query, filters).awaitSingle() return source.fetchSearchAnime(currentPage, query, filters).awaitSingle()
} }
} }
class AnimeSourcePopularPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) { class AnimeSourcePopularPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage { override suspend fun requestNextPage(currentPage: Int): AnimesPage {
// Replace with getPopularAnime
return source.fetchPopularAnime(currentPage).awaitSingle() return source.fetchPopularAnime(currentPage).awaitSingle()
} }
} }
class AnimeSourceLatestPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) { class AnimeSourceLatestPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage { override suspend fun requestNextPage(currentPage: Int): AnimesPage {
// Replace with getLatestUpdates
return source.fetchLatestUpdates(currentPage).awaitSingle() return source.fetchLatestUpdates(currentPage).awaitSingle()
} }
} }

View file

@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.domain.items.chapter.model.NoChaptersException import tachiyomi.domain.items.chapter.model.NoChaptersException
import tachiyomi.domain.source.manga.repository.SourcePagingSourceType import tachiyomi.domain.source.manga.repository.SourcePagingSourceType
@ -14,19 +13,19 @@ class SourceSearchPagingSource(source: CatalogueSource, val query: String, val f
source, source,
) { ) {
override suspend fun requestNextPage(currentPage: Int): MangasPage { override suspend fun requestNextPage(currentPage: Int): MangasPage {
return source.fetchSearchManga(currentPage, query, filters).awaitSingle() return source.getSearchManga(currentPage, query, filters)
} }
} }
class SourcePopularPagingSource(source: CatalogueSource) : SourcePagingSource(source) { class SourcePopularPagingSource(source: CatalogueSource) : SourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): MangasPage { override suspend fun requestNextPage(currentPage: Int): MangasPage {
return source.fetchPopularManga(currentPage).awaitSingle() return source.getPopularManga(currentPage)
} }
} }
class SourceLatestPagingSource(source: CatalogueSource) : SourcePagingSource(source) { class SourceLatestPagingSource(source: CatalogueSource) : SourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): MangasPage { override suspend fun requestNextPage(currentPage: Int): MangasPage {
return source.fetchLatestUpdates(currentPage).awaitSingle() return source.getLatestUpdates(currentPage)
} }
} }

View file

@ -4,16 +4,13 @@ import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.anime.model.AnimeUpdate import tachiyomi.domain.entries.anime.model.AnimeUpdate
import tachiyomi.domain.items.episode.interactor.GetEpisodeByAnimeId import tachiyomi.domain.items.episode.interactor.GetEpisodeByAnimeId
import tachiyomi.domain.items.episode.model.Episode import tachiyomi.domain.items.episode.model.Episode
import uy.kohesive.injekt.api.get
import java.time.Instant import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
const val MAX_FETCH_INTERVAL = 28 class AnimeFetchInterval(
private const val FETCH_INTERVAL_GRACE_PERIOD = 1
class SetAnimeFetchInterval(
private val getEpisodeByAnimeId: GetEpisodeByAnimeId, private val getEpisodeByAnimeId: GetEpisodeByAnimeId,
) { ) {
@ -30,7 +27,7 @@ class SetAnimeFetchInterval(
val episodes = getEpisodeByAnimeId.await(anime.id) val episodes = getEpisodeByAnimeId.await(anime.id)
val interval = anime.fetchInterval.takeIf { it < 0 } ?: calculateInterval( val interval = anime.fetchInterval.takeIf { it < 0 } ?: calculateInterval(
episodes, episodes,
dateTime, dateTime.zone,
) )
val nextUpdate = calculateNextUpdate(anime, interval, dateTime, currentWindow) val nextUpdate = calculateNextUpdate(anime, interval, dateTime, currentWindow)
@ -43,33 +40,34 @@ class SetAnimeFetchInterval(
fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> { fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> {
val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone) val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone)
val lowerBound = today.minusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong()) val lowerBound = today.minusDays(GRACE_PERIOD)
val upperBound = today.plusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong()) val upperBound = today.plusDays(GRACE_PERIOD)
return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1) return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1)
} }
internal fun calculateInterval(episodes: List<Episode>, zonedDateTime: ZonedDateTime): Int { internal fun calculateInterval(episodes: List<Episode>, zone: ZoneId): Int {
val sortedEpisodes = episodes val uploadDates = episodes.asSequence()
.sortedWith(
compareByDescending<Episode> { it.dateUpload }.thenByDescending { it.dateFetch },
)
.take(50)
val uploadDates = sortedEpisodes
.filter { it.dateUpload > 0L } .filter { it.dateUpload > 0L }
.sortedByDescending { it.dateUpload }
.map { .map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zone)
.toLocalDate() .toLocalDate()
.atStartOfDay() .atStartOfDay()
} }
.distinct() .distinct()
val fetchDates = sortedEpisodes .take(10)
.toList()
val fetchDates = episodes.asSequence()
.sortedByDescending { it.dateFetch }
.map { .map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zone)
.toLocalDate() .toLocalDate()
.atStartOfDay() .atStartOfDay()
} }
.distinct() .distinct()
.take(10)
.toList()
val interval = when { val interval = when {
// Enough upload date from source // Enough upload date from source
@ -88,7 +86,7 @@ class SetAnimeFetchInterval(
else -> 7 else -> 7
} }
return interval.coerceIn(1, MAX_FETCH_INTERVAL) return interval.coerceIn(1, MAX_INTERVAL)
} }
private fun calculateNextUpdate( private fun calculateNextUpdate(
@ -119,7 +117,7 @@ class SetAnimeFetchInterval(
} }
private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int): Int { private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int): Int {
if (delta >= MAX_FETCH_INTERVAL) return MAX_FETCH_INTERVAL if (delta >= MAX_INTERVAL) return MAX_INTERVAL
// double delta again if missed more than 9 check in new delta // double delta again if missed more than 9 check in new delta
val cycle = timeSinceLatest.floorDiv(delta) + 1 val cycle = timeSinceLatest.floorDiv(delta) + 1
@ -129,4 +127,10 @@ class SetAnimeFetchInterval(
delta delta
} }
} }
companion object {
const val MAX_INTERVAL = 28
private const val GRACE_PERIOD = 1L
}
} }

View file

@ -4,16 +4,13 @@ import tachiyomi.domain.entries.manga.model.Manga
import tachiyomi.domain.entries.manga.model.MangaUpdate import tachiyomi.domain.entries.manga.model.MangaUpdate
import tachiyomi.domain.items.chapter.interactor.GetChapterByMangaId import tachiyomi.domain.items.chapter.interactor.GetChapterByMangaId
import tachiyomi.domain.items.chapter.model.Chapter import tachiyomi.domain.items.chapter.model.Chapter
import uy.kohesive.injekt.api.get
import java.time.Instant import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
const val MAX_FETCH_INTERVAL = 28 class MangaFetchInterval(
private const val FETCH_INTERVAL_GRACE_PERIOD = 1
class SetMangaFetchInterval(
private val getChapterByMangaId: GetChapterByMangaId, private val getChapterByMangaId: GetChapterByMangaId,
) { ) {
@ -30,7 +27,7 @@ class SetMangaFetchInterval(
val chapters = getChapterByMangaId.await(manga.id) val chapters = getChapterByMangaId.await(manga.id)
val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval( val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval(
chapters, chapters,
dateTime, dateTime.zone,
) )
val nextUpdate = calculateNextUpdate(manga, interval, dateTime, currentWindow) val nextUpdate = calculateNextUpdate(manga, interval, dateTime, currentWindow)
@ -43,33 +40,34 @@ class SetMangaFetchInterval(
fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> { fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> {
val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone) val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone)
val lowerBound = today.minusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong()) val lowerBound = today.minusDays(GRACE_PERIOD)
val upperBound = today.plusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong()) val upperBound = today.plusDays(GRACE_PERIOD)
return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1) return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1)
} }
internal fun calculateInterval(chapters: List<Chapter>, zonedDateTime: ZonedDateTime): Int { internal fun calculateInterval(chapters: List<Chapter>, zone: ZoneId): Int {
val sortedChapters = chapters val uploadDates = chapters.asSequence()
.sortedWith(
compareByDescending<Chapter> { it.dateUpload }.thenByDescending { it.dateFetch },
)
.take(50)
val uploadDates = sortedChapters
.filter { it.dateUpload > 0L } .filter { it.dateUpload > 0L }
.sortedByDescending { it.dateUpload }
.map { .map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone) ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zone)
.toLocalDate() .toLocalDate()
.atStartOfDay() .atStartOfDay()
} }
.distinct() .distinct()
val fetchDates = sortedChapters .take(10)
.toList()
val fetchDates = chapters.asSequence()
.sortedByDescending { it.dateFetch }
.map { .map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone) ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zone)
.toLocalDate() .toLocalDate()
.atStartOfDay() .atStartOfDay()
} }
.distinct() .distinct()
.take(10)
.toList()
val interval = when { val interval = when {
// Enough upload date from source // Enough upload date from source
@ -88,7 +86,7 @@ class SetMangaFetchInterval(
else -> 7 else -> 7
} }
return interval.coerceIn(1, MAX_FETCH_INTERVAL) return interval.coerceIn(1, MAX_INTERVAL)
} }
private fun calculateNextUpdate( private fun calculateNextUpdate(
@ -119,7 +117,7 @@ class SetMangaFetchInterval(
} }
private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int): Int { private fun doubleInterval(delta: Int, timeSinceLatest: Int, doubleWhenOver: Int): Int {
if (delta >= MAX_FETCH_INTERVAL) return MAX_FETCH_INTERVAL if (delta >= MAX_INTERVAL) return MAX_INTERVAL
// double delta again if missed more than 9 check in new delta // double delta again if missed more than 9 check in new delta
val cycle = timeSinceLatest.floorDiv(delta) + 1 val cycle = timeSinceLatest.floorDiv(delta) + 1
@ -129,4 +127,10 @@ class SetMangaFetchInterval(
delta delta
} }
} }
companion object {
const val MAX_INTERVAL = 28
private const val GRACE_PERIOD = 1L
}
} }

View file

@ -14,20 +14,16 @@ class StubAnimeSource(
private val isInvalid: Boolean = name.isBlank() || lang.isBlank() private val isInvalid: Boolean = name.isBlank() || lang.isBlank()
override suspend fun getAnimeDetails(anime: SAnime): SAnime { override suspend fun getAnimeDetails(anime: SAnime): SAnime =
throw AnimeSourceNotInstalledException() throw AnimeSourceNotInstalledException()
}
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> { override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> =
throw AnimeSourceNotInstalledException() throw AnimeSourceNotInstalledException()
}
override suspend fun getVideoList(episode: SEpisode): List<Video> { override suspend fun getVideoList(episode: SEpisode): List<Video> =
throw AnimeSourceNotInstalledException() throw AnimeSourceNotInstalledException()
}
override fun toString(): String { override fun toString(): String =
return if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString() if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString()
}
} }
class AnimeSourceNotInstalledException : Exception() class AnimeSourceNotInstalledException : Exception()

View file

@ -4,7 +4,6 @@ import eu.kanade.tachiyomi.source.MangaSource
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
@Suppress("OverridingDeprecatedMember") @Suppress("OverridingDeprecatedMember")
class StubMangaSource( class StubMangaSource(
@ -15,36 +14,17 @@ class StubMangaSource(
private val isInvalid: Boolean = name.isBlank() || lang.isBlank() private val isInvalid: Boolean = name.isBlank() || lang.isBlank()
override suspend fun getMangaDetails(manga: SManga): SManga { override suspend fun getMangaDetails(manga: SManga): SManga =
throw SourceNotInstalledException() throw SourceNotInstalledException()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails")) override suspend fun getChapterList(manga: SManga): List<SChapter> =
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(SourceNotInstalledException())
}
override suspend fun getChapterList(manga: SManga): List<SChapter> {
throw SourceNotInstalledException() throw SourceNotInstalledException()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList")) override suspend fun getPageList(chapter: SChapter): List<Page> =
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(SourceNotInstalledException())
}
override suspend fun getPageList(chapter: SChapter): List<Page> {
throw SourceNotInstalledException() throw SourceNotInstalledException()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList")) override fun toString(): String =
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString()
return Observable.error(SourceNotInstalledException())
}
override fun toString(): String {
return if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString()
}
} }
class SourceNotInstalledException : Exception() class SourceNotInstalledException : Exception()

View file

@ -0,0 +1,127 @@
package tachiyomi.domain.entries.anime.interactor
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import tachiyomi.domain.items.episode.model.Episode
import java.time.ZoneOffset
import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlin.time.toJavaDuration
@Execution(ExecutionMode.CONCURRENT)
class AnimeFetchIntervalTest {
private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z")
private val testZoneId = ZoneOffset.UTC
private var episode = Episode.create().copy(
dateFetch = testTime.toEpochSecond() * 1000,
dateUpload = testTime.toEpochSecond() * 1000,
)
private val fetchInterval = AnimeFetchInterval(mockk())
@Test
fun `returns default interval of 7 days when not enough distinct days`() {
val episodesWithUploadDate = (1..50).map {
chapterWithTime(episode, 1.days)
}
fetchInterval.calculateInterval(episodesWithUploadDate, testZoneId) shouldBe 7
val episodesWithoutUploadDate = episodesWithUploadDate.map {
it.copy(dateUpload = 0L)
}
fetchInterval.calculateInterval(episodesWithoutUploadDate, testZoneId) shouldBe 7
}
@Test
fun `returns interval based on more recent episodes`() {
val oldEpisodes = (1..5).map {
chapterWithTime(episode, (it * 7).days) // Would have interval of 7 days
}
val newEpisodes = (1..10).map {
chapterWithTime(episode, oldEpisodes.lastUploadDate() + it.days)
}
val episodes = oldEpisodes + newEpisodes
fetchInterval.calculateInterval(episodes, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 7 days when multiple episodes in 1 day`() {
val episodes = (1..10).map {
chapterWithTime(episode, 10.hours)
}
fetchInterval.calculateInterval(episodes, testZoneId) shouldBe 7
}
@Test
fun `returns interval of 7 days when multiple episodes in 2 days`() {
val episodes = (1..2).map {
chapterWithTime(episode, 1.days)
} + (1..5).map {
chapterWithTime(episode, 2.days)
}
fetchInterval.calculateInterval(episodes, testZoneId) shouldBe 7
}
@Test
fun `returns interval of 1 day when episodes are released every 1 day`() {
val episodes = (1..20).map {
chapterWithTime(episode, it.days)
}
fetchInterval.calculateInterval(episodes, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 1 day when delta is less than 1 day`() {
val episodes = (1..20).map {
chapterWithTime(episode, (15 * it).hours)
}
fetchInterval.calculateInterval(episodes, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 2 days when episodes are released every 2 days`() {
val episodes = (1..20).map {
chapterWithTime(episode, (2 * it).days)
}
fetchInterval.calculateInterval(episodes, testZoneId) shouldBe 2
}
@Test
fun `returns interval with floored value when interval is decimal`() {
val episodesWithUploadDate = (1..5).map {
chapterWithTime(episode, (25 * it).hours)
}
fetchInterval.calculateInterval(episodesWithUploadDate, testZoneId) shouldBe 1
val episodesWithoutUploadDate = episodesWithUploadDate.map {
it.copy(dateUpload = 0L)
}
fetchInterval.calculateInterval(episodesWithoutUploadDate, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 1 day when episodes are released just below every 2 days`() {
val episodes = (1..20).map {
chapterWithTime(episode, (43 * it).hours)
}
fetchInterval.calculateInterval(episodes, testZoneId) shouldBe 1
}
private fun chapterWithTime(episode: Episode, duration: Duration): Episode {
val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
return episode.copy(dateFetch = newTime, dateUpload = newTime)
}
private fun List<Episode>.lastUploadDate() =
last().dateUpload.toDuration(DurationUnit.MILLISECONDS)
}

View file

@ -1,104 +0,0 @@
package tachiyomi.domain.entries.anime.interactor
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import tachiyomi.domain.items.episode.model.Episode
import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.toJavaDuration
@Execution(ExecutionMode.CONCURRENT)
class SetAnimeFetchIntervalTest {
private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z")
private var episode = Episode.create().copy(
dateFetch = testTime.toEpochSecond() * 1000,
dateUpload = testTime.toEpochSecond() * 1000,
)
private val setAnimeFetchInterval = SetAnimeFetchInterval(mockk())
@Test
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
val episodes = (1..2).map {
episodeWithTime(episode, 10.hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
}
@Test
fun `calculateInterval returns 7 when 5 episodes in 1 day`() {
val episodes = (1..5).map {
episodeWithTime(episode, 10.hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
}
@Test
fun `calculateInterval returns 7 when 7 episodes in 48 hours, 2 day`() {
val episodes = (1..2).map {
episodeWithTime(episode, 24.hours)
} + (1..5).map {
episodeWithTime(episode, 48.hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 7
}
@Test
fun `calculateInterval returns default of 1 day when interval less than 1`() {
val episodes = (1..5).map {
episodeWithTime(episode, (15 * it).hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
}
// Normal interval calculation
@Test
fun `calculateInterval returns 1 when 5 episodes in 120 hours, 5 days`() {
val episodes = (1..5).map {
episodeWithTime(episode, (24 * it).hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
}
@Test
fun `calculateInterval returns 2 when 5 episodes in 240 hours, 10 days`() {
val episodes = (1..5).map {
episodeWithTime(episode, (48 * it).hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 2
}
@Test
fun `calculateInterval returns floored value when interval is decimal`() {
val episodes = (1..5).map {
episodeWithTime(episode, (25 * it).hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
}
@Test
fun `calculateInterval returns 1 when 5 episodes in 215 hours, 5 days`() {
val episodes = (1..5).map {
episodeWithTime(episode, (43 * it).hours)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
}
@Test
fun `calculateInterval returns interval based on fetch time if upload time not available`() {
val episodes = (1..5).map {
episodeWithTime(episode, (25 * it).hours).copy(dateUpload = 0L)
}
setAnimeFetchInterval.calculateInterval(episodes, testTime) shouldBe 1
}
private fun episodeWithTime(episode: Episode, duration: Duration): Episode {
val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
return episode.copy(dateFetch = newTime, dateUpload = newTime)
}
}

View file

@ -0,0 +1,127 @@
package tachiyomi.domain.entries.manga.interactor
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import tachiyomi.domain.items.chapter.model.Chapter
import java.time.ZoneOffset
import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.DurationUnit
import kotlin.time.toDuration
import kotlin.time.toJavaDuration
@Execution(ExecutionMode.CONCURRENT)
class MangaFetchIntervalTest {
private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z")
private val testZoneId = ZoneOffset.UTC
private var chapter = Chapter.create().copy(
dateFetch = testTime.toEpochSecond() * 1000,
dateUpload = testTime.toEpochSecond() * 1000,
)
private val fetchInterval = MangaFetchInterval(mockk())
@Test
fun `returns default interval of 7 days when not enough distinct days`() {
val chaptersWithUploadDate = (1..50).map {
chapterWithTime(chapter, 1.days)
}
fetchInterval.calculateInterval(chaptersWithUploadDate, testZoneId) shouldBe 7
val chaptersWithoutUploadDate = chaptersWithUploadDate.map {
it.copy(dateUpload = 0L)
}
fetchInterval.calculateInterval(chaptersWithoutUploadDate, testZoneId) shouldBe 7
}
@Test
fun `returns interval based on more recent chapters`() {
val oldChapters = (1..5).map {
chapterWithTime(chapter, (it * 7).days) // Would have interval of 7 days
}
val newChapters = (1..10).map {
chapterWithTime(chapter, oldChapters.lastUploadDate() + it.days)
}
val chapters = oldChapters + newChapters
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 7 days when multiple chapters in 1 day`() {
val chapters = (1..10).map {
chapterWithTime(chapter, 10.hours)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 7
}
@Test
fun `returns interval of 7 days when multiple chapters in 2 days`() {
val chapters = (1..2).map {
chapterWithTime(chapter, 1.days)
} + (1..5).map {
chapterWithTime(chapter, 2.days)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 7
}
@Test
fun `returns interval of 1 day when chapters are released every 1 day`() {
val chapters = (1..20).map {
chapterWithTime(chapter, it.days)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 1 day when delta is less than 1 day`() {
val chapters = (1..20).map {
chapterWithTime(chapter, (15 * it).hours)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 2 days when chapters are released every 2 days`() {
val chapters = (1..20).map {
chapterWithTime(chapter, (2 * it).days)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 2
}
@Test
fun `returns interval with floored value when interval is decimal`() {
val chaptersWithUploadDate = (1..5).map {
chapterWithTime(chapter, (25 * it).hours)
}
fetchInterval.calculateInterval(chaptersWithUploadDate, testZoneId) shouldBe 1
val chaptersWithoutUploadDate = chaptersWithUploadDate.map {
it.copy(dateUpload = 0L)
}
fetchInterval.calculateInterval(chaptersWithoutUploadDate, testZoneId) shouldBe 1
}
@Test
fun `returns interval of 1 day when chapters are released just below every 2 days`() {
val chapters = (1..20).map {
chapterWithTime(chapter, (43 * it).hours)
}
fetchInterval.calculateInterval(chapters, testZoneId) shouldBe 1
}
private fun chapterWithTime(chapter: Chapter, duration: Duration): Chapter {
val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
return chapter.copy(dateFetch = newTime, dateUpload = newTime)
}
private fun List<Chapter>.lastUploadDate() =
last().dateUpload.toDuration(DurationUnit.MILLISECONDS)
}

View file

@ -1,104 +0,0 @@
package tachiyomi.domain.entries.manga.interactor
import io.kotest.matchers.shouldBe
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import tachiyomi.domain.items.chapter.model.Chapter
import java.time.ZonedDateTime
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.toJavaDuration
@Execution(ExecutionMode.CONCURRENT)
class SetMangaFetchIntervalTest {
private val testTime = ZonedDateTime.parse("2020-01-01T00:00:00Z")
private var chapter = Chapter.create().copy(
dateFetch = testTime.toEpochSecond() * 1000,
dateUpload = testTime.toEpochSecond() * 1000,
)
private val setMangaFetchInterval = SetMangaFetchInterval(mockk())
@Test
fun `calculateInterval returns default of 7 days when less than 3 distinct days`() {
val chapters = (1..2).map {
chapterWithTime(chapter, 10.hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
}
@Test
fun `calculateInterval returns 7 when 5 chapters in 1 day`() {
val chapters = (1..5).map {
chapterWithTime(chapter, 10.hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
}
@Test
fun `calculateInterval returns 7 when 7 chapters in 48 hours, 2 day`() {
val chapters = (1..2).map {
chapterWithTime(chapter, 24.hours)
} + (1..5).map {
chapterWithTime(chapter, 48.hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 7
}
@Test
fun `calculateInterval returns default of 1 day when interval less than 1`() {
val chapters = (1..5).map {
chapterWithTime(chapter, (15 * it).hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
}
// Normal interval calculation
@Test
fun `calculateInterval returns 1 when 5 chapters in 120 hours, 5 days`() {
val chapters = (1..5).map {
chapterWithTime(chapter, (24 * it).hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
}
@Test
fun `calculateInterval returns 2 when 5 chapters in 240 hours, 10 days`() {
val chapters = (1..5).map {
chapterWithTime(chapter, (48 * it).hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 2
}
@Test
fun `calculateInterval returns floored value when interval is decimal`() {
val chapters = (1..5).map {
chapterWithTime(chapter, (25 * it).hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
}
@Test
fun `calculateInterval returns 1 when 5 chapters in 215 hours, 5 days`() {
val chapters = (1..5).map {
chapterWithTime(chapter, (43 * it).hours)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
}
@Test
fun `calculateInterval returns interval based on fetch time if upload time not available`() {
val chapters = (1..5).map {
chapterWithTime(chapter, (25 * it).hours).copy(dateUpload = 0L)
}
setMangaFetchInterval.calculateInterval(chapters, testTime) shouldBe 1
}
private fun chapterWithTime(chapter: Chapter, duration: Duration): Chapter {
val newTime = testTime.plus(duration.toJavaDuration()).toEpochSecond() * 1000
return chapter.copy(dateFetch = newTime, dateUpload = newTime)
}
}

View file

@ -28,7 +28,7 @@ guava = "com.google.guava:guava:32.1.2-android"
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" } paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" } paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" }
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-beta05" benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.0-rc01"
test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha01" test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha01"
test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01" test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04" test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04"

View file

@ -1,5 +1,5 @@
[versions] [versions]
compiler = "1.5.2" compiler = "1.5.3"
compose-bom = "2023.09.00-alpha02" compose-bom = "2023.09.00-alpha02"
accompanist = "0.33.1-alpha" accompanist = "0.33.1-alpha"

View file

@ -1,7 +1,7 @@
[versions] [versions]
kotlin_version = "1.9.0" kotlin_version = "1.9.10"
serialization_version = "1.6.0" serialization_version = "1.6.0"
xml_serialization_version = "0.86.1" xml_serialization_version = "0.86.2"
[libraries] [libraries]
reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" } reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin_version" }

View file

@ -1,5 +1,5 @@
[versions] [versions]
aboutlib_version = "10.8.3" aboutlib_version = "10.9.0"
okhttp_version = "5.0.0-alpha.11" okhttp_version = "5.0.0-alpha.11"
shizuku_version = "12.2.0" shizuku_version = "12.2.0"
sqlite = "2.3.1" sqlite = "2.3.1"
@ -56,7 +56,7 @@ flexible-adapter-core = "com.github.arkon.FlexibleAdapter:flexible-adapter:c8013
photoview = "com.github.chrisbanes:PhotoView:2.3.0" photoview = "com.github.chrisbanes:PhotoView:2.3.0"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0" directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
insetter = "dev.chrisbanes.insetter:insetter:0.6.1" insetter = "dev.chrisbanes.insetter:insetter:0.6.1"
compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.0.6" compose-materialmotion = "io.github.fornewid:material-motion-compose-core:1.1.0"
compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0" compose-simpleicons = "br.com.devsrsouza.compose.icons.android:simple-icons:1.0.0"
swipe = "me.saket.swipe:swipe:1.2.0" swipe = "me.saket.swipe:swipe:1.2.0"
@ -83,13 +83,13 @@ sqldelight-gradle = { module = "app.cash.sqldelight:gradle-plugin", version.ref
junit = "org.junit.jupiter:junit-jupiter:5.10.0" junit = "org.junit.jupiter:junit-jupiter:5.10.0"
kotest-assertions = "io.kotest:kotest-assertions-core:5.7.2" kotest-assertions = "io.kotest:kotest-assertions-core:5.7.2"
mockk = "io.mockk:mockk:1.13.7" mockk = "io.mockk:mockk:1.13.8"
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
ktlint = "org.jlleitschuh.gradle:ktlint-gradle:11.5.1" ktlint = "org.jlleitschuh.gradle:ktlint-gradle:11.6.0"
aniyomi-mpv = "com.github.aniyomiorg:aniyomi-mpv-lib:1.10.n" aniyomi-mpv = "com.github.aniyomiorg:aniyomi-mpv-lib:1.10.n"
ffmpeg-kit = "com.github.jmir1:ffmpeg-kit:1.10" ffmpeg-kit = "com.github.jmir1:ffmpeg-kit:1.10"

View file

@ -137,6 +137,7 @@
<string name="action_move_to_top">Move to top</string> <string name="action_move_to_top">Move to top</string>
<string name="action_move_to_top_all_for_series">Move series to top</string> <string name="action_move_to_top_all_for_series">Move series to top</string>
<string name="action_move_to_bottom">Move to bottom</string> <string name="action_move_to_bottom">Move to bottom</string>
<string name="action_move_to_bottom_all_for_series">Move series to bottom</string>
<string name="action_install">Install</string> <string name="action_install">Install</string>
<string name="action_share">Share</string> <string name="action_share">Share</string>
<string name="action_save">Save</string> <string name="action_save">Save</string>
@ -205,6 +206,9 @@
<string name="theme_matrix">Matrix</string> <string name="theme_matrix">Matrix</string>
<string name="theme_tidalwave">Tidal Wave</string> <string name="theme_tidalwave">Tidal Wave</string>
<string name="pref_dark_theme_pure_black">Pure black dark mode</string> <string name="pref_dark_theme_pure_black">Pure black dark mode</string>
<string name="pref_relative_format">Relative timestamps</string>
<!-- "Today" instead of "2023-12-31" -->
<string name="pref_relative_format_summary">\"%1$s\" instead of \"%2$s\"</string>
<string name="pref_date_format">Date format</string> <string name="pref_date_format">Date format</string>
<string name="pref_manage_notifications">Manage notifications</string> <string name="pref_manage_notifications">Manage notifications</string>
@ -401,8 +405,8 @@
<string name="double_tap_anim_speed_0">No animation</string> <string name="double_tap_anim_speed_0">No animation</string>
<string name="double_tap_anim_speed_normal">Normal</string> <string name="double_tap_anim_speed_normal">Normal</string>
<string name="double_tap_anim_speed_fast">Fast</string> <string name="double_tap_anim_speed_fast">Fast</string>
<string name="pref_rotation_type">Default rotation type</string> <string name="pref_rotation_type">Default rotation</string>
<string name="rotation_type">Rotation type</string> <string name="rotation_type">Rotation</string>
<string name="rotation_free">Free</string> <string name="rotation_free">Free</string>
<string name="rotation_portrait">Portrait</string> <string name="rotation_portrait">Portrait</string>
<string name="rotation_reverse_portrait">Reverse portrait</string> <string name="rotation_reverse_portrait">Reverse portrait</string>
@ -451,13 +455,13 @@
<!-- Tracking section --> <!-- Tracking section -->
<string name="tracking_guide">Tracking guide</string> <string name="tracking_guide">Tracking guide</string>
<string name="services">Services</string> <string name="services">Trackers</string>
<string name="tracking_info">One-way sync to update the chapter progress in tracking services. Set up tracking for individual entries from their tracking button.</string> <string name="tracking_info">One-way sync to update the chapter progress in external tracker services. Set up tracking for individual entries from their tracking button.</string>
<string name="enhanced_services">Enhanced services</string> <string name="enhanced_services">Enhanced trackers</string>
<string name="enhanced_services_not_installed">Available but source not installed: %s</string> <string name="enhanced_services_not_installed">Available but source not installed: %s</string>
<string name="enhanced_tracking_info">Services that provide enhanced features for specific sources. Entries are automatically tracked when added to your library.</string> <string name="enhanced_tracking_info">Provides enhanced features for specific sources. Entries are automatically tracked when added to your library.</string>
<string name="action_track">Track</string> <string name="action_track">Track</string>
<string name="track_activity_name">Tracking login</string> <string name="track_activity_name">Tracker login</string>
<!-- Browse section --> <!-- Browse section -->
<string name="pref_hide_in_library_items">Hide entries already in library</string> <string name="pref_hide_in_library_items">Hide entries already in library</string>

View file

@ -15,6 +15,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ContentAlpha import androidx.compose.material.ContentAlpha
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -54,6 +57,7 @@ import kotlinx.coroutines.delay
import tachiyomi.core.preference.Preference import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.TriState import tachiyomi.core.preference.TriState
import tachiyomi.core.preference.toggle import tachiyomi.core.preference.toggle
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.theme.header import tachiyomi.presentation.core.theme.header
import tachiyomi.presentation.core.util.collectAsState import tachiyomi.presentation.core.util.collectAsState
@ -297,7 +301,7 @@ fun TriStateItem(
vertical = SettingsItemsPaddings.Vertical, vertical = SettingsItemsPaddings.Vertical,
), ),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.large),
) { ) {
val stateAlpha = if (enabled && onClick != null) 1f else ContentAlpha.disabled val stateAlpha = if (enabled && onClick != null) 1f else ContentAlpha.disabled
@ -504,7 +508,25 @@ fun SettingsChipRow(
end = SettingsItemsPaddings.Horizontal, end = SettingsItemsPaddings.Horizontal,
bottom = SettingsItemsPaddings.Vertical, bottom = SettingsItemsPaddings.Vertical,
), ),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
content = content,
)
}
}
@Composable
fun SettingsIconGrid(@StringRes labelRes: Int, content: LazyGridScope.() -> Unit) {
Column {
HeadingItem(labelRes)
LazyVerticalGrid(
columns = GridCells.Adaptive(128.dp),
modifier = Modifier.padding(
start = SettingsItemsPaddings.Horizontal,
end = SettingsItemsPaddings.Horizontal,
bottom = SettingsItemsPaddings.Vertical,
),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.tiny),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
content = content, content = content,
) )
} }

View file

@ -0,0 +1,52 @@
package tachiyomi.presentation.core.components.material
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
fun IconToggleButton(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
imageVector: ImageVector,
title: String,
) {
FilledIconToggleButton(
checked = checked,
onCheckedChange = onCheckedChange,
modifier = modifier
.height(48.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.padding.small),
) {
Icon(
imageVector = imageVector,
contentDescription = null,
)
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage import eu.kanade.tachiyomi.animesource.model.AnimesPage
import rx.Observable import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
interface AnimeCatalogueSource : AnimeSource { interface AnimeCatalogueSource : AnimeSource {
@ -17,30 +18,63 @@ interface AnimeCatalogueSource : AnimeSource {
val supportsLatest: Boolean val supportsLatest: Boolean
/** /**
* Returns an observable containing a page with a list of anime. * Get a page with a list of anime.
* *
* @since extensions-lib 1.5
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchPopularAnime(page: Int): Observable<AnimesPage> @Suppress("DEPRECATION")
suspend fun getPopularAnime(page: Int): AnimesPage {
return fetchPopularAnime(page).awaitSingle()
}
/** /**
* Returns an observable containing a page with a list of anime. * Get a page with a list of anime.
* *
* @since extensions-lib 1.5
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> @Suppress("DEPRECATION")
suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
return fetchSearchAnime(page, query, filters).awaitSingle()
}
/** /**
* Returns an observable containing a page with a list of latest anime updates. * Get a page with a list of latest anime updates.
* *
* @since extensions-lib 1.5
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchLatestUpdates(page: Int): Observable<AnimesPage> @Suppress("DEPRECATION")
suspend fun getLatestUpdates(page: Int): AnimesPage {
return fetchLatestUpdates(page).awaitSingle()
}
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
fun getFilterList(): AnimeFilterList fun getFilterList(): AnimeFilterList
// Should be replaced as soon as Anime Extension reach 1.5
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getPopularAnime"),
)
fun fetchPopularAnime(page: Int): Observable<AnimesPage>
// Should be replaced as soon as Anime Extension reach 1.5
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getSearchAnime"),
)
fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage>
// Should be replaced as soon as Anime Extension reach 1.5
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getLatestUpdates"),
)
fun fetchLatestUpdates(page: Int): Observable<AnimesPage>
} }

View file

@ -27,7 +27,7 @@ interface AnimeSource {
/** /**
* Get the updated details for a anime. * Get the updated details for a anime.
* *
* @since extensions-lib 1.4 * @since extensions-lib 1.5
* @param anime the anime to update. * @param anime the anime to update.
* @return the updated anime. * @return the updated anime.
*/ */
@ -39,7 +39,7 @@ interface AnimeSource {
/** /**
* Get all the available episodes for a anime. * Get all the available episodes for a anime.
* *
* @since extensions-lib 1.4 * @since extensions-lib 1.5
* @param anime the anime to update. * @param anime the anime to update.
* @return the episodes for the anime. * @return the episodes for the anime.
*/ */
@ -52,7 +52,7 @@ interface AnimeSource {
* Get the list of videos a episode has. Pages should be returned * Get the list of videos a episode has. Pages should be returned
* in the expected order; the index is ignored. * in the expected order; the index is ignored.
* *
* @since extensions-lib 1.4 * @since extensions-lib 1.5
* @param episode the episode. * @param episode the episode.
* @return the videos for the episode. * @return the videos for the episode.
*/ */
@ -61,41 +61,24 @@ interface AnimeSource {
return fetchVideoList(episode).awaitSingle() return fetchVideoList(episode).awaitSingle()
} }
/**
* Returns an observable with the updated details for a anime.
*
* @param anime the anime to update.
*/
@Deprecated( @Deprecated(
"Use the non-RxJava API instead", "Use the non-RxJava API instead",
ReplaceWith("getAnimeDetails"), ReplaceWith("getAnimeDetails"),
) )
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException( fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> =
"Not used", throw IllegalStateException("Not used")
)
/**
* Returns an observable with all the available episodes for a anime.
*
* @param anime the anime to update.
*/
@Deprecated( @Deprecated(
"Use the non-RxJava API instead", "Use the non-RxJava API instead",
ReplaceWith("getEpisodeList"), ReplaceWith("getEpisodeList"),
) )
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException( fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> =
"Not used", throw IllegalStateException("Not used")
)
/**
* Returns an observable with the list of videos a episode has. Videos should be returned
* in the expected order; the index is ignored.
*
* @param episode the episode.
*/
@Deprecated( @Deprecated(
"Use the non-RxJava API instead", "Use the non-RxJava API instead",
ReplaceWith("getVideoList"), ReplaceWith("getVideoList"),
) )
fun fetchVideoList(episode: SEpisode): Observable<List<Video>> = Observable.empty() fun fetchVideoList(episode: SEpisode): Observable<List<Video>> =
throw IllegalStateException("Not used")
} }

View file

@ -1,8 +1,21 @@
package eu.kanade.tachiyomi.animesource package eu.kanade.tachiyomi.animesource
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import eu.kanade.tachiyomi.PreferenceScreen import eu.kanade.tachiyomi.PreferenceScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
interface ConfigurableAnimeSource : AnimeSource { interface ConfigurableAnimeSource : AnimeSource {
/**
* Gets instance of [SharedPreferences] scoped to the specific source.
*
* @since extensions-lib 1.5
*/
fun getPreferences(): SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
fun setupPreferenceScreen(screen: PreferenceScreen) fun setupPreferenceScreen(screen: PreferenceScreen)
} }

View file

@ -7,30 +7,25 @@ import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
/** /**
* A simple implementation for sources from a website. * A simple implementation for sources from a website.
*/ */
@Suppress("unused")
abstract class AnimeHttpSource : AnimeCatalogueSource { abstract class AnimeHttpSource : AnimeCatalogueSource {
/** /**
@ -88,6 +83,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* @param versionId [Int] the version ID of the source * @param versionId [Int] the version ID of the source
* @return a unique ID for the source * @return a unique ID for the source
*/ */
@Suppress("MemberVisibilityCanBePrivate")
protected fun generateId(name: String, lang: String, versionId: Int): Long { protected fun generateId(name: String, lang: String, versionId: Int): Long {
val key = "${name.lowercase()}/$lang/$versionId" val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
@ -201,11 +197,18 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
protected abstract fun latestUpdatesParse(response: Response): AnimesPage protected abstract fun latestUpdatesParse(response: Response): AnimesPage
/** /**
* Returns an observable with the updated details for a nanime. Normally it's not needed to * Get the updated details for a anime.
* override this method. * Normally it's not needed to override this method.
* *
* @param anime the anime to be updated. * @param anime the anime to be updated.
* @return the updated anime.
*/ */
@Suppress("DEPRECATION")
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
return fetchAnimeDetails(anime).awaitSingle()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getAnimeDetails"))
override fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> { override fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> {
return client.newCall(animeDetailsRequest(anime)) return client.newCall(animeDetailsRequest(anime))
.asObservableSuccess() .asObservableSuccess()
@ -232,11 +235,23 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
protected abstract fun animeDetailsParse(response: Response): SAnime protected abstract fun animeDetailsParse(response: Response): SAnime
/** /**
* Returns an observable with the updated episode list for an anime. Normally it's not needed to * Get all the available episodes for an anime.
* override this method. If an anime is licensed an empty episode list observable is returned * Normally it's not needed to override this method.
* *
* @param anime the anime to look for episodes. * @param anime the anime to update.
* @return the chapters for the manga.
* @throws LicensedEntryItemsException if a anime is licensed and therefore no episodes are available.
*/ */
/*@Suppress("DEPRECATION")
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
if (anime.status == SAnime.LICENSED) {
throw LicensedEntryItemsException()
}
return fetchEpisodeList(anime).awaitSingle()
}*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getEpisodeList"))
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> { override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
return if (anime.status != SAnime.LICENSED) { return if (anime.status != SAnime.LICENSED) {
client.newCall(episodeListRequest(anime)) client.newCall(episodeListRequest(anime))
@ -267,10 +282,18 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
protected abstract fun episodeListParse(response: Response): List<SEpisode> protected abstract fun episodeListParse(response: Response): List<SEpisode>
/** /**
* Returns an observable with the page list for a chapter. * Get the list of videos a episode has. Videos should be returned
* in the expected order; the index is ignored.
* *
* @param episode the episode whose video list has to be fetched. * @param episode the episode.
* @return the videos for the episode.
*/ */
/*@Suppress("DEPRECATION")
override suspend fun getVideoList(episode: SEpisode): List<Video> {
return fetchVideoList(episode).awaitSingle()
}*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getVideoList"))
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> { override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
return client.newCall(videoListRequest(episode)) return client.newCall(videoListRequest(episode))
.asObservableSuccess() .asObservableSuccess()
@ -307,8 +330,15 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* Returns an observable with the page containing the source url of the image. If there's any * Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception. * error, it will return null instead of throwing an exception.
* *
* @since extensions-lib 1.5
* @param video the video whose source image has to be fetched. * @param video the video whose source image has to be fetched.
*/ */
/*@Suppress("DEPRECATION")
open suspend fun getVideoUrl(video: Video): String {
return fetchVideoUrl(video).awaitSingle()
}*/
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getVideoUrl"))
open fun fetchVideoUrl(video: Video): Observable<String> { open fun fetchVideoUrl(video: Video): Observable<String> {
return client.newCall(videoUrlRequest(video)) return client.newCall(videoUrlRequest(video))
.asObservableSuccess() .asObservableSuccess()
@ -333,36 +363,15 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
protected abstract fun videoUrlParse(response: Response): String protected abstract fun videoUrlParse(response: Response): String
/** /**
* Returns an observable with the response of the source image. * Returns the response of the source video.
* Typically does not need to be overridden.
* *
* @param video the page whose source image has to be downloaded. * @since extensions-lib 1.5
* @param video the video whose source video has to be downloaded.
*/ */
suspend fun fetchVideo(video: Video): Response { open suspend fun getVideo(video: Video): Response {
val animeDownloadClient = client.newBuilder() return client.newCachelessCallWithProgress(videoRequest(video), video)
.callTimeout(30, TimeUnit.MINUTES) .awaitSuccess()
.build()
return suspendCoroutine { continuation ->
animeDownloadClient.newCachelessCallWithProgress(
videoRequest(video, video.totalBytesDownloaded),
video,
)
.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
if (response.isSuccessful) {
continuation.resume(response)
} else {
continuation.resumeWithException(HttpException(response.code))
}
}
},
)
}
} }
/** /**
@ -440,7 +449,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* @param episode the episode * @param episode the episode
* @return url of the episode * @return url of the episode
*/ */
open fun getChapterUrl(episode: SEpisode): String { open fun getEpisodeUrl(episode: SEpisode): String {
return episode.url.toString() return episode.url.toString()
} }

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import rx.Observable import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
interface CatalogueSource : MangaSource { interface CatalogueSource : MangaSource {
@ -17,30 +18,63 @@ interface CatalogueSource : MangaSource {
val supportsLatest: Boolean val supportsLatest: Boolean
/** /**
* Returns an observable containing a page with a list of manga. * Get a page with a list of manga.
* *
* @since extensions-lib 1.5
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchPopularManga(page: Int): Observable<MangasPage> @Suppress("DEPRECATION")
suspend fun getPopularManga(page: Int): MangasPage {
return fetchPopularManga(page).awaitSingle()
}
/** /**
* Returns an observable containing a page with a list of manga. * Get a page with a list of manga.
* *
* @since extensions-lib 1.5
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> @Suppress("DEPRECATION")
suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
return fetchSearchManga(page, query, filters).awaitSingle()
}
/** /**
* Returns an observable containing a page with a list of latest manga updates. * Get a page with a list of latest manga updates.
* *
* @since extensions-lib 1.5
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchLatestUpdates(page: Int): Observable<MangasPage> @Suppress("DEPRECATION")
suspend fun getLatestUpdates(page: Int): MangasPage {
return fetchLatestUpdates(page).awaitSingle()
}
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
fun getFilterList(): FilterList fun getFilterList(): FilterList
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getPopularManga"),
)
fun fetchPopularManga(page: Int): Observable<MangasPage> =
throw IllegalStateException("Not used")
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getSearchManga"),
)
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
throw IllegalStateException("Not used")
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getLatestUpdates"),
)
fun fetchLatestUpdates(page: Int): Observable<MangasPage> =
throw IllegalStateException("Not used")
} }

View file

@ -1,8 +1,21 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import eu.kanade.tachiyomi.PreferenceScreen import eu.kanade.tachiyomi.PreferenceScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
interface ConfigurableSource : MangaSource { interface ConfigurableSource : MangaSource {
/**
* Gets instance of [SharedPreferences] scoped to the specific source.
*
* @since extensions-lib 1.5
*/
fun getPreferences(): SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
fun setupPreferenceScreen(screen: PreferenceScreen) fun setupPreferenceScreen(screen: PreferenceScreen)
} }

View file

@ -27,7 +27,7 @@ interface MangaSource {
/** /**
* Get the updated details for a manga. * Get the updated details for a manga.
* *
* @since extensions-lib 1.4 * @since extensions-lib 1.5
* @param manga the manga to update. * @param manga the manga to update.
* @return the updated manga. * @return the updated manga.
*/ */
@ -39,7 +39,7 @@ interface MangaSource {
/** /**
* Get all the available chapters for a manga. * Get all the available chapters for a manga.
* *
* @since extensions-lib 1.4 * @since extensions-lib 1.5
* @param manga the manga to update. * @param manga the manga to update.
* @return the chapters for the manga. * @return the chapters for the manga.
*/ */
@ -52,7 +52,7 @@ interface MangaSource {
* Get the list of pages a chapter has. Pages should be returned * Get the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored. * in the expected order; the index is ignored.
* *
* @since extensions-lib 1.4 * @since extensions-lib 1.5
* @param chapter the chapter. * @param chapter the chapter.
* @return the pages for the chapter. * @return the pages for the chapter.
*/ */
@ -61,41 +61,24 @@ interface MangaSource {
return fetchPageList(chapter).awaitSingle() return fetchPageList(chapter).awaitSingle()
} }
/**
* Returns an observable with the updated details for a manga.
*
* @param manga the manga to update.
*/
@Deprecated( @Deprecated(
"Use the non-RxJava API instead", "Use the non-RxJava API instead",
ReplaceWith("getMangaDetails"), ReplaceWith("getMangaDetails"),
) )
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException( fun fetchMangaDetails(manga: SManga): Observable<SManga> =
"Not used", throw IllegalStateException("Not used")
)
/**
* Returns an observable with all the available chapters for a manga.
*
* @param manga the manga to update.
*/
@Deprecated( @Deprecated(
"Use the non-RxJava API instead", "Use the non-RxJava API instead",
ReplaceWith("getChapterList"), ReplaceWith("getChapterList"),
) )
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException( fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
"Not used", throw IllegalStateException("Not used")
)
/**
* Returns an observable with the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored.
*
* @param chapter the chapter.
*/
@Deprecated( @Deprecated(
"Use the non-RxJava API instead", "Use the non-RxJava API instead",
ReplaceWith("getPageList"), ReplaceWith("getPageList"),
) )
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty() fun fetchPageList(chapter: SChapter): Observable<List<Page>> =
throw IllegalStateException("Not used")
} }

View file

@ -16,6 +16,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
@ -24,6 +25,7 @@ import java.security.MessageDigest
/** /**
* A simple implementation for sources from a website. * A simple implementation for sources from a website.
*/ */
@Suppress("unused")
abstract class HttpSource : CatalogueSource { abstract class HttpSource : CatalogueSource {
/** /**
@ -81,6 +83,7 @@ abstract class HttpSource : CatalogueSource {
* @param versionId [Int] the version ID of the source * @param versionId [Int] the version ID of the source
* @return a unique ID for the source * @return a unique ID for the source
*/ */
@Suppress("MemberVisibilityCanBePrivate")
protected fun generateId(name: String, lang: String, versionId: Int): Long { protected fun generateId(name: String, lang: String, versionId: Int): Long {
val key = "${name.lowercase()}/$lang/$versionId" val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
@ -194,11 +197,18 @@ abstract class HttpSource : CatalogueSource {
protected abstract fun latestUpdatesParse(response: Response): MangasPage protected abstract fun latestUpdatesParse(response: Response): MangasPage
/** /**
* Returns an observable with the updated details for a manga. Normally it's not needed to * Get the updated details for a manga.
* override this method. * Normally it's not needed to override this method.
* *
* @param manga the manga to be updated. * @param manga the manga to be updated.
* @return the updated manga.
*/ */
@Suppress("DEPRECATION")
override suspend fun getMangaDetails(manga: SManga): SManga {
return fetchMangaDetails(manga).awaitSingle()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga)) return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess() .asObservableSuccess()
@ -225,11 +235,23 @@ abstract class HttpSource : CatalogueSource {
protected abstract fun mangaDetailsParse(response: Response): SManga protected abstract fun mangaDetailsParse(response: Response): SManga
/** /**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to * Get all the available chapters for a manga.
* override this method. If a manga is licensed an empty chapter list observable is returned * Normally it's not needed to override this method.
* *
* @param manga the manga to look for chapters. * @param manga the manga to update.
* @return the chapters for the manga.
* @throws LicensedEntryItemsException if a manga is licensed and therefore no chapters are available.
*/ */
@Suppress("DEPRECATION")
override suspend fun getChapterList(manga: SManga): List<SChapter> {
if (manga.status == SManga.LICENSED) {
throw LicensedEntryItemsException()
}
return fetchChapterList(manga).awaitSingle()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return if (manga.status != SManga.LICENSED) { return if (manga.status != SManga.LICENSED) {
client.newCall(chapterListRequest(manga)) client.newCall(chapterListRequest(manga))
@ -260,10 +282,18 @@ abstract class HttpSource : CatalogueSource {
protected abstract fun chapterListParse(response: Response): List<SChapter> protected abstract fun chapterListParse(response: Response): List<SChapter>
/** /**
* Returns an observable with the page list for a chapter. * Get the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored.
* *
* @param chapter the chapter whose page list has to be fetched. * @param chapter the chapter.
* @return the pages for the chapter.
*/ */
@Suppress("DEPRECATION")
override suspend fun getPageList(chapter: SChapter): List<Page> {
return fetchPageList(chapter).awaitSingle()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter)) return client.newCall(pageListRequest(chapter))
.asObservableSuccess() .asObservableSuccess()
@ -293,8 +323,15 @@ abstract class HttpSource : CatalogueSource {
* Returns an observable with the page containing the source url of the image. If there's any * Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception. * error, it will return null instead of throwing an exception.
* *
* @since extensions-lib 1.5
* @param page the page whose source image has to be fetched. * @param page the page whose source image has to be fetched.
*/ */
@Suppress("DEPRECATION")
open suspend fun getImageUrl(page: Page): String {
return fetchImageUrl(page).awaitSingle()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getImageUrl"))
open fun fetchImageUrl(page: Page): Observable<String> { open fun fetchImageUrl(page: Page): Observable<String> {
return client.newCall(imageUrlRequest(page)) return client.newCall(imageUrlRequest(page))
.asObservableSuccess() .asObservableSuccess()
@ -318,24 +355,14 @@ abstract class HttpSource : CatalogueSource {
*/ */
protected abstract fun imageUrlParse(response: Response): String protected abstract fun imageUrlParse(response: Response): String
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
fun fetchImage(page: Page): Observable<Response> {
// images will be cached or saved manually, so don't take up network cache
return client.newCachelessCallWithProgress(imageRequest(page), page)
.asObservableSuccess()
}
/** /**
* Returns the response of the source image. * Returns the response of the source image.
* Typically does not need to be overridden.
* *
* @since extensions-lib 1.5
* @param page the page whose source image has to be downloaded. * @param page the page whose source image has to be downloaded.
*/ */
open suspend fun getImage(page: Page): Response { open suspend fun getImage(page: Page): Response {
// images will be cached or saved manually, so don't take up network cache
return client.newCachelessCallWithProgress(imageRequest(page), page) return client.newCachelessCallWithProgress(imageRequest(page), page)
.awaitSuccess() .awaitSuccess()
} }

View file

@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages)
.filter { !it.imageUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
}
private fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() }
.concatMap { getImageUrl(it) }
}
private fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.State.LOAD_PAGE
return fetchImageUrl(page)
.doOnError { page.status = Page.State.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}

View file

@ -56,11 +56,11 @@ actual class LocalAnimeSource(
override val supportsLatest = true override val supportsLatest = true
// Browse related // Browse related
override fun fetchPopularAnime(page: Int) = fetchSearchAnime(page, "", POPULAR_FILTERS) override suspend fun getPopularAnime(page: Int) = getSearchAnime(page, "", POPULAR_FILTERS)
override fun fetchLatestUpdates(page: Int) = fetchSearchAnime(page, "", LATEST_FILTERS) override suspend fun getLatestUpdates(page: Int) = getSearchAnime(page, "", LATEST_FILTERS)
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> { override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
val baseDirsFiles = fileSystem.getFilesInBaseDirectories() val baseDirsFiles = fileSystem.getFilesInBaseDirectories()
val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L } val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L }
@ -132,6 +132,85 @@ actual class LocalAnimeSource(
} }
} }
return AnimesPage(animes.toList(), false)
}
// Old fetch functions
// TODO: Should be replaced when Anime Extensions get to 1.15
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPopularAnime"))
override fun fetchPopularAnime(page: Int) = fetchSearchAnime(page, "", POPULAR_FILTERS)
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getLatestUpdates"))
override fun fetchLatestUpdates(page: Int) = fetchSearchAnime(page, "", LATEST_FILTERS)
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getSearchAnime"))
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
val baseDirsFiles = fileSystem.getFilesInBaseDirectories()
val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L }
var animeDirs = baseDirsFiles
// Filter out files that are hidden and is not a folder
.filter { it.isDirectory && !it.name.startsWith('.') }
.distinctBy { it.name }
.filter { // Filter by query or last modified
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
it.lastModified() >= lastModifiedLimit
}
}
filters.forEach { filter ->
when (filter) {
is AnimeOrderBy.Popular -> {
animeDirs = if (filter.state!!.ascending) {
animeDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
animeDirs.sortedWith(
compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name },
)
}
}
is AnimeOrderBy.Latest -> {
animeDirs = if (filter.state!!.ascending) {
animeDirs.sortedBy(File::lastModified)
} else {
animeDirs.sortedByDescending(File::lastModified)
}
}
else -> {
/* Do nothing */
}
}
}
// Transform animeDirs to list of SAnime
val animes = animeDirs.map { animeDir ->
SAnime.create().apply {
title = animeDir.name
url = animeDir.name
// Try to find the cover
coverManager.find(animeDir.name)
?.takeIf(File::exists)
?.let { thumbnail_url = it.absolutePath }
}
}
// Fetch episodes of all the anime
animes.forEach { anime ->
runBlocking {
val episodes = getEpisodeList(anime)
if (episodes.isNotEmpty()) {
val episode = episodes.last()
// Copy the cover from the first episode found if not available
if (anime.thumbnail_url == null) {
try {
updateCoverFromVideo(episode, anime)
} catch (e: Exception) {
logcat(LogPriority.ERROR) { "Couldn't extract thumbnail from video." }
}
}
}
}
}
return Observable.just(AnimesPage(animes.toList(), false)) return Observable.just(AnimesPage(animes.toList(), false))
} }

View file

@ -16,7 +16,6 @@ import kotlinx.serialization.json.decodeFromStream
import logcat.LogPriority import logcat.LogPriority
import nl.adaptivity.xmlutil.AndroidXmlReader import nl.adaptivity.xmlutil.AndroidXmlReader
import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XML
import rx.Observable
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.metadata.comicinfo.copyFromComicInfo import tachiyomi.core.metadata.comicinfo.copyFromComicInfo
@ -70,11 +69,11 @@ actual class LocalMangaSource(
override val supportsLatest: Boolean = true override val supportsLatest: Boolean = true
// Browse related // Browse related
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS) override suspend fun getPopularManga(page: Int) = getSearchManga(page, "", POPULAR_FILTERS)
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS) override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", LATEST_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
val baseDirsFiles = fileSystem.getFilesInBaseDirectories() val baseDirsFiles = fileSystem.getFilesInBaseDirectories()
val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L } val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L }
@ -150,7 +149,7 @@ actual class LocalMangaSource(
} }
} }
return Observable.just(MangasPage(mangas.toList(), false)) return MangasPage(mangas.toList(), false)
} }
// Manga details related // Manga details related