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:
- To the latest version of the app (stable is v0.12.3.10)
- 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
- 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

View file

@ -4,7 +4,7 @@ contact_links:
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
- name: 📦 Aniyomi extensions
url: https://aniyomi.org/extensions
url: https://aniyomi.org/extensions/
about: Anime extensions and sources
- name: 🧑‍💻 Aniyomi help discord
url: https://discord.gg/F32UjdJZrR

View file

@ -95,7 +95,7 @@ body:
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).
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
- label: I have updated the app to version **[0.12.3.10](https://github.com/aniyomiorg/aniyomi/releases/latest)**.
required: true

View file

@ -120,3 +120,14 @@ jobs:
prerelease: false
env:
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.*",
"ignoreCase": true,
"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

View file

@ -30,7 +30,7 @@ Before you start, please note that the ability to use following technologies is
# 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

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>
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)
</details>

View file

@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.OkHttpClient
import okhttp3.Response
import rx.Observable
import tachiyomi.core.preference.Preference
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 {
val imageUrl = page.imageUrl ?: return getImage(page)
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.UpdateMangaCategory
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.GetAnimeFavorites
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.ResetAnimeViewerFlags
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.manga.interactor.GetDuplicateLibraryManga
import tachiyomi.domain.entries.manga.interactor.GetLibraryManga
import tachiyomi.domain.entries.manga.interactor.GetManga
import tachiyomi.domain.entries.manga.interactor.GetMangaFavorites
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.ResetMangaViewerFlags
import tachiyomi.domain.entries.manga.interactor.SetMangaChapterFlags
import tachiyomi.domain.entries.manga.interactor.SetMangaFetchInterval
import tachiyomi.domain.entries.manga.repository.MangaRepository
import tachiyomi.domain.history.anime.interactor.GetAnimeHistory
import tachiyomi.domain.history.anime.interactor.GetNextEpisodes
@ -188,7 +188,7 @@ class DomainModule : InjektModule {
addFactory { GetNextEpisodes(get(), get(), get()) }
addFactory { ResetAnimeViewerFlags(get()) }
addFactory { SetAnimeEpisodeFlags(get()) }
addFactory { SetAnimeFetchInterval(get()) }
addFactory { AnimeFetchInterval(get()) }
addFactory { SetAnimeDefaultEpisodeFlags(get(), get(), get()) }
addFactory { SetAnimeViewerFlags(get()) }
addFactory { NetworkToLocalAnime(get()) }
@ -204,7 +204,7 @@ class DomainModule : InjektModule {
addFactory { GetNextChapters(get(), get(), get()) }
addFactory { ResetMangaViewerFlags(get()) }
addFactory { SetMangaChapterFlags(get()) }
addFactory { SetMangaFetchInterval(get()) }
addFactory { MangaFetchInterval(get()) }
addFactory {
SetMangaDefaultChapterFlags(
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.tachiyomi.animesource.model.SAnime
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.AnimeUpdate
import tachiyomi.domain.entries.anime.repository.AnimeRepository
@ -15,7 +15,7 @@ import java.util.Date
class UpdateAnime(
private val animeRepository: AnimeRepository,
private val setAnimeFetchInterval: SetAnimeFetchInterval,
private val animeFetchInterval: AnimeFetchInterval,
) {
suspend fun await(animeUpdate: AnimeUpdate): Boolean {
@ -79,9 +79,9 @@ class UpdateAnime(
suspend fun awaitUpdateFetchInterval(
anime: Anime,
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setAnimeFetchInterval.getWindow(dateTime),
window: Pair<Long, Long> = animeFetchInterval.getWindow(dateTime),
): Boolean {
return setAnimeFetchInterval.toAnimeUpdateOrNull(anime, dateTime, window)
return animeFetchInterval.toAnimeUpdateOrNull(anime, dateTime, window)
?.let { animeRepository.updateAnime(it) }
?: 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.tachiyomi.data.cache.MangaCoverCache
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.MangaUpdate
import tachiyomi.domain.entries.manga.repository.MangaRepository
@ -15,7 +15,7 @@ import java.util.Date
class UpdateManga(
private val mangaRepository: MangaRepository,
private val setMangaFetchInterval: SetMangaFetchInterval,
private val mangaFetchInterval: MangaFetchInterval,
) {
suspend fun await(mangaUpdate: MangaUpdate): Boolean {
@ -79,9 +79,9 @@ class UpdateManga(
suspend fun awaitUpdateFetchInterval(
manga: Manga,
dateTime: ZonedDateTime = ZonedDateTime.now(),
window: Pair<Long, Long> = setMangaFetchInterval.getWindow(dateTime),
window: Pair<Long, Long> = mangaFetchInterval.getWindow(dateTime),
): Boolean {
return setMangaFetchInterval.toMangaUpdateOrNull(manga, dateTime, window)
return mangaFetchInterval.toMangaUpdateOrNull(manga, dateTime, window)
?.let { mangaRepository.updateManga(it) }
?: false
}

View file

@ -28,7 +28,7 @@ class UiPreferences(
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", "")

View file

@ -13,13 +13,18 @@ import java.util.Date
fun RelativeDateHeader(
modifier: Modifier = Modifier,
date: Date,
relativeTime: Boolean,
dateFormat: DateFormat,
) {
val context = LocalContext.current
ListGroupHeader(
modifier = modifier,
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
Button(
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],
onLongClick = { onLongClickItem(4) },
onClick = onMarkPreviousAsViewedClicked,

View file

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

View file

@ -91,6 +91,7 @@ fun MangaScreen(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
fetchInterval: Int?,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
isTabletUi: Boolean,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -151,6 +152,7 @@ fun MangaScreen(
MangaScreenSmallImpl(
state = state,
snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat,
fetchInterval = fetchInterval,
chapterSwipeStartAction = chapterSwipeStartAction,
@ -188,6 +190,7 @@ fun MangaScreen(
MangaScreenLargeImpl(
state = state,
snackbarHostState = snackbarHostState,
dateRelativeTime = dateRelativeTime,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
dateFormat = dateFormat,
@ -228,6 +231,7 @@ fun MangaScreen(
private fun MangaScreenSmallImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -297,11 +301,9 @@ private fun MangaScreenSmallImpl(
}
val animatedTitleAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0) 1f else 0f,
label = "titleAlpha",
)
val animatedBgAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f,
label = "bgAlpha",
)
EntryToolbar(
title = state.manga.title,
@ -446,6 +448,7 @@ private fun MangaScreenSmallImpl(
sharedChapterItems(
manga = state.manga,
chapters = chapters,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
@ -464,6 +467,7 @@ private fun MangaScreenSmallImpl(
fun MangaScreenLargeImpl(
state: MangaScreenModel.State.Success,
snackbarHostState: SnackbarHostState,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
fetchInterval: Int?,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
@ -679,6 +683,7 @@ fun MangaScreenLargeImpl(
sharedChapterItems(
manga = state.manga,
chapters = chapters,
dateRelativeTime = dateRelativeTime,
dateFormat = dateFormat,
chapterSwipeStartAction = chapterSwipeStartAction,
chapterSwipeEndAction = chapterSwipeEndAction,
@ -741,6 +746,7 @@ private fun SharedMangaBottomActionMenu(
private fun LazyListScope.sharedChapterItems(
manga: Manga,
chapters: List<ChapterItem>,
dateRelativeTime: Boolean,
dateFormat: DateFormat,
chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction,
chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction,
@ -769,7 +775,11 @@ private fun LazyListScope.sharedChapterItems(
date = chapterItem.chapter.dateUpload
.takeIf { it > 0L }
?.let {
Date(it).toRelativeString(context, dateFormat)
Date(it).toRelativeString(
context,
dateRelativeTime,
dateFormat,
)
},
readProgress = chapterItem.chapter.lastPageRead
.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 uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DateFormat
@Composable
fun AnimeHistoryContent(
@ -24,7 +23,8 @@ fun AnimeHistoryContent(
onClickDelete: (AnimeHistoryWithRelations) -> Unit,
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(
contentPadding = contentPadding,
@ -44,6 +44,7 @@ fun AnimeHistoryContent(
RelativeDateHeader(
modifier = Modifier.animateItemPlacement(),
date = item.date,
relativeTime = relativeTime,
dateFormat = dateFormat,
)
}

View file

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

View file

@ -72,7 +72,7 @@ fun MoreScreen(
textRes = R.string.fdroid_warning,
modifier = Modifier.clickable {
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 dateFormat by uiPreferences.dateFormat().collectAsState()
val formattedNow = remember(dateFormat) {
UiPreferences.dateFormat(dateFormat).format(now)
}
LaunchedEffect(currentLanguage) {
val locale = if (currentLanguage.isEmpty()) {
LocaleListCompat.getEmptyLocaleList()
@ -199,6 +204,15 @@ object SettingsAppearanceScreen : SearchableSettings {
"${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),
onPreferenceClick = {
uriHandler.openUri(
"https://aniyomi.org/help/contribution/#translation",
"https://aniyomi.org/docs/contribute#translation",
)
},
)
@ -165,7 +165,7 @@ object AboutScreen : Screen() {
item {
TextPreferenceWidget(
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 = {
val libraryLicenseScreen = OpenSourceLibraryLicenseScreen(
name = it.name,
website = it.website,
license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
name = it.library.name,
website = it.library.website,
license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(),
)
navigator.push(libraryLicenseScreen)
},

View file

@ -1,25 +1,25 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import eu.kanade.domain.entries.manga.model.orientationType
import eu.kanade.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.components.SettingsIconGrid
import tachiyomi.presentation.core.components.material.IconToggleButton
private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it }
@ -36,22 +36,20 @@ fun OrientationModeSelectDialog(
)
}
AdaptiveSheet(
onDismissRequest = onDismissRequest,
) {
Row(
modifier = Modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
SettingsChipRow(R.string.rotation_type) {
orientationTypeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == orientationType,
onClick = {
screenModel.onChangeOrientation(it)
AdaptiveSheet(onDismissRequest = onDismissRequest) {
Box(modifier = Modifier.padding(vertical = 16.dp)) {
SettingsIconGrid(R.string.rotation_type) {
items(orientationTypeOptions) { (stringRes, mode) ->
IconToggleButton(
checked = mode == orientationType,
onCheckedChange = {
screenModel.onChangeOrientation(mode)
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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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.presentation.components.AdaptiveSheet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
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
private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it }
@ -36,22 +37,20 @@ fun ReadingModeSelectDialog(
)
}
AdaptiveSheet(
onDismissRequest = onDismissRequest,
) {
Row(
modifier = Modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small),
) {
SettingsChipRow(R.string.pref_category_reading_mode) {
readingModeOptions.map { (stringRes, it) ->
FilterChip(
selected = it == readingMode,
onClick = {
screenModel.onChangeReadingMode(it)
AdaptiveSheet(onDismissRequest = onDismissRequest) {
Box(modifier = Modifier.padding(vertical = MaterialTheme.padding.medium)) {
SettingsIconGrid(R.string.pref_category_reading_mode) {
items(readingModeOptions) { (stringRes, mode) ->
IconToggleButton(
checked = mode == readingMode,
onCheckedChange = {
screenModel.onChangeReadingMode(mode)
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
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)

View file

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

View file

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

View file

@ -176,7 +176,7 @@ fun WebViewScreenContent(
textRes = R.string.information_cloudflare_help,
modifier = Modifier.clickable {
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" }
}
}
if (oldVersion < 106) {
val pref = preferenceStore.getInt("relative_time", 7)
if (pref.get() == 0) {
uiPreferences.relativeTime().set(false)
}
}
return true
}
}

View file

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

View file

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

View file

@ -46,7 +46,6 @@ import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Response
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.launchNow
import tachiyomi.core.util.lang.withIOContext
@ -396,7 +395,7 @@ class MangaDownloader(
if (page.imageUrl.isNullOrEmpty()) {
page.status = Page.State.LOAD_PAGE
try {
page.imageUrl = download.source.fetchImageUrl(page).awaitSingle()
page.imageUrl = download.source.getImageUrl(page)
} catch (e: Throwable) {
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.model.Category
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.GetLibraryAnime
import tachiyomi.domain.entries.anime.interactor.SetAnimeFetchInterval
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.domain.entries.anime.model.toAnimeUpdate
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 syncEpisodesWithSource: SyncEpisodesWithSource = 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)
@ -216,7 +216,7 @@ class AnimeLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val failedUpdates = CopyOnWriteArrayList<Pair<Anime, String?>>()
val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = setAnimeFetchInterval.getWindow(ZonedDateTime.now())
val fetchWindow = animeFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope {
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_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

View file

@ -377,7 +377,7 @@ class AnimeLibraryUpdateNotifier(private val context: Context) {
companion object {
// 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
// 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.entries.manga.interactor.GetLibraryManga
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.toMangaUpdate
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 syncChaptersWithSource: SyncChaptersWithSource = 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)
@ -216,7 +216,7 @@ class MangaLibraryUpdateJob(private val context: Context, workerParams: WorkerPa
val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>()
val hasDownloads = AtomicBoolean(false)
val restrictions = libraryPreferences.autoUpdateItemRestrictions().get()
val fetchWindow = setMangaFetchInterval.getWindow(ZonedDateTime.now())
val fetchWindow = mangaFetchInterval.getWindow(ZonedDateTime.now())
coroutineScope {
mangaToUpdate.groupBy { it.manga.source }.values

View file

@ -380,7 +380,7 @@ class MangaLibraryUpdateNotifier(private val context: Context) {
companion object {
// 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
// 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 {
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}")
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || image.location !is Location.Pictures) {
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 =
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 relativePath = listOf(
Environment.DIRECTORY_PICTURES,
context.getString(R.string.app_name),
imageLocation,
).joinToString(File.separator)
val contentValues = contentValuesOf(
MediaStore.Images.Media.RELATIVE_PATH to relativePath,
MediaStore.Images.Media.DISPLAY_NAME to image.name,
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(
pictureDir,
contentValues,
@ -76,24 +104,8 @@ class ImageSaver(
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)
private fun findUriOrDefault(relativePath: String, imagePath: String, default: () -> Uri): Uri {
private fun findUriOrDefault(path: String, filename: String, default: () -> Uri): Uri {
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DISPLAY_NAME,
@ -104,19 +116,19 @@ class ImageSaver(
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(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
arrayOf(relativePath, imagePath),
arrayOf(normalizedPath, filename),
null,
).use { cursor ->
if (cursor != null && cursor.count >= 1) {
cursor.moveToFirst().let {
val id = cursor.getLong(
cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID),
)
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
return ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id,
@ -124,6 +136,7 @@ class ImageSaver(
}
}
}
return default()
}
}

View file

@ -161,7 +161,7 @@ internal class AppUpdateNotifier(private val context: Context) {
setContentIntent(
NotificationHandler.openUrl(
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 ""
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.")

View file

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

View file

@ -105,7 +105,7 @@ class MangaExtensionDetailsScreenModel(
val extension = state.value.extension ?: return ""
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.")

View file

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

View file

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

View file

@ -78,13 +78,17 @@ class AnimeDownloadQueueScreenModel(
}
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
?.filterIsInstance<AnimeDownloadItem>()
?.map(AnimeDownloadItem::download)
?.partition { item.download.anime.id == it.anime.id }
?: 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 -> {
cancel(listOf(item.download))

View file

@ -84,13 +84,17 @@ class MangaDownloadQueueScreenModel(
}
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
?.filterIsInstance<MangaDownloadItem>()
?.map(MangaDownloadItem::download)
?.partition { item.download.manga.id == it.manga.id }
?: 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 -> {
cancel(listOf(item.download))

View file

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

View file

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

View file

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

View file

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

View file

@ -198,7 +198,7 @@ object AnimeLibraryTab : Tab() {
icon = Icons.Outlined.HelpOutline,
onClick = {
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,
onClick = {
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,
episodeList = viewModel.currentPlaylist,
currentEpisodeIndex = viewModel.getCurrentEpisodeIndex(),
relativeTime = viewModel.relativeTime,
dateRelativeTime = viewModel.relativeTime,
dateFormat = viewModel.dateFormat,
onBookmarkClicked = viewModel::bookmarkEpisode,
onEpisodeClicked = this::changeEpisode,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@ import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Calendar
import java.util.Date
import java.util.TimeZone
fun Date.toDateTimestampString(dateFormatter: DateFormat): String {
val date = dateFormatter.format(this)
@ -46,80 +45,18 @@ fun Long.toDateKey(): Date {
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
fun Date.toRelativeString(
context: Context,
relative: Boolean = true,
dateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT),
): String {
if (!relative) {
return dateFormat.format(this)
}
val now = Date()
val difference = now.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY) - this.timeWithOffset.floorNearest(
MILLISECONDS_IN_DAY,
)
val difference = now.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY) - this.timeWithOffset.floorNearest(MILLISECONDS_IN_DAY)
val days = difference.floorDiv(MILLISECONDS_IN_DAY).toInt()
return when {
difference < 0 -> dateFormat.format(this)

View file

@ -13,6 +13,10 @@
android:id="@+id/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
android:id="@+id/cancel_download"
android:title="@string/action_cancel" />

View file

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.core
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"

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
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
@ -95,6 +104,9 @@ suspend fun Call.await(): Response {
return await(callStack)
}
/**
* @since extensions-lib 1.5
*/
suspend fun Call.awaitSuccess(): Response {
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
val response = await(callStack)
@ -105,15 +117,6 @@ suspend fun Call.awaitSuccess(): 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 {
val progressClient = newBuilder()
.cache(null)

View file

@ -14,7 +14,24 @@ import kotlin.coroutines.resume
object WebViewUtil {
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 {
try {

View file

@ -16,18 +16,21 @@ class AnimeSourceSearchPagingSource(
val filters: AnimeFilterList,
) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage {
// Replace with getSearchAnime
return source.fetchSearchAnime(currentPage, query, filters).awaitSingle()
}
}
class AnimeSourcePopularPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage {
// Replace with getPopularAnime
return source.fetchPopularAnime(currentPage).awaitSingle()
}
}
class AnimeSourceLatestPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage {
// Replace with getLatestUpdates
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.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import tachiyomi.core.util.lang.awaitSingle
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.domain.items.chapter.model.NoChaptersException
import tachiyomi.domain.source.manga.repository.SourcePagingSourceType
@ -14,19 +13,19 @@ class SourceSearchPagingSource(source: CatalogueSource, val query: String, val f
source,
) {
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) {
override suspend fun requestNextPage(currentPage: Int): MangasPage {
return source.fetchPopularManga(currentPage).awaitSingle()
return source.getPopularManga(currentPage)
}
}
class SourceLatestPagingSource(source: CatalogueSource) : SourcePagingSource(source) {
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.items.episode.interactor.GetEpisodeByAnimeId
import tachiyomi.domain.items.episode.model.Episode
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
const val MAX_FETCH_INTERVAL = 28
private const val FETCH_INTERVAL_GRACE_PERIOD = 1
class SetAnimeFetchInterval(
class AnimeFetchInterval(
private val getEpisodeByAnimeId: GetEpisodeByAnimeId,
) {
@ -30,7 +27,7 @@ class SetAnimeFetchInterval(
val episodes = getEpisodeByAnimeId.await(anime.id)
val interval = anime.fetchInterval.takeIf { it < 0 } ?: calculateInterval(
episodes,
dateTime,
dateTime.zone,
)
val nextUpdate = calculateNextUpdate(anime, interval, dateTime, currentWindow)
@ -43,33 +40,34 @@ class SetAnimeFetchInterval(
fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> {
val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone)
val lowerBound = today.minusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
val upperBound = today.plusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
val lowerBound = today.minusDays(GRACE_PERIOD)
val upperBound = today.plusDays(GRACE_PERIOD)
return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1)
}
internal fun calculateInterval(episodes: List<Episode>, zonedDateTime: ZonedDateTime): Int {
val sortedEpisodes = episodes
.sortedWith(
compareByDescending<Episode> { it.dateUpload }.thenByDescending { it.dateFetch },
)
.take(50)
val uploadDates = sortedEpisodes
internal fun calculateInterval(episodes: List<Episode>, zone: ZoneId): Int {
val uploadDates = episodes.asSequence()
.filter { it.dateUpload > 0L }
.sortedByDescending { it.dateUpload }
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone)
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zone)
.toLocalDate()
.atStartOfDay()
}
.distinct()
val fetchDates = sortedEpisodes
.take(10)
.toList()
val fetchDates = episodes.asSequence()
.sortedByDescending { it.dateFetch }
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone)
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zone)
.toLocalDate()
.atStartOfDay()
}
.distinct()
.take(10)
.toList()
val interval = when {
// Enough upload date from source
@ -88,7 +86,7 @@ class SetAnimeFetchInterval(
else -> 7
}
return interval.coerceIn(1, MAX_FETCH_INTERVAL)
return interval.coerceIn(1, MAX_INTERVAL)
}
private fun calculateNextUpdate(
@ -119,7 +117,7 @@ class SetAnimeFetchInterval(
}
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
val cycle = timeSinceLatest.floorDiv(delta) + 1
@ -129,4 +127,10 @@ class SetAnimeFetchInterval(
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.items.chapter.interactor.GetChapterByMangaId
import tachiyomi.domain.items.chapter.model.Chapter
import uy.kohesive.injekt.api.get
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
const val MAX_FETCH_INTERVAL = 28
private const val FETCH_INTERVAL_GRACE_PERIOD = 1
class SetMangaFetchInterval(
class MangaFetchInterval(
private val getChapterByMangaId: GetChapterByMangaId,
) {
@ -30,7 +27,7 @@ class SetMangaFetchInterval(
val chapters = getChapterByMangaId.await(manga.id)
val interval = manga.fetchInterval.takeIf { it < 0 } ?: calculateInterval(
chapters,
dateTime,
dateTime.zone,
)
val nextUpdate = calculateNextUpdate(manga, interval, dateTime, currentWindow)
@ -43,33 +40,34 @@ class SetMangaFetchInterval(
fun getWindow(dateTime: ZonedDateTime): Pair<Long, Long> {
val today = dateTime.toLocalDate().atStartOfDay(dateTime.zone)
val lowerBound = today.minusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
val upperBound = today.plusDays(FETCH_INTERVAL_GRACE_PERIOD.toLong())
val lowerBound = today.minusDays(GRACE_PERIOD)
val upperBound = today.plusDays(GRACE_PERIOD)
return Pair(lowerBound.toEpochSecond() * 1000, upperBound.toEpochSecond() * 1000 - 1)
}
internal fun calculateInterval(chapters: List<Chapter>, zonedDateTime: ZonedDateTime): Int {
val sortedChapters = chapters
.sortedWith(
compareByDescending<Chapter> { it.dateUpload }.thenByDescending { it.dateFetch },
)
.take(50)
val uploadDates = sortedChapters
internal fun calculateInterval(chapters: List<Chapter>, zone: ZoneId): Int {
val uploadDates = chapters.asSequence()
.filter { it.dateUpload > 0L }
.sortedByDescending { it.dateUpload }
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zonedDateTime.zone)
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateUpload), zone)
.toLocalDate()
.atStartOfDay()
}
.distinct()
val fetchDates = sortedChapters
.take(10)
.toList()
val fetchDates = chapters.asSequence()
.sortedByDescending { it.dateFetch }
.map {
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zonedDateTime.zone)
ZonedDateTime.ofInstant(Instant.ofEpochMilli(it.dateFetch), zone)
.toLocalDate()
.atStartOfDay()
}
.distinct()
.take(10)
.toList()
val interval = when {
// Enough upload date from source
@ -88,7 +86,7 @@ class SetMangaFetchInterval(
else -> 7
}
return interval.coerceIn(1, MAX_FETCH_INTERVAL)
return interval.coerceIn(1, MAX_INTERVAL)
}
private fun calculateNextUpdate(
@ -119,7 +117,7 @@ class SetMangaFetchInterval(
}
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
val cycle = timeSinceLatest.floorDiv(delta) + 1
@ -129,4 +127,10 @@ class SetMangaFetchInterval(
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()
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
override suspend fun getAnimeDetails(anime: SAnime): SAnime =
throw AnimeSourceNotInstalledException()
}
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> =
throw AnimeSourceNotInstalledException()
}
override suspend fun getVideoList(episode: SEpisode): List<Video> {
override suspend fun getVideoList(episode: SEpisode): List<Video> =
throw AnimeSourceNotInstalledException()
}
override fun toString(): String {
return if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString()
}
override fun toString(): String =
if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString()
}
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.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
@Suppress("OverridingDeprecatedMember")
class StubMangaSource(
@ -15,36 +14,17 @@ class StubMangaSource(
private val isInvalid: Boolean = name.isBlank() || lang.isBlank()
override suspend fun getMangaDetails(manga: SManga): SManga {
override suspend fun getMangaDetails(manga: SManga): SManga =
throw SourceNotInstalledException()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getMangaDetails"))
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(SourceNotInstalledException())
}
override suspend fun getChapterList(manga: SManga): List<SChapter> {
override suspend fun getChapterList(manga: SManga): List<SChapter> =
throw SourceNotInstalledException()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getChapterList"))
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(SourceNotInstalledException())
}
override suspend fun getPageList(chapter: SChapter): List<Page> {
override suspend fun getPageList(chapter: SChapter): List<Page> =
throw SourceNotInstalledException()
}
@Deprecated("Use the non-RxJava API instead", replaceWith = ReplaceWith("getPageList"))
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(SourceNotInstalledException())
}
override fun toString(): String {
return if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString()
}
override fun toString(): String =
if (isInvalid.not()) "$name (${lang.uppercase()})" else id.toString()
}
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-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-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha01"
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0-alpha04"

View file

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

View file

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

View file

@ -1,5 +1,5 @@
[versions]
aboutlib_version = "10.8.3"
aboutlib_version = "10.9.0"
okhttp_version = "5.0.0-alpha.11"
shizuku_version = "12.2.0"
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"
directionalviewpager = "com.github.tachiyomiorg:DirectionalViewPager:1.0.0"
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"
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"
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-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", 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"
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_all_for_series">Move series to top</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_share">Share</string>
<string name="action_save">Save</string>
@ -205,6 +206,9 @@
<string name="theme_matrix">Matrix</string>
<string name="theme_tidalwave">Tidal Wave</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_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_normal">Normal</string>
<string name="double_tap_anim_speed_fast">Fast</string>
<string name="pref_rotation_type">Default rotation type</string>
<string name="rotation_type">Rotation type</string>
<string name="pref_rotation_type">Default rotation</string>
<string name="rotation_type">Rotation</string>
<string name="rotation_free">Free</string>
<string name="rotation_portrait">Portrait</string>
<string name="rotation_reverse_portrait">Reverse portrait</string>
@ -451,13 +455,13 @@
<!-- Tracking section -->
<string name="tracking_guide">Tracking guide</string>
<string name="services">Services</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="enhanced_services">Enhanced services</string>
<string name="services">Trackers</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 trackers</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="track_activity_name">Tracking login</string>
<string name="track_activity_name">Tracker login</string>
<!-- Browse section -->
<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.size
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.material.ContentAlpha
import androidx.compose.material.icons.Icons
@ -54,6 +57,7 @@ import kotlinx.coroutines.delay
import tachiyomi.core.preference.Preference
import tachiyomi.core.preference.TriState
import tachiyomi.core.preference.toggle
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.theme.header
import tachiyomi.presentation.core.util.collectAsState
@ -297,7 +301,7 @@ fun TriStateItem(
vertical = SettingsItemsPaddings.Vertical,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.large),
) {
val stateAlpha = if (enabled && onClick != null) 1f else ContentAlpha.disabled
@ -504,7 +508,25 @@ fun SettingsChipRow(
end = SettingsItemsPaddings.Horizontal,
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,
)
}

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.AnimesPage
import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
interface AnimeCatalogueSource : AnimeSource {
@ -17,30 +18,63 @@ interface AnimeCatalogueSource : AnimeSource {
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.
*/
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 query the search query.
* @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.
*/
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.
*/
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.
*
* @since extensions-lib 1.4
* @since extensions-lib 1.5
* @param anime the anime to update.
* @return the updated anime.
*/
@ -39,7 +39,7 @@ interface AnimeSource {
/**
* Get all the available episodes for a anime.
*
* @since extensions-lib 1.4
* @since extensions-lib 1.5
* @param anime the anime to update.
* @return the episodes for the anime.
*/
@ -52,7 +52,7 @@ interface AnimeSource {
* Get the list of videos a episode has. Pages should be returned
* in the expected order; the index is ignored.
*
* @since extensions-lib 1.4
* @since extensions-lib 1.5
* @param episode the episode.
* @return the videos for the episode.
*/
@ -61,41 +61,24 @@ interface AnimeSource {
return fetchVideoList(episode).awaitSingle()
}
/**
* Returns an observable with the updated details for a anime.
*
* @param anime the anime to update.
*/
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getAnimeDetails"),
)
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> = throw IllegalStateException(
"Not used",
)
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> =
throw IllegalStateException("Not used")
/**
* Returns an observable with all the available episodes for a anime.
*
* @param anime the anime to update.
*/
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getEpisodeList"),
)
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> = throw IllegalStateException(
"Not used",
)
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> =
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(
"Use the non-RxJava API instead",
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
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import eu.kanade.tachiyomi.PreferenceScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
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)
}

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.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.net.URI
import java.net.URISyntaxException
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.
*/
@Suppress("unused")
abstract class AnimeHttpSource : AnimeCatalogueSource {
/**
@ -88,6 +83,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* @param versionId [Int] the version ID of the source
* @return a unique ID for the source
*/
@Suppress("MemberVisibilityCanBePrivate")
protected fun generateId(name: String, lang: String, versionId: Int): Long {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
@ -201,11 +197,18 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
protected abstract fun latestUpdatesParse(response: Response): AnimesPage
/**
* Returns an observable with the updated details for a nanime. Normally it's not needed to
* override this method.
* Get the updated details for a anime.
* Normally it's not needed to override this method.
*
* @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> {
return client.newCall(animeDetailsRequest(anime))
.asObservableSuccess()
@ -232,11 +235,23 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
protected abstract fun animeDetailsParse(response: Response): SAnime
/**
* Returns an observable with the updated episode list for an anime. Normally it's not needed to
* override this method. If an anime is licensed an empty episode list observable is returned
* Get all the available episodes for an anime.
* 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>> {
return if (anime.status != SAnime.LICENSED) {
client.newCall(episodeListRequest(anime))
@ -267,10 +282,18 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
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>> {
return client.newCall(videoListRequest(episode))
.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
* 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.
*/
/*@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> {
return client.newCall(videoUrlRequest(video))
.asObservableSuccess()
@ -333,36 +363,15 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
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 {
val animeDownloadClient = client.newBuilder()
.callTimeout(30, TimeUnit.MINUTES)
.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))
}
}
},
)
}
open suspend fun getVideo(video: Video): Response {
return client.newCachelessCallWithProgress(videoRequest(video), video)
.awaitSuccess()
}
/**
@ -440,7 +449,7 @@ abstract class AnimeHttpSource : AnimeCatalogueSource {
* @param episode the episode
* @return url of the episode
*/
open fun getChapterUrl(episode: SEpisode): String {
open fun getEpisodeUrl(episode: SEpisode): String {
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.MangasPage
import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
interface CatalogueSource : MangaSource {
@ -17,30 +18,63 @@ interface CatalogueSource : MangaSource {
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.
*/
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 query the search query.
* @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.
*/
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.
*/
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
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import eu.kanade.tachiyomi.PreferenceScreen
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
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)
}

View file

@ -27,7 +27,7 @@ interface MangaSource {
/**
* Get the updated details for a manga.
*
* @since extensions-lib 1.4
* @since extensions-lib 1.5
* @param manga the manga to update.
* @return the updated manga.
*/
@ -39,7 +39,7 @@ interface MangaSource {
/**
* Get all the available chapters for a manga.
*
* @since extensions-lib 1.4
* @since extensions-lib 1.5
* @param manga the manga to update.
* @return the chapters for the manga.
*/
@ -52,7 +52,7 @@ interface MangaSource {
* Get the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored.
*
* @since extensions-lib 1.4
* @since extensions-lib 1.5
* @param chapter the chapter.
* @return the pages for the chapter.
*/
@ -61,41 +61,24 @@ interface MangaSource {
return fetchPageList(chapter).awaitSingle()
}
/**
* Returns an observable with the updated details for a manga.
*
* @param manga the manga to update.
*/
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getMangaDetails"),
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException(
"Not used",
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> =
throw IllegalStateException("Not used")
/**
* Returns an observable with all the available chapters for a manga.
*
* @param manga the manga to update.
*/
@Deprecated(
"Use the non-RxJava API instead",
ReplaceWith("getChapterList"),
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException(
"Not used",
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> =
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(
"Use the non-RxJava API instead",
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.Response
import rx.Observable
import tachiyomi.core.util.lang.awaitSingle
import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.net.URISyntaxException
@ -24,6 +25,7 @@ import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
@Suppress("unused")
abstract class HttpSource : CatalogueSource {
/**
@ -81,6 +83,7 @@ abstract class HttpSource : CatalogueSource {
* @param versionId [Int] the version ID of the source
* @return a unique ID for the source
*/
@Suppress("MemberVisibilityCanBePrivate")
protected fun generateId(name: String, lang: String, versionId: Int): Long {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
@ -194,11 +197,18 @@ abstract class HttpSource : CatalogueSource {
protected abstract fun latestUpdatesParse(response: Response): MangasPage
/**
* Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method.
* Get the updated details for a manga.
* Normally it's not needed to override this method.
*
* @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> {
return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess()
@ -225,11 +235,23 @@ abstract class HttpSource : CatalogueSource {
protected abstract fun mangaDetailsParse(response: Response): SManga
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned
* Get all the available chapters for a manga.
* 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>> {
return if (manga.status != SManga.LICENSED) {
client.newCall(chapterListRequest(manga))
@ -260,10 +282,18 @@ abstract class HttpSource : CatalogueSource {
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>> {
return client.newCall(pageListRequest(chapter))
.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
* 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.
*/
@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> {
return client.newCall(imageUrlRequest(page))
.asObservableSuccess()
@ -318,24 +355,14 @@ abstract class HttpSource : CatalogueSource {
*/
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.
* Typically does not need to be overridden.
*
* @since extensions-lib 1.5
* @param page the page whose source image has to be downloaded.
*/
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)
.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
// 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 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))
}

View file

@ -16,7 +16,6 @@ import kotlinx.serialization.json.decodeFromStream
import logcat.LogPriority
import nl.adaptivity.xmlutil.AndroidXmlReader
import nl.adaptivity.xmlutil.serialization.XML
import rx.Observable
import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE
import tachiyomi.core.metadata.comicinfo.ComicInfo
import tachiyomi.core.metadata.comicinfo.copyFromComicInfo
@ -70,11 +69,11 @@ actual class LocalMangaSource(
override val supportsLatest: Boolean = true
// 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 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