diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a932a7db3..f2da40f3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,85 @@ The format is simplified version of [Keep a Changelog](https://keepachangelog.co ## [Unreleased] +## [1.11.0] + +### Additions +- **Recommendations**, as a source of its own under "For you" in Browse. Learns a taste profile + from what you read (reading is the driver; tracker scores only scale it; a dropped series counts + against its tags; what you read long ago fades), then asks four public catalogues — AniList, + MangaUpdates, Kitsu and MyAnimeList — for the best-rated and most-read series under every tag + you are into, merges what they agree on (a series all four name scores highest), and finds each + one in the sources your library uses, with MangaDex alternative titles as a fallback for pt-BR + names. Your sources' own genre filters are searched too, deeper for stronger tags, and the + trackers' "similar to" suggestions for your strongest seeds round it out. Everything is read in + detail before it is shown and ranked on its full tags, tag pairs you like together, and the + community's score +- Recommendations learn from you: long-press a cover for *Not interested* (its tags lose weight, + relative to how much your library backs them) or *Already read*; adding to the library from the + window reinforces the tags it was recommended for. Verdicts can be forgotten on the tags screen +- "Your tags" screen: every learned tag with its weight and how many series back it; drag a + slider to fix a weight, add a tag, strike one that is not a genre, long-press to select and + merge tags you consider the same. Merges, bans and weights are yours and are backed up +- Recommendations are built in batches of fifty up to the number you choose (25 to 500; Settings › + Browse › Recommendations), on a schedule (every week to every six months, at your hour, Wi-Fi + only) that carries on the next day by itself when a big target needs more than one night, or on + demand: "Fetch 50 more" adds a batch, "Rebuild from scratch" starts over. Stage-by-stage + progress, a notification when done, and the list refreshes itself when the library grows by ten + or more. How far back a "recent start" may go in the catalogues is a setting too +- Recommendation filters: only with these tags (only tags something in the list carries), include + saved-but-unread, sort by best match / newest / fewest chapters, and "hide sources with + unreliable dates" (on by default). A long-press on a cover says why it is there: tags or "like + X", chapter count, last chapter, community score, and whether it is new, gone quiet or a bit + outside your usual +- Trackers enrich the library overnight: their genres and tags join the entry's own (as a custom + edit, so a refresh keeps them), and every other name they know feeds migration and merged-source + search — which is where a scanlator's own Portuguese title finally finds the series elsewhere +- Merged sources: the app looks overnight for library entries on your other sources and offers a + one-tap merge on the entry's page +- Release calendar: month view (toggle in the app bar), "Arrived today", "On hiatus", a "Only what + I'm caught up on" filter (also honoured by the widget and the morning digest), a "probably" + estimate for entries too new to have a rhythm (borrowed from the source's other series), and + tracker status (AniList, MAL, Kitsu, MangaUpdates) overriding a source that never updated its own +- Webtoon reader: "continue reading" returns to the exact spot on a tall page, a tenth of a + screen early, instead of the top of the page +- Dracula / Alucard theme (follows the system: Alucard light, Dracula dark) +- Category rules, "Your tags" and the release calendar share the app's standard app bar + +### Changes +- "Hide library items" in Browse is now on by default: what you already have on a source no + longer shows up when browsing that source +- Recommendations skip one-shots (by tag, title or chapter names), series with no chapters, and + series gone quiet for longer than their size allows (three months for a new one, up to two years + for a long one; finished series are exempt). Series from sources that report no real dates are + ranked lower and hidden by default, and their chapter count does not make them "new". Every + rebuild starts from nothing: the previous list and the rows it left behind are discarded first +- Tag names are unified across languages (Ação / Action / Acción), and source metadata dressed as + a genre ("Classificação: Sugestivo", scanlator names, years) is dropped +- Merged-source search no longer offers the entry's own source or sources already merged in, and + tries the series' other names on a source that found nothing under the library title +- A weekly series that posted late once keeps expecting its usual weekday +- One slot in five on the recommendation list goes to something a little outside your usual, so + the list keeps surprising instead of narrowing on the same five tags + ### Fixes - Tapping an extension group header did nothing: the row's ripple was on a child view, and a clickable child swallows the touch before it reaches the row the click listener is on +- Sources that report every chapter as uploaded "0 minutes ago" no longer rewrite every chapter on + each update, pollute "recently updated", or fool the release calendar and the recommendations: + such dates are treated as unknown, and the calendar learns from when chapters actually arrive +- Release calendar: a source that stamps a date as midnight UTC no longer lands a day early west + of Greenwich; webtoon "current page" was one behind when the image was taller than the screen +- Release estimates are recalculated once when the estimator changes, instead of staying wrong + until each series' next fetch +- Recommendations: a stale list could survive a rebuild that finished while the screen was + covered; a re-rank could overwrite a batch the build had just written; looking a title up in the + sources ran one source at a time with no timeout, so one silent source could hold a build for + an hour; "Fetch 50 more" did nothing once the target was met +- Tag names from sources and from your own typing are sanitised before they are sent to AniList + or Kitsu + +### Other +- Chapter memo is treated as source-owned; per-chapter reading offsets live in a preference ## [1.10.3] diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e1c91f3b8..540be4ede1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,7 +19,7 @@ fun runCommand(command: String): String { } @Suppress("PropertyName") -val _versionName = "1.10.3" +val _versionName = "1.11.0" val betaCount by lazy { val betaTags = runCommand("git tag -l --sort=refname v${_versionName}-b*") @@ -47,7 +47,7 @@ base { android { defaultConfig { applicationId = "eu.kanade.tachiyomi" - versionCode = 162 + versionCode = 163 versionName = _versionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 1a681207bc..ba8e736c3d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -61,6 +61,8 @@ import eu.kanade.tachiyomi.util.system.AuthenticatorUtil import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.launchIO +import karasu.domain.manga.interval.ReleaseEstimate +import karasu.domain.manga.interval.RecalculateReleaseEstimates import eu.kanade.tachiyomi.util.system.localeContext import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.setToDefault @@ -153,6 +155,14 @@ open class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.F with(TachiyomiWidgetManager()) { this@App.init() } } + // The release estimator changed since these estimates were written: redo them once. + if (preferences.releaseEstimateVersion().get() != ReleaseEstimate.ESTIMATOR_VERSION) { + scope.launchIO { + runCatching { Injekt.get().await() } + .onSuccess { preferences.releaseEstimateVersion().set(ReleaseEstimate.ESTIMATOR_VERSION) } + } + } + // Show notification to disable Incognito Mode when it's enabled preferences.incognitoMode().changes() .onEach { enabled -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt b/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt index fb58261578..3f32d1682a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/appwidget/ReleasesGlanceWidget.kt @@ -109,7 +109,8 @@ class ReleasesGlanceWidget : GlanceAppWidget() { .toSet() val grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()) val calendar = runCatching { - getReleaseSchedule.await(categories).calendar(dayCount = 7, grace = grace) + getReleaseSchedule.await(categories, onlyCaughtUp = preferences.releaseCalendarOnlyCaughtUp().get()) + .calendar(dayCount = 7, grace = grace) } .getOrNull() ?: return ReleasesWidgetData(today = true, covers = emptyList()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 339fcb0187..3c9518efc1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.koreader.KoreaderSyncJob import eu.kanade.tachiyomi.data.migration.AutoMigrateJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING @@ -860,6 +861,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet // do with how often the library is refreshed. Its own hour is its off switch, and // re-registering here is what survives a reboot or an app update. AutoMigrateJob.setupTask(context) + RecommendationJob.setupTask(context) } fun cancelAllWorks(context: Context) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt index 8ea3566552..c44092d82a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/ReleaseDigestJob.kt @@ -50,7 +50,8 @@ class ReleaseDigestJob(private val context: Context, workerParams: WorkerParamet // Day one of the calendar rather than a fresh query, so the notification and the screen // it opens can never disagree — including about overdue entries, which the calendar puts // on today because the chapter has not arrived and is therefore still coming. - val today = Injekt.get().await(categories) + val schedule = Injekt.get().await(categories, onlyCaughtUp = preferences.releaseCalendarOnlyCaughtUp().get()) + val today = schedule .calendar( dayCount = 1, grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt index badf66b547..11db44f63a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/migration/AutoMigrateJob.kt @@ -23,6 +23,7 @@ import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.RecommendationSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.main.MainActivity @@ -143,7 +144,8 @@ class AutoMigrateJob(private val context: Context, workerParams: WorkerParameter * confident about, so listing pt-BR before English is what makes pt-BR win a tie. */ private fun candidateSources(languages: List, exclude: Long): List { - val installed = sourceManager.getCatalogueSources().filter { it.id != exclude } + val installed = sourceManager.getCatalogueSources() + .filter { it.id != exclude && it.id != RecommendationSource.ID } return languages.flatMap { lang -> installed.filter { it.lang.equals(lang, ignoreCase = true) } } } @@ -296,7 +298,6 @@ class AutoMigrateJob(private val context: Context, workerParams: WorkerParameter * twice a year and never walk it back. Landing exactly on the hour counts as passed, so * rescheduling at the moment the job fires books the next slot, not the same one again. */ - @VisibleForTesting fun millisUntil(day: Int, hour: Int, now: ZonedDateTime = ZonedDateTime.now()): Long { val todayAt = now.truncatedTo(ChronoUnit.DAYS).withHour(hour) var next = if (todayAt.isAfter(now)) todayAt else todayAt.plusDays(1) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index ec84e49082..f74886ec27 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -71,6 +71,12 @@ object Notifications { * library moved on its own. That is worth being able to keep loud while muting the rest. */ const val CHANNEL_AUTO_MIGRATE = "auto_migrate_channel" + + /** A rebuild of the recommendations the reader asked for has finished. */ + const val CHANNEL_RECOMMENDATIONS = "recommendations_channel" + const val ID_RECOMMENDATIONS = -107 + const val CHANNEL_RECOMMENDATIONS_PROGRESS = "recommendations_progress_channel" + const val ID_RECOMMENDATIONS_PROGRESS = -108 const val ID_AUTO_MIGRATE = -106 /** @@ -199,6 +205,21 @@ object Notifications { ).apply { group = GROUP_LIBRARY }, + NotificationChannel( + CHANNEL_RECOMMENDATIONS, + context.getString(MR.strings.recommendations), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + group = GROUP_LIBRARY + }, + NotificationChannel( + CHANNEL_RECOMMENDATIONS_PROGRESS, + context.getString(MR.strings.recommendation_refreshing), + NotificationManager.IMPORTANCE_LOW, + ).apply { + group = GROUP_LIBRARY + setShowBadge(false) + }, NotificationChannel( CHANNEL_BACKUP_RESTORE_PROGRESS, context.getString(MR.strings.progress), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index bb4f89f3d7..4ac952f3a5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -245,6 +245,50 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto */ fun releaseScheduleCategories() = preferenceStore.getStringSet("release_schedule_categories", emptySet()) + /** Calendar as a month grid rather than the list of days. */ + fun releaseCalendarMonthView() = preferenceStore.getBoolean("release_calendar_month_view", false) + + /** Calendar, widget and digest leave out entries the reader is behind on. */ + fun releaseCalendarOnlyCaughtUp() = preferenceStore.getBoolean("release_calendar_only_caught_up", false) + + /** The estimator whose output the stored estimates came from; a newer one recalculates once. */ + fun releaseEstimateVersion() = preferenceStore.getInt(Preference.appStateKey("release_estimate_version"), 0) + + /** JSON object of chapter id to how far into its last read page the reader was (0..1). */ + fun readerPageOffsets() = preferenceStore.getString("reader_page_offsets", "{}") + + /** Hour of the day the recommendations are rebuilt, or -1 for never. */ + fun recommendationHour() = preferenceStore.getInt("recommendation_hour", 3) + + /** Library categories the taste profile is learned from. Empty means the whole library. */ + fun recommendationCategories() = preferenceStore.getStringSet("recommendation_categories", emptySet()) + + /** How many recommendations to build up to. Bigger takes more nights; the job resumes itself. */ + fun recommendationTarget() = preferenceStore.getInt("recommendation_target", 150) + + /** How far back a series may have started to count as a recent start in the catalogue pass. */ + fun recommendationRecentYears() = preferenceStore.getInt("recommendation_recent_years", 3) + + /** Days between rebuilds. */ + fun recommendationIntervalDays() = preferenceStore.getInt("recommendation_interval_days", 15) + + fun recommendationOnlyOnWifi() = preferenceStore.getBoolean("recommendation_only_on_wifi", true) + + /** JSON object of lowercase canonical tag to the name of the group the reader merged it into. */ + fun recommendationTagMerges() = preferenceStore.getString("recommendation_tag_merges", "{}") + + /** JSON list of the reader's verdicts on recommendations. A preference so it is backed up. */ + fun recommendationFeedback() = preferenceStore.getString("recommendation_feedback", "[]") + + /** JSON object of manga id to what its trackers know (tags, other names). Backed up with the rest. */ + fun recommendationTrackerExtras() = preferenceStore.getString("recommendation_tracker_extras", "{}") + + /** Tags the reader struck from the profile for good: things a source called a genre that are not one. */ + fun recommendationBannedTags() = preferenceStore.getStringSet("recommendation_banned_tags", emptySet()) + + /** JSON object of tag to weight (-1..1): the reader's overrides on the learned taste profile. */ + fun recommendationTagWeights() = preferenceStore.getString("recommendation_tag_weights", "{}") + fun libraryUpdateMangaRestriction() = preferenceStore.getStringSet("library_update_manga_restriction", setOf(MANGA_HAS_UNREAD, MANGA_NON_COMPLETED, MANGA_NON_READ)) fun libraryUpdateCategories() = preferenceStore.getStringSet("library_update_categories", emptySet()) @@ -423,7 +467,7 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto fun onlySearchPinned() = preferenceStore.getBoolean(Keys.onlySearchPinned, false) - fun hideInLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", false) + fun hideInLibraryItems() = preferenceStore.getBoolean("browse_hide_in_library_items", true) // Tutorial preferences fun shownFilterTutorial() = preferenceStore.getBoolean("shown_filter_tutorial", false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationJob.kt new file mode 100644 index 0000000000..c420e3133b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/recommendation/RecommendationJob.kt @@ -0,0 +1,289 @@ +package eu.kanade.tachiyomi.data.recommendation + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import android.app.Notification +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.work.ForegroundInfo +import kotlinx.coroutines.delay +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import co.touchlab.kermit.Logger +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.migration.AutoMigrateJob +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.system.localeContext +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager +import java.util.concurrent.TimeUnit +import karasu.domain.recommendation.BuildProgress +import karasu.domain.recommendation.BuildRecommendations +import karasu.domain.recommendation.DiscardRecommendations +import karasu.domain.recommendation.EnrichFromTrackers +import karasu.domain.recommendation.FindMergeCandidates +import karasu.domain.recommendation.RecommendationStore +import karasu.i18n.MR +import karasu.util.lang.getString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Builds the recommendations, a batch of series at a time, until the window holds as many as + * the reader asked for. + * + * A build is resumable: the first run gathers every candidate and reads the first batches; if + * the target is not met before [RUN_BUDGET_MS] runs out, the queue is kept and the job books + * itself again for the next day at the same hour, until it is. So a target of five hundred takes + * a few quiet nights and a target of fifty takes one, with nothing to configure. Each batch is + * written as it lands, so the window grows as the build goes and a killed run loses one batch. + * + * Modes: [MODE_FULL] discards everything and starts over (the schedule, "rebuild from scratch"); + * [MODE_CONTINUE] reads one more batch off the queue, or starts a full build when there is no + * queue (the "refresh" button: one batch per tap); [MODE_RESUME] carries a scheduled build on + * (the next-day booking). + */ +class RecommendationJob(private val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + val mode = inputData.getString(MODE) ?: MODE_FULL + val manual = inputData.getBoolean(MANUAL, false) + val preferences = Injekt.get() + if (!manual && preferences.recommendationHour().get() < 0) return Result.success() + // A long run: promoted so the system does not cut it short. Refused on some devices when + // the app is in the background; the run then simply goes on as ordinary work. + runCatching { setForeground(getForegroundInfo()) } + val store = Injekt.get() + val build = Injekt.get() + val target = preferences.recommendationTarget().get() + val deadline = System.currentTimeMillis() + RUN_BUDGET_MS + return try { + var snapshot = store.read() + val fresh = mode == MODE_FULL || snapshot == null || (mode == MODE_CONTINUE && snapshot.pending.isEmpty()) + if (fresh) { + // From zero: the old list and the rows it left behind go before anything is built, + // so nothing of the last run can leak into this one. + runCatching { Injekt.get().await() } + // What the trackers know about the library comes first: it feeds the profile below. + runCatching { + Injekt.get().await { done, total -> report(BuildProgress(BuildProgress.Stage.ENRICHING, done, total)) } + }.onFailure { Logger.w(it) { "Tracker enrichment failed; building on what the sources said" } } + // Same names, other sources: where else the library could be read from. + runCatching { + Injekt.get().await { done, total -> report(BuildProgress(BuildProgress.Stage.MERGES, done, total)) } + }.onFailure { Logger.w(it) { "Merge lookup failed" } } + snapshot = build.search(onProgress = ::report) + store.write(snapshot) + } + + var batches = 0 + // "Fetch 50 more" means fifty more, target or no target; the schedule stops at the target. + while (snapshot!!.pending.isNotEmpty() && (mode == MODE_CONTINUE || snapshot.shown < target)) { + if (mode == MODE_CONTINUE && batches >= 1) break + if (System.currentTimeMillis() > deadline) break + snapshot = build.detailBatch(snapshot) { progress -> + // Progress counts towards the target, not the batch: that is the number the + // reader is waiting on. + report(BuildProgress(BuildProgress.Stage.DETAILS, snapshot!!.shown, target)) + } + store.write(snapshot) + batches++ + // A breath between batches, so a build is a hum and not a burst. + if (snapshot.shown < target && snapshot.pending.isNotEmpty()) delay(BATCH_PAUSE_MS) + } + + context.notificationManager.cancel(Notifications.ID_RECOMMENDATIONS_PROGRESS) + val unfinished = snapshot.shown < target && snapshot.pending.isNotEmpty() + // Only a scheduled build books its own continuation, and only while the schedule is on. + if (unfinished && mode != MODE_CONTINUE && preferences.recommendationHour().get() >= 0) resumeTomorrow(context) + if (manual) notify(snapshot.shown, unfinished) + Result.success() + } catch (e: Exception) { + context.notificationManager.cancel(Notifications.ID_RECOMMENDATIONS_PROGRESS) + Logger.e(e) { "Recommendation build failed" } + if (manual) Result.failure() else Result.retry() + } + } + + private suspend fun report(progress: BuildProgress) { + setProgress(workDataOf(STAGE to progress.stage.name, DONE to progress.done, TOTAL to progress.total)) + showProgress(progress) + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = progressNotification(BuildProgress(BuildProgress.Stage.PROFILE)) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(Notifications.ID_RECOMMENDATIONS_PROGRESS, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(Notifications.ID_RECOMMENDATIONS_PROGRESS, notification) + } + } + + /** One quiet, ongoing notification that follows the build stage by stage. */ + private fun showProgress(progress: BuildProgress) { + context.notificationManager.notify(Notifications.ID_RECOMMENDATIONS_PROGRESS, progressNotification(progress)) + } + + private fun progressNotification(progress: BuildProgress): Notification { + val localeContext = context.localeContext + return localeContext.notificationBuilder(Notifications.CHANNEL_RECOMMENDATIONS_PROGRESS) { + setSmallIcon(R.drawable.ic_karasu) + setContentTitle(localeContext.getString(MR.strings.recommendation_refreshing)) + setContentText(progress.label(localeContext)) + setProgress(progress.total, progress.done, progress.total == 0) + setOngoing(true) + setOnlyAlertOnce(true) + }.build() + } + + private fun notify(count: Int, unfinished: Boolean) { + val localeContext = context.localeContext + val open = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java) + .setAction(MainActivity.SHORTCUT_RECOMMENDATIONS) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = localeContext.notificationBuilder(Notifications.CHANNEL_RECOMMENDATIONS) { + setSmallIcon(R.drawable.ic_karasu) + setContentTitle(localeContext.getString(MR.strings.recommendations)) + setContentText( + localeContext.getString(MR.plurals.recommendation_built, count, count) + + if (unfinished) " · " + localeContext.getString(MR.strings.recommendation_more_pending) else "", + ) + setContentIntent(open) + setAutoCancel(true) + }.build() + context.notificationManager.notify(Notifications.ID_RECOMMENDATIONS, notification) + } + + companion object { + private const val TAG = "Recommendations" + private const val WORK_NAME = "Recommendations" + private const val NOW_WORK_NAME = "RecommendationsNow" + private const val MANUAL = "manual" + private const val MODE = "mode" + const val MODE_FULL = "full" + const val MODE_CONTINUE = "continue" + const val MODE_RESUME = "resume" + private const val RESUME_WORK_NAME = "RecommendationsResume" + + /** How long one run may work before it hands the rest to the next day. */ + private const val RUN_BUDGET_MS = 40L * 60 * 1000 + + /** Between batches. */ + private const val BATCH_PAUSE_MS = 3_000L + private const val STAGE = "stage" + private const val DONE = "done" + private const val TOTAL = "total" + + fun setupTask(context: Context) { + val preferences = Injekt.get() + val hour = preferences.recommendationHour().get() + val wm = WorkManager.getInstance(context) + if (hour < 0) { + wm.cancelUniqueWork(WORK_NAME) + return + } + val network = if (preferences.recommendationOnlyOnWifi().get()) NetworkType.UNMETERED else NetworkType.CONNECTED + val days = preferences.recommendationIntervalDays().get().coerceAtLeast(1).toLong() + val request = PeriodicWorkRequestBuilder(days, TimeUnit.DAYS) + .setInputData(workDataOf(MODE to MODE_FULL)) + .setInitialDelay(AutoMigrateJob.millisUntil(AutoMigrateJob.DAILY, hour), TimeUnit.MILLISECONDS) + .setConstraints(Constraints.Builder().setRequiredNetworkType(network).build()) + .addTag(TAG) + .build() + // Replaced rather than updated: this job is about *when* it fires. + wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, request) + } + + /** + * A run now. One at a time: asking twice does not queue two. + * + * @param mode [MODE_CONTINUE] reads one more batch (or starts over when there is no + * queue); [MODE_FULL] discards everything first. On a full run the screen is emptied at + * once — the reader asked for fresh, and the old list is what they asked to be rid of. + */ + fun runNow(context: Context, mode: String = MODE_CONTINUE) { + if (mode == MODE_FULL) Injekt.get().clear() + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(MANUAL to true, MODE to mode)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .addTag(TAG) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(NOW_WORK_NAME, ExistingWorkPolicy.KEEP, request) + } + + /** The rest of a scheduled build, tomorrow at the same hour. */ + private fun resumeTomorrow(context: Context) { + val preferences = Injekt.get() + val hour = preferences.recommendationHour().get().coerceAtLeast(0) + val network = if (preferences.recommendationOnlyOnWifi().get()) NetworkType.UNMETERED else NetworkType.CONNECTED + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(MODE to MODE_RESUME)) + .setInitialDelay(AutoMigrateJob.millisUntil(AutoMigrateJob.DAILY, hour), TimeUnit.MILLISECONDS) + .setConstraints(Constraints.Builder().setRequiredNetworkType(network).build()) + .addTag(TAG) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(RESUME_WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + + /** Whether a reader-started rebuild is queued or running. */ + fun isRunningNowFlow(context: Context): Flow = progressFlow(context).map { it != null } + + /** + * Where the reader-started rebuild is, or null when none is queued or running. Queued + * but not started yet reads as the first stage, so the reader sees something at once. + */ + fun progressFlow(context: Context): Flow = stateFlow(context).map { (it as? RunState.Running)?.progress } + + /** Whether the reader-started rebuild is running, and if not, how the last one ended. */ + sealed interface RunState { + data class Running(val progress: BuildProgress) : RunState + data object Idle : RunState + data object Failed : RunState + } + + fun stateFlow(context: Context): Flow = + // By tag, so a scheduled run and a next-day resumption show their progress too. Only + // RUNNING counts: a periodic request sits ENQUEUED between runs, always. + WorkManager.getInstance(context).getWorkInfosByTagFlow(TAG).map { list -> + val info = list.firstOrNull { it.state == WorkInfo.State.RUNNING } + if (info == null) { + return@map if (list.any { it.state == WorkInfo.State.FAILED }) RunState.Failed else RunState.Idle + } + val stage = info.progress.getString(STAGE)?.let { runCatching { BuildProgress.Stage.valueOf(it) }.getOrNull() } + ?: BuildProgress.Stage.PROFILE + RunState.Running(BuildProgress(stage, info.progress.getInt(DONE, 0), info.progress.getInt(TOTAL, 0))) + } + + /** The stage as a line of text, with the count when the stage has one. */ + fun BuildProgress.label(context: Context): String = when (stage) { + BuildProgress.Stage.ENRICHING -> context.getString(MR.strings.recommendation_stage_enriching, done, total) + BuildProgress.Stage.MERGES -> context.getString(MR.strings.recommendation_stage_merges, done, total) + BuildProgress.Stage.PROFILE -> context.getString(MR.strings.recommendation_stage_profile) + BuildProgress.Stage.SEARCHING -> context.getString(MR.strings.recommendation_stage_searching, done, total) + BuildProgress.Stage.TRACKERS -> context.getString(MR.strings.recommendation_stage_trackers, done, total) + BuildProgress.Stage.DETAILS -> context.getString(MR.strings.recommendation_stage_details, done, total) + BuildProgress.Stage.RANKING -> context.getString(MR.strings.recommendation_stage_ranking) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index ccdb60d17d..294b483148 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -244,7 +244,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI // Karasu's own MyAnimeList client, registered as an android app: public, no secret, PKCE. // Its single registered redirect is karasu://myanimelist-auth, which is why neither the // authorize URL nor the token exchange sends redirect_uri. - private const val CLIENT_ID = "2fd75c9c4d50cd157b1479216400f7f5" + const val CLIENT_ID = "2fd75c9c4d50cd157b1479216400f7f5" private const val BASE_OAUTH_URL = "https://myanimelist.net/v1/oauth2" private const val BASE_API_URL = "https://api.myanimelist.net/v2" diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/RecommendationSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/RecommendationSource.kt new file mode 100644 index 0000000000..2befb41e83 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/RecommendationSource.kt @@ -0,0 +1,242 @@ +package eu.kanade.tachiyomi.source + +import android.content.Context +import android.text.format.DateUtils +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import karasu.domain.recommendation.BuildRecommendations.Companion.key +import karasu.domain.manga.interactor.GetLibraryManga +import karasu.domain.recommendation.GetTasteProfile +import karasu.domain.recommendation.RecommendationFeedback +import karasu.domain.recommendation.RecommendationFeedbackStore +import karasu.domain.recommendation.normalizeTag +import karasu.domain.recommendation.RecommendationStore +import karasu.domain.recommendation.Recommended +import karasu.domain.recommendation.rerank +import karasu.i18n.MR +import karasu.util.lang.getString +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import uy.kohesive.injekt.injectLazy + +/** + * The recommendations, shown as if they were a catalogue so the source browser draws them. + * + * A shop window, not a catalogue: it owns no manga row. Every entry it returns carries the real + * source in [SOURCE_MEMO], and the browser resolves it there before showing it, so the row opened, + * added to the library or backed up is always the real one. That is also why the fetch methods + * below throw — nothing ever asks this source for details, chapters or pages. + * + * What it shows is the last [RecommendationStore] snapshot, built by the nightly job. When there + * is none yet the job is started and the page comes back empty: the browser is watching the job + * and reloads when it lands, which beats a spinner nobody can leave. Moving a slider on the tags + * screen re-ranks the snapshot without going back to the sources; only the job fetches again. + */ +class RecommendationSource(private val context: Context) : CatalogueSource { + + private val getTasteProfile: GetTasteProfile by injectLazy() + private val store: RecommendationStore by injectLazy() + private val feedback: RecommendationFeedbackStore by injectLazy() + private val getLibraryManga: GetLibraryManga by injectLazy() + + override val id = ID + override val name = context.getString(MR.strings.recommendations) + override val lang = "other" + override val supportsLatest = true + + override fun toString() = name + + /** + * The tags group is the profile's own tags, strongest first, so the reader can narrow the + * window to "Romance and Comedy" without leaving it. Read from the snapshot rather than the + * database: this is called synchronously, and the snapshot already carries the profile it + * was ranked against. + */ + override fun getFilterList(): FilterList { + val snapshot = store.read() + // Only tags at least one entry carries: a tag with nothing behind it is a checkbox that + // empties the list. + val present = snapshot?.items.orEmpty() + .flatMap { it.tags }.mapNotNull { normalizeTag(it)?.lowercase() }.toSet() + val tags = snapshot?.profileTags?.filterValues { it > 0f }?.keys + ?.filter { it.lowercase() in present } + ?.take(TAG_FILTERS).orEmpty() + return FilterList( + listOfNotNull( + HideUntrustedDates(context), + IncludeLibrary(context), + OrderBy(context), + tags.takeIf { it.isNotEmpty() }?.let { TagGroup(context, it) }, + ), + ) + } + + /** Everything, best match first. */ + override suspend fun getPopularManga(page: Int) = page(page, "", FilterList()) + + /** Few chapters or a recent start. */ + override suspend fun getLatestUpdates(page: Int) = page(page, "", FilterList(), onlyNew = true) + + override suspend fun getSearchManga(page: Int, query: String, filters: FilterList) = page(page, query, filters) + + private suspend fun page(page: Int, query: String, filters: FilterList, onlyNew: Boolean = false): MangasPage { + val includeLibrary = filters.filterIsInstance().firstOrNull()?.state ?: false + // On by default, so the popular and latest tabs (no filters) hide them too. + val hideUntrusted = filters.filterIsInstance().firstOrNull()?.state ?: true + val order = filters.filterIsInstance().firstOrNull()?.state?.index ?: 0 + // Every checked tag must be on the entry: checking two narrows, it does not widen. + val wanted = filters.filterIsInstance().firstOrNull()?.state + ?.filter { it.state }?.map { it.name.lowercase() }.orEmpty() + val now = System.currentTimeMillis() + + var items = items().asSequence() + .filter { it.chapters != 0 && !it.isOneShot && !it.isStale(now) } + .filter { !hideUntrusted || !it.datesUntrusted } + .filter { includeLibrary || !it.fromLibrary } + .filter { !onlyNew || it.isNew(now) } + .filter { query.isBlank() || it.title.contains(query, ignoreCase = true) } + .filter { item -> + wanted.isEmpty() || item.tags.mapNotNull { normalizeTag(it)?.lowercase() }.let { its -> wanted.all { it in its } } + } + items = when (order) { + // A source that reports no upload dates leaves firstUpload null; the chapter count is + // the next best clue to how new a series is, and keeps the order from being a no-op. + 1 -> items.sortedWith(compareByDescending { it.firstUpload ?: 0L }.thenBy { it.chapters ?: Int.MAX_VALUE }) + 2 -> items.sortedWith(compareBy { it.chapters ?: Int.MAX_VALUE }.thenByDescending { it.score }) + else -> items // Already by score, unfetched last. + } + + val all = items.toList() + val from = (page - 1) * PAGE_SIZE + return MangasPage(all.drop(from).take(PAGE_SIZE).map { it.toSManga() }, hasNextPage = from + PAGE_SIZE < all.size) + } + + private suspend fun items(): List { + val profile = getTasteProfile.await() + val snapshot = store.read() ?: run { + // Unique work: if a rebuild is already running this is a no-op. + RecommendationJob.runNow(context, RecommendationJob.MODE_FULL) + return emptyList() + } + // Ten series added since the last build is a window that no longer knows the library. + // Asked once; the job is unique, so page after page does not queue it again. + val librarySize = getLibraryManga.await().distinctBy { it.manga.id }.size + if (snapshot.librarySize > 0 && librarySize - snapshot.librarySize >= REBUILD_AFTER_ADDS) RecommendationJob.runNow(context, RecommendationJob.MODE_FULL) + val hidden = feedback.hidden() + val hiddenKeys = hidden.map { it.titleKey }.toSet() + // A changed profile (a slider, a merge, other categories) re-ranks on the spot; the + // result is written back so the filter sheet, which reads the file, sees the same tags. + val reranked = snapshot.rerank(profile) + if (reranked !== snapshot) store.writeIfUnchanged(snapshot.builtAt, reranked) + return reranked.items.filterNot { item -> + item.title.key() in hiddenKeys || hidden.any { it.sourceId == item.sourceId && it.url == item.url } + } + } + + /** + * One line on why an entry is here and what it is: the reason, the size, when it last moved. + * For the long-press dialog, where the grade shows nothing but a cover. + */ + fun describe(sourceId: Long, url: String): String? { + val item = store.read()?.items?.firstOrNull { it.sourceId == sourceId && it.url == url } ?: return null + val now = System.currentTimeMillis() + val parts = buildList { + item.via?.let { add(context.getString(MR.strings.recommendation_like, it)) } + ?: item.because.takeIf { it.isNotEmpty() }?.let { add(it.joinToString(" · ")) } + if (item.fromCatalog && item.averageScore != null) add(context.getString(MR.strings.recommendation_anilist_score, item.averageScore)) + item.chapters?.let { add(context.getString(MR.plurals.recommendation_chapters, it, it)) } + item.lastUpload?.takeIf { it > 0 }?.let { + add(context.getString(MR.strings.recommendation_last_chapter, DateUtils.getRelativeTimeSpanString(it, now, DateUtils.DAY_IN_MILLIS))) + } + if (item.completed) add(context.getString(MR.strings.completed)) + if (item.isNew(now)) add(context.getString(MR.strings.recommendation_badge_new)) + if (item.isQuiet(now)) add(context.getString(MR.strings.recommendation_badge_quiet)) + if (item.explore) add(context.getString(MR.strings.recommendation_badge_explore)) + if (item.datesUntrusted) add(context.getString(MR.strings.recommendation_badge_untrusted_dates)) + } + return parts.joinToString(" · ").ifBlank { null } + } + + /** When the list on disk was built, or null when there is none. The browser compares it to what it shows. */ + fun currentBuild(): Long? = store.read()?.builtAt + + /** The reader's verdict on one entry. Hides it; "not interested" also teaches the profile. */ + fun feedback(sourceId: Long, url: String, kind: RecommendationFeedback.Kind) { + val item = store.read()?.items?.firstOrNull { it.sourceId == sourceId && it.url == url } + feedback.add( + RecommendationFeedback( + sourceId = sourceId, + url = url, + titleKey = (item?.title ?: url).key(), + kind = kind, + because = item?.because.orEmpty(), + at = System.currentTimeMillis(), + ), + ) + } + + private fun Recommended.toSManga() = SManga.create().also { + it.url = url + it.title = title + it.thumbnail_url = thumbnail + // The row the browser creates copies this in: the real tags, not the reason. + it.genre = tags.joinToString(", ").ifBlank { null } + it.memo = buildJsonObject { + put(SOURCE_MEMO, JsonPrimitive(sourceId)) + put( + BECAUSE_MEMO, + JsonPrimitive( + via?.let { context.getString(MR.strings.recommendation_like, it) } + ?: because.joinToString(" · "), + ), + ) + } + } + + private class IncludeLibrary(context: Context) : Filter.CheckBox(context.getString(MR.strings.recommendation_include_library)) + + private class HideUntrustedDates(context: Context) : + Filter.CheckBox(context.getString(MR.strings.recommendation_hide_untrusted_dates), state = true) + + private class TagFilter(name: String) : Filter.CheckBox(name) + + private class TagGroup(context: Context, tags: List) : + Filter.Group(context.getString(MR.strings.recommendation_filter_tags), tags.map(::TagFilter)) + + private class OrderBy(context: Context) : Filter.Sort( + context.getString(MR.strings.order_by), + arrayOf( + context.getString(MR.strings.recommendation_sort_affinity), + context.getString(MR.strings.recommendation_sort_newest), + context.getString(MR.strings.recommendation_sort_shortest), + ), + Selection(0, false), + ) + + override suspend fun getMangaDetails(manga: SManga): SManga = throw UnsupportedOperationException("Not a catalogue") + override suspend fun getChapterList(manga: SManga): List = throw UnsupportedOperationException("Not a catalogue") + override suspend fun getPageList(chapter: SChapter): List = throw UnsupportedOperationException("Not a catalogue") + + companion object { + const val ID = 1L + + /** Memo key holding the id of the source the recommended manga really belongs to. */ + const val SOURCE_MEMO = "karasu.recommendation.source" + + /** Memo key holding the reason, ready to show: the tags it was recommended for. */ + const val BECAUSE_MEMO = "karasu.recommendation.because" + + private const val PAGE_SIZE = 30 + + /** How many of the profile's tags the filter sheet offers. */ + private const val TAG_FILTERS = 40 + + /** New library entries since the snapshot that make it stale enough to rebuild now. */ + private const val REBUILD_AFTER_ADDS = 10 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index b5586eabe8..d76276b520 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -44,7 +44,12 @@ class SourceManager( scope.launch { extensionManager.installedExtensionsFlow .collectLatest { extensions -> - val mutableMap = ConcurrentHashMap(mapOf(LocalSource.ID to LocalSource(context))) + val mutableMap = ConcurrentHashMap( + mapOf( + LocalSource.ID to LocalSource(context), + RecommendationSource.ID to RecommendationSource(context), + ), + ) extensions.forEach { extension -> extension.sources.forEach { mutableMap[it.id] = it diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt index f2499ca788..d71b8f54ca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/calendar/ReleaseCalendarController.kt @@ -20,6 +20,9 @@ import karasu.domain.manga.interval.ReleaseCalendar import karasu.domain.manga.interval.ReleaseEstimate import karasu.domain.manga.interval.ReleaseSchedule import karasu.domain.manga.interval.calendar +import karasu.domain.manga.interval.month +import karasu.domain.manga.interval.ReleaseMonth +import java.time.YearMonth import karasu.presentation.library.ReleaseCalendarScreen import uy.kohesive.injekt.injectLazy @@ -37,6 +40,9 @@ class ReleaseCalendarController : BaseComposeController() { private var schedule by mutableStateOf(null) private var calendar by mutableStateOf(null) + private var month by mutableStateOf(null) + private var monthView by mutableStateOf(preferences.releaseCalendarMonthView().get()) + private var onlyCaughtUp by mutableStateOf(preferences.releaseCalendarOnlyCaughtUp().get()) /** * Reloaded on every attach and every resume rather than once per composition. @@ -51,12 +57,11 @@ class ReleaseCalendarController : BaseComposeController() { val categories = preferences.releaseScheduleCategories().get() .mapNotNull { it.toIntOrNull() } .toSet() - val loaded = withIOContext { getReleaseSchedule.await(categories) } + val loaded = withIOContext { getReleaseSchedule.await(categories, onlyCaughtUp = onlyCaughtUp) } + val grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()) schedule = loaded - calendar = loaded.calendar( - dayCount = DAYS_SHOWN, - grace = ReleaseEstimate.graceOf(preferences.releaseMissGraceDays().get()), - ) + calendar = loaded.calendar(dayCount = DAYS_SHOWN, grace = grace) + month = loaded.month(YearMonth.now(), grace = grace) } } @@ -75,6 +80,18 @@ class ReleaseCalendarController : BaseComposeController() { ReleaseCalendarScreen( schedule = schedule, calendar = calendar, + month = month, + monthView = monthView, + onMonthViewChange = { + monthView = it + preferences.releaseCalendarMonthView().set(it) + }, + onlyCaughtUp = onlyCaughtUp, + onOnlyCaughtUpChange = { + onlyCaughtUp = it + preferences.releaseCalendarOnlyCaughtUp().set(it) + load() + }, contentPadding = PaddingValues(bottom = 32.dp), onMangaClick = { manga -> manga.id?.let { router.pushController(MangaDetailsController(it).withFadeTransaction()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 418acb08af..abe0b07f6e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -103,6 +103,9 @@ import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.ui.setting.SettingsLegacyController import eu.kanade.tachiyomi.ui.setting.controllers.SettingsMainController import eu.kanade.tachiyomi.ui.source.BrowseController +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.source.browse.BrowseSourceController import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchController import eu.kanade.tachiyomi.util.manga.MangaCoverMetadata @@ -147,6 +150,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import karasu.core.migration.Migrator import karasu.domain.base.BasePreferences @@ -1068,6 +1073,14 @@ open class MainActivity : BaseActivity() { router.pushController(ReleaseCalendarController().withFadeTransaction()) } } + SHORTCUT_RECOMMENDATIONS -> { + nav.selectedItemId = R.id.nav_browse + nav.post { + (Injekt.get().get(RecommendationSource.ID) as? CatalogueSource)?.let { + router.pushController(BrowseSourceController(it).withFadeTransaction()) + } + } + } SHORTCUT_MIGRATE_REVIEW -> { // The targets the nightly pass found but would not apply on its own. Same review // screen as a hand-started migration, with the picks already filled in. @@ -1652,6 +1665,7 @@ open class MainActivity : BaseActivity() { // Shortcut actions const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY" const val SHORTCUT_RELEASE_CALENDAR = "eu.kanade.tachiyomi.SHOW_RELEASE_CALENDAR" + const val SHORTCUT_RECOMMENDATIONS = "eu.kanade.tachiyomi.SHOW_RECOMMENDATIONS" const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED" const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ" const val SHORTCUT_BROWSE = "eu.kanade.tachiyomi.SHOW_BROWSE" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 09deff328b..b61d7829d0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -92,6 +92,10 @@ import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersSortBottomSheet import eu.kanade.tachiyomi.ui.manga.chapter.languageSources import eu.kanade.tachiyomi.ui.manga.chapter.showChapterLanguageDialog import eu.kanade.tachiyomi.ui.manga.merge.MergeSearchController +import karasu.domain.migration.MangaAliases +import karasu.domain.recommendation.MergeSuggestionsStore +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import eu.kanade.tachiyomi.ui.manga.merge.MergedSourceRow import eu.kanade.tachiyomi.ui.manga.merge.showMergedSourcesDialog import eu.kanade.tachiyomi.ui.manga.track.TrackItem @@ -496,6 +500,31 @@ class MangaDetailsController : } } + /** + * The nightly pass found this series on another of the reader's sources: one tap merges it. + * Offered once per entry and source, whatever the answer. + */ + private fun offerMergeSuggestion(view: View) { + val manga = manga ?: return + if (!manga.favorite) return + val store = Injekt.get() + val suggestion = store.pending(manga.id).firstOrNull() ?: return + val sourceName = presenter.sourceManager.get(suggestion.sourceId)?.name ?: return + val mangaId = manga.id ?: return + snack = view.snack(view.context.getString(MR.strings.merge_suggestion, sourceName), Snackbar.LENGTH_LONG) { + setAction(MR.strings.merge_suggestion_action) { presenter.addMergedSource(suggestion.sourceId, suggestion.url) } + // Either way it is not offered again: merged, it is in; waved away, the reader said no. + // The nightly look would otherwise keep serving the same find until its recheck. + addCallback( + object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + store.ignore(mangaId, suggestion.sourceId) + } + }, + ) + } + } + override fun onDestroyView(view: View) { snack?.dismiss() snack = null @@ -909,7 +938,11 @@ class MangaDetailsController : } } + /** Offered once per screen, the first time the entry is on screen. */ + private var mergeOffered = false + fun updateHeader() { + if (!mergeOffered) view?.let { mergeOffered = true; offerMergeSuggestion(it) } view ?: return binding.swipeRefresh.isRefreshing = presenter.isLoading adapter?.setChapters(presenter.chapters) @@ -1900,11 +1933,18 @@ class MangaDetailsController : .show() }, onAdd = { - router.pushController( - MergeSearchController(presenter.manga) - .apply { targetController = this@MangaDetailsController } - .withFadeTransaction(), - ) + val manga = presenter.manga + viewScope.launchIO { + val excluded = presenter.mergedSourceIds() + manga.source + val aliases = MangaAliases(Injekt.get(), Injekt.get()).local(manga) + withUIContext { + router.pushController( + MergeSearchController(manga, excluded, aliases) + .apply { targetController = this@MangaDetailsController } + .withFadeTransaction(), + ) + } + } }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index e60b4410d9..cd399fe6a0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -797,6 +797,9 @@ class MangaDetailsPresenter( * The row for the other source already exists — global search inserts every result it * shows — so this only records the link and pulls that row's chapters in. */ + /** The sources already merged into this entry. */ + suspend fun mergedSourceIds(): Set = mergedSources.await(manga.id!!).map { it.source }.toSet() + fun addMergedSource(source: Long, url: String) { presenterScope.launchIO { if (!mergedSources.addAtEnd(mangaId, source, url, ownSource = manga.source)) return@launchIO diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt index bb73afb6dd..667efb221e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/merge/MergeSearchController.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchCardAdapter import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchController +import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchPresenter /** * Global search that merges the picked result into an existing manga instead of opening it. @@ -16,15 +17,41 @@ import eu.kanade.tachiyomi.ui.source.globalsearch.GlobalSearchController */ class MergeSearchController( initialQuery: String? = null, + excludedSources: Set = emptySet(), + aliases: List = emptyList(), ) : GlobalSearchController( initialQuery, - bundle = bundleOf(QUERY to initialQuery), + bundle = bundleOf( + QUERY to initialQuery, + EXCLUDED to excludedSources.toLongArray(), + ALIASES to aliases.toTypedArray(), + ), ) { @Suppress("unused") - constructor(bundle: Bundle) : this(bundle.getString(QUERY)) + constructor(bundle: Bundle) : this( + bundle.getString(QUERY), + bundle.getLongArray(EXCLUDED)?.toSet().orEmpty(), + bundle.getStringArray(ALIASES)?.toList().orEmpty(), + ) - constructor(manga: Manga) : this(manga.originalTitle) + /** + * @param excludedSources the series' own source and the ones already merged in: offering + * them again would only offer the same chapters twice. + * @param aliases the other names the series goes by, tried on a source that found nothing + * under the library title. + */ + constructor(manga: Manga, excludedSources: Set, aliases: List) : + this(manga.originalTitle, excludedSources, aliases) + + // Runs from the base constructor, before this class's own fields exist: everything it needs + // has to come from the bundle, which is why the constructor puts it there. + override fun createPresenter() = GlobalSearchPresenter( + initialQuery, + extensionFilter, + excludedSources = args.getLongArray(EXCLUDED)?.toSet().orEmpty(), + fallbackQueries = args.getStringArray(ALIASES)?.toList().orEmpty(), + ) override fun onMangaClick(manga: Manga) { val target = targetController as? MangaDetailsController ?: return @@ -40,5 +67,7 @@ class MergeSearchController( companion object { const val QUERY = "query" + private const val EXCLUDED = "excluded" + private const val ALIASES = "aliases" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageOffsetStore.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageOffsetStore.kt new file mode 100644 index 0000000000..562889f84d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/PageOffsetStore.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.ui.reader + +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import kotlinx.serialization.json.Json + +/** + * How far into its last read page each chapter was left, 0..1 of the page's height. + * + * A page index is the unit the rest of the app saves, and in a webtoon one page can be a strip + * several screens tall — resuming at its top loses minutes of reading. A fraction rather than + * pixels, so it survives a different phone, width or zoom. + * + * ponytail: a JSON map in one preference, capped at [MAX] chapters, most recent kept. Not the + * chapter's `memo` — that one belongs to the source and is overwritten on every chapter sync. + * A column on `chapters` is the upgrade if this should reach backups. + */ +class PageOffsetStore(private val preferences: PreferencesHelper) { + + private fun read(): Map = + runCatching { Json.decodeFromString>(preferences.readerPageOffsets().get()) } + .getOrDefault(emptyMap()) + + fun get(chapterId: Long?): Float = chapterId?.let { read()[it.toString()] } ?: 0f + + fun set(chapterId: Long, fraction: Float) { + val map = read().toMutableMap() + // Re-inserted so the entry moves to the end: insertion order is recency. + map.remove(chapterId.toString()) + if (fraction > 0f) map[chapterId.toString()] = fraction + while (map.size > MAX) map.remove(map.keys.first()) + preferences.readerPageOffsets().set(Json.encodeToString(map)) + } + + private companion object { + const val MAX = 500 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 189c849560..9486000ed5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -1539,6 +1539,9 @@ class ReaderActivity : BaseActivity() { * bottom menu and delegates the change to the view model. */ @SuppressLint("SetTextI18n") + /** The webtoon viewer's report of how far down the current page the screen is. */ + fun onPageOffsetChanged(page: ReaderPage, fraction: Float) = viewModel.onPageOffsetChanged(page, fraction) + fun onPageSelected(page: ReaderPage, hasExtraPage: Boolean) { viewModel.onPageSelected(page, hasExtraPage) val pages = page.chapter.pages ?: return diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt index 3b66dee3ea..b4ecc0e22d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderViewModel.kt @@ -108,6 +108,7 @@ class ReaderViewModel( private val downloadManager: DownloadManager = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), + private val pageOffsets: PageOffsetStore = PageOffsetStore(Injekt.get()), private val chapterFilter: ChapterFilter = Injekt.get(), private val storageManager: StorageManager = Injekt.get(), private val downloadPreferences: DownloadPreferences = Injekt.get(), @@ -199,7 +200,7 @@ class ReaderViewModel( .onEach { currentChapter -> chapterId = currentChapter.chapter.id!! if (secondRun || !currentChapter.chapter.read) { - currentChapter.requestedPage = currentChapter.chapter.last_page_read + currentChapter.requestLastRead() } secondRun = true } @@ -740,8 +741,31 @@ class ReaderViewModel( * Saves this [readerChapter]'s progress (last read page and whether it's read). * If incognito mode isn't on or has at least 1 tracker */ + /** Where the reader left this chapter: the page the app always saved, and how far into it. */ + private fun ReaderChapter.requestLastRead() { + requestedPage = chapter.last_page_read + requestedOffset = pageOffsets.get(chapter.id) + } + + /** + * How far into the current page the screen is, as last reported by the viewer. Kept in + * memory and written with the page — or on leaving, since a strip several screens tall is + * read for minutes without the page ever changing. + */ + private var pageOffset: Pair? = null + + fun onPageOffsetChanged(page: ReaderPage, fraction: Float) { + pageOffset = page.chapter.chapter.id!! to fraction + } + + private fun savePageOffset(chapterId: Long) { + if (preferences.incognitoMode().get()) return + val (id, fraction) = pageOffset ?: return + pageOffsets.set(chapterId, if (id == chapterId) fraction else 0f) + } + private suspend fun saveChapterProgress(readerChapter: ReaderChapter, page: ReaderPage, hasExtraPage: Boolean) { - readerChapter.requestedPage = readerChapter.chapter.last_page_read + readerChapter.requestLastRead() getChapter.awaitById(readerChapter.chapter.id!!)?.let { dbChapter -> readerChapter.chapter.bookmark = dbChapter.bookmark } @@ -750,6 +774,7 @@ class ReaderViewModel( if (shouldTrack && page.status !is Page.State.Error) { readerChapter.chapter.last_page_read = page.index readerChapter.chapter.pages_left = (readerChapter.pages?.size ?: page.index) - page.index + savePageOffset(readerChapter.chapter.id!!) // For double pages, check if the second to last page is doubled up if ( (readerChapter.pages?.lastIndex == page.index && page.firstHalf != true) || @@ -803,6 +828,7 @@ class ReaderViewModel( fun flushReadTimer() { getCurrentChapter()?.let { + it.chapter.id?.let(::savePageOffset) viewModelScope.launchNonCancellableIO { saveChapterHistory(it) } @@ -885,7 +911,7 @@ class ReaderViewModel( if (currChapters != null) { // Save current page val currChapter = currChapters.currChapter - currChapter.requestedPage = currChapter.chapter.last_page_read + currChapter.requestLastRead() mutableState.update { it.copy( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt index f4424e0e59..f2805fcfd9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderChapter.kt @@ -21,6 +21,9 @@ data class ReaderChapter(val chapter: Chapter) { var requestedPage: Int = 0 + /** How far into [requestedPage] to land, 0..1. Only the webtoon viewer honours it. */ + var requestedOffset: Float = 0f + private var references = 0 fun ref() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt index 7cb1ab0dfc..5d505db0ff 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt @@ -28,24 +28,26 @@ class WebtoonLayoutManager(activity: ReaderActivity, private val extraLayoutSpac } /** - * Returns the position of the last item whose end side is visible on screen. + * Returns the position of the last item whose end side is visible on screen, or of the item + * spanning the whole screen when one image is taller than it. + * + * Without the second case a page taller than the screen was never "current": its end was + * off screen, so the page before it was reported instead, and that is what got saved as the + * reading position. Ported from Mihon (mihonapp/mihon#562). */ fun findLastEndVisibleItemPosition(): Int { ensureLayoutState() - @ViewBoundsCheck.ViewBounds val preferredBoundsFlag = - (ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE) - - val fromIndex = childCount - 1 - val toIndex = -1 - - val child = if (mOrientation == HORIZONTAL) { - mHorizontalBoundCheck - .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) - } else { - mVerticalBoundCheck - .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0) + val callback = if (mOrientation == HORIZONTAL) mHorizontalBoundCheck else mVerticalBoundCheck + val start = callback.mCallback.parentStart + val end = callback.mCallback.parentEnd + for (i in childCount - 1 downTo 0) { + val child = getChildAt(i)!! + val childStart = callback.mCallback.getChildStart(child) + val childEnd = callback.mCallback.getChildEnd(child) + if (childEnd <= end || childStart < start) { + return getPosition(child) + } } - - return if (child == null) NO_POSITION else getPosition(child) + return NO_POSITION } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 6cdc1d4311..513b10d9f3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -251,6 +251,7 @@ class WebtoonPageHolder( progressContainer.isVisible = false removeErrorLayout() addTranslationsView() + page?.let(viewer::onPageDecoded) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index 5374755cc3..ca843bf243 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewGroup import android.view.ViewGroup.LayoutParams.MATCH_PARENT import androidx.core.view.isGone +import androidx.core.view.doOnLayout import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.WebtoonLayoutManager @@ -234,7 +235,9 @@ class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = fals if (recycler.isGone) { Logger.d { "Recycler first layout" } val pages = chapters.currChapter.pages ?: return - moveToPage(pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]) + val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)] + pendingOffset = chapters.currChapter.requestedOffset.takeIf { it > 0f }?.let { page to it } + moveToPage(page) recycler.isVisible = true } } @@ -255,12 +258,51 @@ class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = fals } } + /** + * How far down the item at [position] the top of the screen is, 0..1. Zero when the item + * starts on screen: resuming at its top is then at most a few lines early, never late. + */ + private fun offsetOf(position: Int): Float { + val child = layoutManager.findViewByPosition(position) ?: return 0f + if (child.height <= 0) return 0f + return ((layoutManager.paddingTop - child.top).toFloat() / child.height).coerceIn(0f, 1f) + } + + /** + * The page to land partway down once its image is decoded, and how far. Set on first layout + * and applied from [onPageDecoded]: before the decode the item has no height to scroll into. + */ + private var pendingOffset: Pair? = null + + fun onPageDecoded(page: ReaderPage) { + val (pending, fraction) = pendingOffset ?: return + if (pending != page) return + pendingOffset = null + val position = adapter.items.indexOf(page).takeIf { it != -1 } ?: return + val child = layoutManager.findViewByPosition(position) ?: return + // The decode may still have a layout pending that gives the item its real height, and a + // scroll must not be asked for from inside that layout. + child.doOnLayout { + recycler.post { + // A tenth of a screen of leeway before the saved spot, so the resume is never past it. + val offset = fraction * it.height - recycler.height * RESUME_LEEWAY + layoutManager.scrollToPositionWithOffset(position, -offset.toInt().coerceAtLeast(0)) + onScrolled() + } + } + } + fun onScrolled(pos: Int? = null) { val position = pos ?: layoutManager.findLastEndVisibleItemPosition() val item = adapter.items.getOrNull(position) val allowPreload = checkAllowPreload(item as? ReaderPage) + if (item is ReaderPage && pendingOffset == null) { + activity.onPageOffsetChanged(item, offsetOf(position)) + } if (item != null && currentPage != item) { currentPage = item + // Moved on before the saved spot could be applied: it no longer applies. + if (pendingOffset?.first != item) pendingOffset = null when (item) { is ReaderPage -> onPageSelected(item, allowPreload) is ChapterTransition -> onTransitionSelected(item) @@ -342,3 +384,6 @@ class WebtoonViewer(val activity: ReaderActivity, val hasMargins: Boolean = fals } private const val RECYCLER_VIEW_CACHE_SIZE = 4 + +/** Fraction of the screen to land above the saved spot when resuming. */ +private const val RESUME_LEEWAY = 0.1f diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt index b445a7dce2..33a945cb12 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/controllers/SettingsBrowseController.kt @@ -13,6 +13,15 @@ import karasu.i18n.MR import karasu.util.lang.getString import dev.icerock.moko.resources.compose.stringResource import eu.kanade.tachiyomi.data.migration.AutoMigrateJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob.Companion.label +import karasu.domain.recommendation.BuildProgress +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.Injekt +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.launchIn +import karasu.domain.recommendation.RecommendationStore +import android.text.format.DateUtils import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.changesIn @@ -32,6 +41,10 @@ import eu.kanade.tachiyomi.ui.setting.onChange import eu.kanade.tachiyomi.ui.setting.onClick import eu.kanade.tachiyomi.ui.setting.preference import eu.kanade.tachiyomi.ui.setting.preferenceCategory +import karasu.domain.category.interactor.GetCategories +import kotlinx.coroutines.runBlocking +import eu.kanade.tachiyomi.ui.setting.multiSelectListPreferenceMat +import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.ui.setting.summaryMRes as summaryRes import eu.kanade.tachiyomi.ui.setting.switchPreference import eu.kanade.tachiyomi.ui.setting.titleMRes as titleRes @@ -39,6 +52,8 @@ import eu.kanade.tachiyomi.util.lang.addBetaTag import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.formatHourOfDay import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.withUIContext +import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.view.setAction import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.withFadeTransaction @@ -51,6 +66,8 @@ import karasu.presentation.extension.repo.ExtensionRepoController class SettingsBrowseController : SettingsLegacyController() { + private val getCategories: GetCategories by injectLazy() + /** * Languages that actually have an installed source, sorted by name. * @@ -75,6 +92,7 @@ class SettingsBrowseController : SettingsLegacyController() { switchPreference { bindTo(preferences.hideInLibraryItems()) titleRes = MR.strings.hide_in_library_items + defaultValue = true } } @@ -300,6 +318,109 @@ class SettingsBrowseController : SettingsLegacyController() { } infoPreference(MR.strings.does_not_prevent_unofficial_nsfw) } + + preferenceCategory { + titleRes = MR.strings.recommendations + + // What gets built. + intListPreference(activity) { + bindTo(preferences.recommendationTarget()) + titleRes = MR.strings.recommendation_target + entries = RECOMMENDATION_TARGETS.map { context.getString(MR.plurals.recommendation_target_count, it, it) } + entryValues = RECOMMENDATION_TARGETS + defaultValue = 150 + } + + multiSelectListPreferenceMat(activity) { + bindTo(preferences.recommendationCategories()) + titleRes = MR.strings.recommendation_categories + summaryRes = MR.strings.recommendation_categories_summary + + // FIXME: Don't do blocking (same as the library settings) + val categories = listOf(Category.createDefault(context)) + runBlocking { getCategories.await() } + entries = categories.map { it.name } + entryValues = categories.map { it.id.toString() } + noSelectionRes = MR.strings.all + } + + intListPreference(activity) { + bindTo(preferences.recommendationRecentYears()) + titleRes = MR.strings.recommendation_recent_years + entries = RECOMMENDATION_RECENT_YEARS.map { context.getString(MR.plurals.recommendation_years, it, it) } + entryValues = RECOMMENDATION_RECENT_YEARS + defaultValue = 3 + } + + // When it gets built. + intListPreference(activity) { + bindTo(preferences.recommendationHour()) + titleRes = MR.strings.recommendation_update_hour + entries = listOf(context.getString(MR.strings.auto_migration_off)) + + MIGRATE_HOURS.map { context.formatHourOfDay(it) } + entryValues = listOf(-1) + MIGRATE_HOURS + defaultValue = 3 + + onChange { + viewScope.launchUI { RecommendationJob.setupTask(context) } + true + } + } + + intListPreference(activity) { + bindTo(preferences.recommendationIntervalDays()) + titleRes = MR.strings.recommendation_update_every + entries = RECOMMENDATION_INTERVALS.map { context.getString(MR.plurals.recommendation_every_days, it, it) } + entryValues = RECOMMENDATION_INTERVALS + defaultValue = 15 + + preferences.recommendationHour().changesIn(viewScope) { isVisible = it >= 0 } + + onChange { + viewScope.launchUI { RecommendationJob.setupTask(context) } + true + } + } + + switchPreference { + bindTo(preferences.recommendationOnlyOnWifi()) + titleRes = MR.strings.recommendation_only_on_wifi + defaultValue = true + + preferences.recommendationHour().changesIn(viewScope) { isVisible = it >= 0 } + + onChange { + viewScope.launchUI { RecommendationJob.setupTask(context) } + true + } + } + + // Right now. + preference { + titleRes = MR.strings.recommendation_rebuild_now + val store = Injekt.get() + fun refreshSummary(progress: BuildProgress?) { + if (progress != null) { + summary = progress.label(context) + return + } + // Off the main thread: the snapshot is a file, and a few hundred entries to parse. + viewScope.launchIO { + val text = store.read()?.let { snapshot -> + context.getString( + MR.strings.recommendation_last_built, + DateUtils.getRelativeTimeSpanString(snapshot.builtAt, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS), + ) + } ?: context.getString(MR.strings.recommendation_never_built) + withUIContext { summary = text } + } + } + RecommendationJob.progressFlow(context).onEach { refreshSummary(it) }.launchIn(viewScope) + onClick { + RecommendationJob.runNow(context, RecommendationJob.MODE_FULL) + refreshSummary(BuildProgress(BuildProgress.Stage.PROFILE)) + } + } + } } override fun onActivityResumed(activity: Activity) { @@ -310,5 +431,9 @@ class SettingsBrowseController : SettingsLegacyController() { private companion object { /** Overnight-heavy: the pass is a burst of searches nobody should be waiting on. */ val MIGRATE_HOURS = listOf(0, 3, 6, 9, 12, 18, 21) + /** Taste moves slowly and a build is a night of requests: no reason to run it daily. */ + val RECOMMENDATION_INTERVALS = listOf(7, 15, 30, 90, 180) + val RECOMMENDATION_TARGETS = listOf(25, 50, 150, 250, 500) + val RECOMMENDATION_RECENT_YEARS = listOf(1, 2, 3, 5, 10) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt index fee24c1ada..40b42a59d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/BrowseController.kt @@ -755,6 +755,11 @@ class BrowseController : */ fun setLastUsedSource(item: SourceItem?) { adapter?.removeAllScrollableHeaders() + // Added bottom-up: the recommendations sit right under "last used", above the languages. + presenter.recommendationItem?.let { + adapter?.addScrollableHeader(it) + adapter?.addScrollableHeader(LangItem(SourcePresenter.RECOMMENDATIONS_KEY)) + } if (item != null) { adapter?.addScrollableHeader(item) adapter?.addScrollableHeader(LangItem(SourcePresenter.LAST_USED_KEY)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt index 0c9d8094aa..fef5f13573 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceHolder.kt @@ -9,6 +9,8 @@ import karasu.util.lang.getString import dev.icerock.moko.resources.compose.stringResource import eu.kanade.tachiyomi.databinding.SourceItemBinding import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.util.lang.addExperimentalTag import eu.kanade.tachiyomi.source.icon import eu.kanade.tachiyomi.source.includeLangInName import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder @@ -43,8 +45,9 @@ class SourceHolder(view: View, val adapter: SourceAdapter) : val isPinned = item.isPinned ?: underPinnedSection val showLanguage = source.includeLangInName(adapter.enabledLanguages, adapter.extensionManager) val sourceName = if (showLanguage && (underPinnedSection || underLastUsedSection)) source.toString() else source.name - binding.title.text = sourceName + binding.title.text = if (source.id == RecommendationSource.ID) sourceName.addExperimentalTag(itemView.context) else sourceName + binding.sourcePin.isVisible = source.id != RecommendationSource.ID binding.sourcePin.apply { imageTintList = ColorStateList.valueOf( context.getResourceColor( @@ -68,9 +71,17 @@ class SourceHolder(view: View, val adapter: SourceAdapter) : // Set circle letter image. itemView.post { val icon = source.icon() + // The crow is a white silhouette drawn for notifications; tinted with the text colour + // it follows the theme. The tint is cleared for everything else: the view is recycled. + binding.sourceImage.imageTintList = if (item.source.id == RecommendationSource.ID) { + ColorStateList.valueOf(itemView.context.getResourceColor(AR.attr.textColorPrimary)) + } else { + null + } when { icon != null -> binding.sourceImage.setImageDrawable(icon) item.source.id == LocalSource.ID -> binding.sourceImage.setImageResource(R.mipmap.ic_local_source) + item.source.id == RecommendationSource.ID -> binding.sourceImage.setImageResource(R.drawable.ic_karasu) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt index 9afbfcd4f9..56f81d947b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourceItem.kt @@ -32,7 +32,7 @@ class SourceItem(val source: CatalogueSource, header: LangItem? = null, val isPi } override fun isSwipeable(): Boolean { - return Injekt.get().enableSourceSwipeAction().get() && source.id != LocalSource.ID && header != null && header.code != SourcePresenter.LAST_USED_KEY + return Injekt.get().enableSourceSwipeAction().get() && source.id != LocalSource.ID && header != null && header.code != SourcePresenter.LAST_USED_KEY && header.code != SourcePresenter.RECOMMENDATIONS_KEY } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt index b71c4c4c10..189e7c80ab 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/SourcePresenter.kt @@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.util.system.withUIContext import kotlinx.coroutines.CoroutineScope @@ -37,6 +38,12 @@ class SourcePresenter( var sourceItems = emptyList() var lastUsedItem: SourceItem? = null + /** The recommendations window, pinned under "last used" rather than filed under "other". */ + val recommendationItem: SourceItem? + get() = (sourceManager.get(RecommendationSource.ID) as? CatalogueSource) + ?.takeUnless { it.id.toString() in preferences.hiddenSources().get() } + ?.let { SourceItem(it, LangItem(RECOMMENDATIONS_KEY)) } + var lastUsedJob: Job? = null fun onCreate() { @@ -139,6 +146,8 @@ class SourcePresenter( return sourceManager.getCatalogueSources() .filter { it.lang in languages || it.id == LocalSource.ID } + // Shown as its own section above the languages, see [recommendationItem]. + .filterNot { it.id == RecommendationSource.ID } .filterNot { it.id.toString() in hiddenCatalogues } .sortedBy { "(${it.lang}) ${it.name}" } } @@ -146,6 +155,7 @@ class SourcePresenter( companion object { const val PINNED_KEY = "pinned" const val LAST_USED_KEY = "last_used" + const val RECOMMENDATIONS_KEY = "recommendations" private var lastSources: List? = null private var lastUsedItemRem: SourceItem? = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt index 58a082b115..6fb087def3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceController.kt @@ -1,7 +1,13 @@ package eu.kanade.tachiyomi.ui.source.browse import android.os.Build +import android.app.Activity +import android.R as AR import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater @@ -34,6 +40,15 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.ui.setting.controllers.SettingsBrowseController +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.distinctUntilChanged +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob +import eu.kanade.tachiyomi.data.recommendation.RecommendationJob.Companion.label +import karasu.presentation.recommendation.RecommendationTagsController +import karasu.domain.recommendation.RecommendationFeedback import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.online.HttpSource @@ -51,6 +66,7 @@ import eu.kanade.tachiyomi.util.addOrRemoveToFavorites import eu.kanade.tachiyomi.util.system.connectivityManager import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.e +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.materialAlertDialog import eu.kanade.tachiyomi.util.system.openInBrowser @@ -78,6 +94,8 @@ import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import karasu.domain.manga.interactor.GetManga import karasu.domain.source.browse.filter.models.SavedSearch @@ -152,6 +170,10 @@ open class BrowseSourceController(bundle: Bundle) : /** Current filter sheet */ private var filterSheet: SourceFilterSheet? = null private var lastPosition: Int = -1 + private var tagsEdited = false + + /** The recommendation build the adapter's cards came from, to notice when a newer one exists. */ + private var shownBuild: Long? = null // Basically a cache just so the filter sheet is shown faster var savedSearches by mutableStateOf(emptyList()) @@ -192,6 +214,51 @@ open class BrowseSourceController(bundle: Bundle) : override fun onViewCreated(view: View) { super.onViewCreated(view) + if (presenter.source is RecommendationSource) { + // A rebuild started here, from settings, or by the source itself on a first open + // with nothing built: the snack follows its stages, and the list reloads when it + // lands. + var wasRunning = false + RecommendationJob.stateFlow(view.context) + .distinctUntilChanged() + .onEach { state -> + when { + state is RecommendationJob.Companion.RunState.Running -> { + if (!wasRunning) { + // A rebuild wiped the list: the old cards are what it was asked + // to get rid of. A batch being added leaves them where they are. + val window = presenter.source as? RecommendationSource + viewScope.launchIO { + if (window?.currentBuild() == null) { + withUIContext { + adapter?.clear() + showProgressBar() + } + } + } + } + wasRunning = true + val text = state.progress.label(view.context) + snack?.takeIf { it.isShown && it.duration == Snackbar.LENGTH_INDEFINITE }?.setText(text) + ?: run { snack = view.snack(text, Snackbar.LENGTH_INDEFINITE) } + } + wasRunning -> { + wasRunning = false + snack?.dismiss() + if (state is RecommendationJob.Companion.RunState.Failed) { + snack = view.snack(MR.strings.recommendation_refresh_failed, Snackbar.LENGTH_LONG) + } else { + snack = view.snack(MR.strings.recommendation_refreshed) + } + } + } + // Whatever the state, cards from an older build than the one on disk are + // replaced. This is the check that cannot miss: it does not depend on having + // watched the job run. + if (state !is RecommendationJob.Companion.RunState.Running) refreshIfStale() + } + .launchIn(viewScope) + } // Initialize adapter, scroll listener and recycler views adapter = FlexibleAdapter(null, this, false) @@ -376,6 +443,10 @@ open class BrowseSourceController(bundle: Bundle) : val isLocalSource = presenter.source is LocalSource menu.findItem(R.id.action_local_source_help).isVisible = isLocalSource + menu.findItem(R.id.action_recommendation_tags).isVisible = presenter.source is RecommendationSource + menu.findItem(R.id.action_recommendation_refresh).isVisible = presenter.source is RecommendationSource + menu.findItem(R.id.action_recommendation_settings).isVisible = presenter.source is RecommendationSource + menu.findItem(R.id.action_recommendation_rebuild).isVisible = presenter.source is RecommendationSource } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -384,6 +455,17 @@ open class BrowseSourceController(bundle: Bundle) : R.id.action_display_mode -> swapDisplayMode() R.id.action_open_in_web_view -> openInWebView() R.id.action_local_source_help -> openLocalSourceHelpGuide() + // The progress flow above picks it up and says where it is. + // Refresh: one more batch of fifty, or a fresh build when there is nothing queued. + R.id.action_recommendation_refresh -> view?.context?.let { RecommendationJob.runNow(it, RecommendationJob.MODE_CONTINUE) } + R.id.action_recommendation_rebuild -> view?.context?.let { RecommendationJob.runNow(it, RecommendationJob.MODE_FULL) } + R.id.action_recommendation_settings -> router.pushController( + SettingsBrowseController().apply { preferenceKey = RECOMMENDATION_SETTINGS_KEY }.withFadeTransaction(), + ) + R.id.action_recommendation_tags -> { + tagsEdited = true + router.pushController(RecommendationTagsController().withFadeTransaction()) + } R.id.action_source_settings -> openSourceSettings() else -> return super.onOptionsItemSelected(item) } @@ -600,6 +682,12 @@ open class BrowseSourceController(bundle: Bundle) : adapter?.notifyItemChanged(lastPosition, false) lastPosition = -1 } + // Weights changed under the list: refetch. Only after the tags screen, so coming back + // from a manga keeps the scroll position. + if (type == ControllerChangeType.POP_ENTER && tagsEdited) { + tagsEdited = false + presenter.restartPager() + } } /** @@ -626,12 +714,35 @@ open class BrowseSourceController(bundle: Bundle) : * @param page the current page. * @param mangas the list of manga of the page. */ + /** + * The presenter keeps its pages across view recreations, so a list built while this screen + * was covered would come back as it was. The build stamp the cards came from is compared + * with the one on disk, and a newer one restarts the pager, which clears the adapter. + */ + private fun refreshIfStale() { + val window = presenter.source as? RecommendationSource ?: return + viewScope.launchIO { + val current = window.currentBuild() + if (current != null && current != shownBuild) { + withUIContext { presenter.restartPager() } + } + } + } + + override fun onAttach(view: View) { + super.onAttach(view) + refreshIfStale() + } + fun onAddPage(page: Int, mangas: List) { val adapter = adapter ?: return hideProgressBar() if (page == 1) { adapter.clear() resetProgressItem() + (presenter.source as? RecommendationSource)?.let { window -> + viewScope.launchIO { shownBuild = window.currentBuild() } + } } adapter.onLoadMoreComplete(mangas) if (isControllerVisible) { @@ -891,6 +1002,45 @@ open class BrowseSourceController(bundle: Bundle) : val manga = (adapter?.getItem(position) as? BrowseSourceItem?)?.manga ?: return val view = view ?: return val activity = activity ?: return + val window = presenter.source as? RecommendationSource + if (window != null) { + // On the window the long press is a verdict, not only "add to library" — and the one + // place the grade can say why this is here. + val title = SpannableStringBuilder(manga.title) + window.describe(manga.source, manga.url)?.let { why -> + val start = title.length + 1 + title.append("\n").append(why) + title.setSpan(RelativeSizeSpan(0.75f), start, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + title.setSpan(ForegroundColorSpan(activity.getResourceColor(AR.attr.textColorSecondary)), start, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + activity.materialAlertDialog() + .setTitle(title) + .setItems( + arrayOf( + activity.getString(if (manga.favorite) MR.strings.remove_from_library else MR.strings.add_to_library), + activity.getString(MR.strings.recommendation_not_interested), + activity.getString(MR.strings.recommendation_already_read), + ), + ) { _, which -> + val kind = when (which) { + 1 -> RecommendationFeedback.Kind.NOT_INTERESTED + 2 -> RecommendationFeedback.Kind.ALREADY_READ + else -> null + } + if (kind == null) { + toggleFavorite(manga, position, view, activity) + } else { + window.feedback(manga.source, manga.url, kind) + adapter?.removeItem(position) + } + } + .show() + return + } + toggleFavorite(manga, position, view, activity) + } + + private fun toggleFavorite(manga: Manga, position: Int, view: View, activity: Activity) { viewScope.launchIO { withUIContext { snack?.dismiss() } snack = manga.addOrRemoveToFavorites( @@ -902,6 +1052,8 @@ open class BrowseSourceController(bundle: Bundle) : onMangaAdded = { adapter?.notifyItemChanged(position) snack = view.snack(MR.strings.added_to_library) + // Taking a recommendation is the clearest yes the window ever gets. + (presenter.source as? RecommendationSource)?.feedback(manga.source, manga.url, RecommendationFeedback.Kind.ADDED) }, onMangaMoved = { adapter?.notifyItemChanged(position) }, onMangaDeleted = { presenter.confirmDeletion(manga) }, @@ -918,6 +1070,9 @@ open class BrowseSourceController(bundle: Bundle) : companion object { const val SOURCE_ID_KEY = "sourceId" + /** The preference the settings screen scrolls to and highlights. */ + private const val RECOMMENDATION_SETTINGS_KEY = "recommendation_hour" + const val SEARCH_QUERY_KEY = "searchQuery" const val USE_LATEST_KEY = "useLatest" const val SMART_SEARCH_CONFIG_KEY = "smartSearchConfig" diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt index f6ef93f4c7..b695c78532 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceHolder.kt @@ -25,6 +25,9 @@ abstract class BrowseSourceHolder(view: View, adapter: FlexibleAdapter, private val catalogueListType: Preference, private val outlineOnCovers: Preference, + /** A line under the title in list layout; the recommendations put their reason here. */ + val subtitle: String? = null, ) : AbstractFlexibleItem() { @@ -80,6 +82,7 @@ class BrowseSourceItem( payloads: MutableList?, ) { holder.onSetValues(manga) + holder.onSetSubtitle(subtitle) holder.bindChapterCount(manga) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt index 86a35a7ee1..aa413576fd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourceListHolder.kt @@ -58,6 +58,11 @@ class BrowseSourceListHolder( setImage(manga) } + override fun onSetSubtitle(subtitle: String?) { + binding.subtitle.text = subtitle + binding.subtitle.isVisible = !subtitle.isNullOrBlank() + } + override fun setImage(manga: Manga) { // Update the cover. isFavorite = manga.favorite diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt index 212b53e455..e82d275af2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/browse/BrowseSourcePresenter.kt @@ -14,6 +14,11 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.safeMemo +import eu.kanade.tachiyomi.source.RecommendationSource +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter import eu.kanade.tachiyomi.ui.source.filter.CheckboxItem import eu.kanade.tachiyomi.ui.source.filter.CheckboxSectionItem @@ -204,17 +209,19 @@ open class BrowseSourcePresenter( pager.asFlow() .map { (first, second) -> first to second - .map { networkToLocalManga(it, sourceId) } - .filter { !preferences.hideInLibraryItems().get() || !it.favorite } + .map { networkToLocalManga(it, sourceId) to it.safeMemo()[RecommendationSource.BECAUSE_MEMO]?.jsonPrimitive?.contentOrNull } + // The recommendations are library entries by design; hiding them would empty it. + .filter { (manga, _) -> sourceId == RecommendationSource.ID || !preferences.hideInLibraryItems().get() || !manga.favorite } } - .onEach { initializeMangas(it.second) } + .onEach { initializeMangas(it.second.map { (manga, _) -> manga }) } .map { (first, second) -> - first to second.map { + first to second.map { (manga, because) -> BrowseSourceItem( - it, + manga, browseAsList, sourceListType, outlineCovers, + subtitle = because, ) } } @@ -264,7 +271,9 @@ open class BrowseSourcePresenter( * @param sManga the manga from the source. * @return a manga from the database. */ - private suspend fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga { + private suspend fun networkToLocalManga(sManga: SManga, browsedSourceId: Long): Manga { + // A recommendation is a window onto a manga of another source: never a row of its own. + val sourceId = sManga.safeMemo()[RecommendationSource.SOURCE_MEMO]?.jsonPrimitive?.longOrNull ?: browsedSourceId var localManga = getManga.awaitByUrlAndSource(sManga.url, sourceId) if (localManga == null) { val newManga = Manga.create(sManga.url, sManga.title, sourceId) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt index f7fb16c502..662480cd93 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/source/globalsearch/GlobalSearchPresenter.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.RecommendationSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.SManga @@ -45,6 +46,10 @@ open class GlobalSearchPresenter( private val initialQuery: String? = "", private val initialExtensionFilter: String? = null, private val sourcesToUse: List? = null, + /** Sources that make no sense to search: for a merge, the ones the series is already on. */ + private val excludedSources: Set = emptySet(), + /** Other names the series goes by, tried on a source in turn when the query found nothing. */ + private val fallbackQueries: List = emptyList(), val sourceManager: SourceManager = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), @@ -104,6 +109,8 @@ open class GlobalSearchPresenter( val list = sourceManager.getCatalogueSources() .filter { it.lang in languages } .filterNot { it.id.toString() in hiddenCatalogues } + // The recommendations window is not a catalogue: a hit from it would be filed under its id. + .filterNot { it.id == RecommendationSource.ID } .sortedBy { "(${it.lang}) ${it.name}" } return if (preferences.onlySearchPinned().get()) { @@ -125,7 +132,13 @@ open class GlobalSearchPresenter( private fun List.healthyFirst(): List = sortedBy { readFailures.isSourceFailing(it.id) } - private fun getSourcesToQuery(): List { + private fun getSourcesToQuery(): List = pickSourcesToQuery().filterNot { it.id in excludedSources } + + private companion object { + const val MAX_FALLBACKS = 4 + } + + private fun pickSourcesToQuery(): List { if (sourcesToUse != null) return sourcesToUse.healthyFirst() val filter = extensionFilter val enabledSources = getEnabledSources().healthyFirst() @@ -171,6 +184,21 @@ open class GlobalSearchPresenter( * * @param query query on which to search. */ + /** + * The query, then each fallback name until one finds something. A pt-BR source lists the + * series under the group's own title; a tracker's romaji or English name is what finds it. + */ + private suspend fun searchWithFallbacks(source: CatalogueSource, query: String): MangasPage { + // Every extra name is another request on every source that came back empty. + val names = listOf(query) + fallbackQueries.filter { !it.equals(query, ignoreCase = true) }.take(MAX_FALLBACKS) + var last = MangasPage(emptyList(), false) + for (name in names) { + last = source.getSearchManga(1, name, source.getFilterList()) + if (last.mangas.isNotEmpty()) return last + } + return last + } + fun search(query: String) { // Return if there's nothing to do if (this.query == query) return @@ -196,7 +224,7 @@ open class GlobalSearchPresenter( return@mainLaunch } val mangas = try { - source.getSearchManga(1, query, source.getFilterList()) + searchWithFallbacks(source, query) .also { readFailures.clearSource(source.id) } } catch (error: Exception) { // Remembering this is what makes the ordering above self-correcting: diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt index 6c0ed9c721..fd9b53536c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterSourceSync.kt @@ -52,6 +52,11 @@ suspend fun syncChaptersWithSource( // is deleted below, so a merged list would delete the other sources' chapters here. val dbChapters = getChapter.awaitAllRaw(manga.id!!, false) + // A source with no dates of its own stamps the moment it was asked on every chapter, so + // every chapter is "0 minutes ago" and every sync sees every date changed. Those stamps are + // dropped here, once, before anything reads them: the chapter list, the library's "recently + // updated", the release calendar and the recommendations all get "unknown" instead of a lie. + val fetchStamped = datesAreTheFetch(rawSourceChapters.map { it.date_upload }) val sourceChapters = rawSourceChapters .distinctBy { it.url } .mapIndexed { i, sChapter -> @@ -60,6 +65,7 @@ suspend fun syncChaptersWithSource( name = with(ChapterSanitizer) { sChapter.name.sanitize(manga.title) } manga_id = manga.id source_order = i + if (fetchStamped) date_upload = 0L } } @@ -254,3 +260,17 @@ internal fun shouldUpdateDbChapter(dbChapter: Chapter, sourceChapter: Chapter): dbChapter.chapter_number != sourceChapter.chapter_number || dbChapter.source_order != sourceChapter.source_order } + +/** + * True when the upload dates are the time of the request rather than of the releases: three or + * more chapters, all within a day of each other, and that day is now. A series really posted + * in a single day exists, but not one whose whole run was posted today. + */ +internal fun datesAreTheFetch(dates: List, now: Long = System.currentTimeMillis()): Boolean { + val real = dates.filter { it > 0 } + if (real.size < 3) return false + val span = real.max() - real.min() + return span < FETCH_STAMP_WINDOW_MS && now - real.max() < FETCH_STAMP_WINDOW_MS +} + +private const val FETCH_STAMP_WINDOW_MS = 24L * 60 * 60 * 1000 diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt index 38ef97e246..2c02b7837e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/lang/StringExtensions.kt @@ -194,6 +194,19 @@ fun String.addBetaTag(context: Context, useSuperScript: Boolean = true): Spanned } } +/** Like [addBetaTag], for what is further from done than beta. */ +fun String.addExperimentalTag(context: Context, useSuperScript: Boolean = true): Spanned { + val text = context.getString(MR.strings.experimental) + val color = context.getResourceColor(R.attr.colorSecondary) + return buildSpannedString { + append(this@addExperimentalTag) + val tag: SpannableStringBuilder.() -> Unit = { + bold { scale(0.75f) { color(color) { append(text) } } } + } + if (useSuperScript) superscript(tag) else tag() + } +} + fun String.toNormalized(): String = replace("’", "'") fun String.getUrlWithoutDomain(): String { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt index f99d65a77f..cb10b0363f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/LocaleHelper.kt @@ -20,6 +20,7 @@ object LocaleHelper { "", "other" -> context.getString(MR.strings.other) SourcePresenter.LAST_USED_KEY -> context.getString(MR.strings.last_used) SourcePresenter.PINNED_KEY -> context.getString(MR.strings.pinned) + SourcePresenter.RECOMMENDATIONS_KEY -> context.getString(MR.strings.recommendation_section) "all" -> context.getString(MR.strings.all) else -> getLocalizedDisplayName(lang) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt index 57686b0350..ba1f010f15 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt @@ -73,6 +73,12 @@ enum class Themes(@StyleRes val styleRes: Int, val nightMode: Int, val nameRes: R.style.Theme_Tachiyomi_Doki, AppCompatDelegate.MODE_NIGHT_YES, MR.strings.doki, + ), + DRACULA( + R.style.Theme_Tachiyomi_Dracula, + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + MR.strings.alucard, + MR.strings.dracula, ) ; diff --git a/app/src/main/java/karasu/core/di/DomainModule.kt b/app/src/main/java/karasu/core/di/DomainModule.kt index 3fe8a2af88..df46bbbef6 100644 --- a/app/src/main/java/karasu/core/di/DomainModule.kt +++ b/app/src/main/java/karasu/core/di/DomainModule.kt @@ -2,6 +2,7 @@ package karasu.core.di import eu.kanade.tachiyomi.source.MergedSourceFallback import eu.kanade.tachiyomi.util.chapter.MergedSourceSync +import android.app.Application import org.koin.dsl.module import karasu.data.category.CategoryRepositoryImpl import karasu.data.chapter.ChapterRepositoryImpl @@ -52,6 +53,16 @@ import karasu.domain.manga.failures.ReadFailures import karasu.domain.manga.failures.interactor.GetBrokenSources import karasu.domain.manga.failures.interactor.UpdateFailures import karasu.domain.manga.interactor.GetLibraryManga +import karasu.domain.recommendation.BuildRecommendations +import karasu.domain.recommendation.DiscardRecommendations +import karasu.domain.recommendation.GetTasteProfile +import karasu.domain.recommendation.RecommendationFeedbackStore +import karasu.domain.recommendation.RecommendationStore +import karasu.domain.recommendation.TrackerExtrasStore +import karasu.domain.recommendation.EnrichFromTrackers +import karasu.domain.recommendation.FindMergeCandidates +import karasu.domain.recommendation.MergeSuggestionsStore +import karasu.domain.migration.MangaAliases import karasu.domain.manga.interval.FetchInterval import karasu.domain.manga.interval.GetReleaseSchedule import karasu.domain.manga.interval.RecalculateReleaseEstimates @@ -110,7 +121,17 @@ fun domainModule() = module { single { MangaUpdateFailureRepositoryImpl(get()) } factory { UpdateFailures(get()) } factory { FetchInterval(get()) } - factory { GetReleaseSchedule(get(), get()) } + factory { GetReleaseSchedule(get(), get(), get()) } + factory { GetTasteProfile(get(), get(), get(), get(), get(), get(), get(), get()) } + single { RecommendationFeedbackStore(get()) } + factory { BuildRecommendations(get(), get(), get(), get(), get(), get()) } + single { RecommendationStore(get()) } + factory { DiscardRecommendations(get(), get(), get()) } + single { TrackerExtrasStore(get()) } + factory { EnrichFromTrackers(get(), get(), get(), get(), get()) } + single { MergeSuggestionsStore(get()) } + factory { MangaAliases(get(), get(), get()) } + factory { FindMergeCandidates(get(), get(), get(), get(), get(), get(), get()) } factory { RecalculateReleaseEstimates(get(), get(), get()) } // In-memory, so every reader and the broken-sources screen have to be looking at the same one. single { ReadFailures() } diff --git a/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt b/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt index 860af41f1b..ce67a798aa 100644 --- a/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt +++ b/app/src/main/java/karasu/data/chapter/ChapterRepositoryImpl.kt @@ -64,6 +64,9 @@ class ChapterRepositoryImpl(private val handler: DatabaseHandler) : ChapterRepos override suspend fun getScanlatorsByChapter(mangaId: Long): List = handler.awaitList { chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() } } + override suspend fun getAllScanlators(): List = + handler.awaitList { chaptersQueries.getAllScanlators() } + override fun getScanlatorsByChapterAsFlow(mangaId: Long): Flow> = handler.subscribeToList { chaptersQueries.getScanlatorsByMangaId(mangaId) { it.orEmpty() } } diff --git a/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt b/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt index 89cdb9a74f..d55c13117b 100644 --- a/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt +++ b/app/src/main/java/karasu/domain/chapter/ChapterRepository.kt @@ -27,6 +27,7 @@ interface ChapterRepository { suspend fun getRecents(filterScanlators: Boolean, search: String = "", limit: Long = 25L, offset: Long = 0L): List suspend fun getScanlatorsByChapter(mangaId: Long): List + suspend fun getAllScanlators(): List fun getScanlatorsByChapterAsFlow(mangaId: Long): Flow> suspend fun delete(chapter: Chapter): Boolean diff --git a/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt b/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt index 14e543be83..8fd0ffa699 100644 --- a/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt +++ b/app/src/main/java/karasu/domain/chapter/interactor/GetChapter.kt @@ -28,6 +28,13 @@ class GetChapter( /** How many chapters are stored for [mangaId]. Never merged, and never reads the rows. */ suspend fun awaitCountRaw(mangaId: Long) = chapterRepository.countChapters(mangaId) + /** Every scanlator name in the library, each group of a joint release on its own. */ + suspend fun awaitAllScanlators(): Set = chapterRepository.getAllScanlators() + .flatMap { it.split('&', ',', '/', '+', '|') } + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .toSet() + suspend fun awaitAll(mangaId: Long, filterScanlators: Boolean): List { val own = chapterRepository.getChapters(mangaId, filterScanlators) if (!mergedSources.hasMerges(mangaId)) return own diff --git a/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt b/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt index 4ff20dafe4..aed04c2e38 100644 --- a/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt +++ b/app/src/main/java/karasu/domain/manga/interval/FetchInterval.kt @@ -3,6 +3,9 @@ package karasu.domain.manga.interval import eu.kanade.tachiyomi.data.database.models.Chapter import karasu.data.DatabaseHandler import kotlin.math.roundToInt +import java.time.ZoneOffset +import java.time.ZoneId +import java.time.Instant /** * Stores when each manga is expected to release, and therefore when to ask about it. @@ -135,12 +138,27 @@ class FetchInterval( * row that carries that number. Unnumbered chapters have nothing to collapse against and are * kept as they are. */ -fun releaseDates(chapters: List): Pair, List> { +fun releaseDates(chapters: List, zone: ZoneId = ZoneId.systemDefault()): Pair, List> { val (numbered, unnumbered) = chapters.partition { it.isRecognizedNumber } val perNumber = numbered.groupBy { (it.chapter_number * 1000f).roundToInt() }.values - val uploads = perNumber.map { group -> group.map { it.date_upload }.filter { it > 0 }.minOrNull() ?: 0L } + - unnumbered.map { it.date_upload } + val uploads = (perNumber.map { group -> group.map { it.date_upload }.filter { it > 0 }.minOrNull() ?: 0L } + + unnumbered.map { it.date_upload }).map { asLocalRelease(it, zone) } val fetches = perNumber.map { group -> group.minOf { it.date_fetch } } + unnumbered.map { it.date_fetch } return uploads to fetches } + +/** + * A stamp at exactly midnight UTC is a date the source knew, not a moment: "Monday", stored the + * only way a date fits in a millisecond field. Read as an instant it is Sunday evening anywhere + * west of Greenwich, and the whole calendar slides a day early. So it becomes noon, local, of + * that date — the middle of the day it named. A stamp with a time of day is left exactly as it + * is: that one really happened then. + */ +fun asLocalRelease(stamp: Long, zone: ZoneId = ZoneId.systemDefault()): Long { + if (stamp <= 0 || stamp % DAY_MS != 0L) return stamp + val date = Instant.ofEpochMilli(stamp).atZone(ZoneOffset.UTC).toLocalDate() + return date.atTime(12, 0).atZone(zone).toInstant().toEpochMilli() +} + +private const val DAY_MS = 24L * 60 * 60 * 1000 diff --git a/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt b/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt index 2f6ddb36b7..63ef522110 100644 --- a/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt +++ b/app/src/main/java/karasu/domain/manga/interval/GetReleaseSchedule.kt @@ -4,7 +4,9 @@ import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.source.model.SManga import java.time.Instant import java.time.LocalDate +import java.time.YearMonth import java.time.ZoneId +import karasu.domain.recommendation.TrackerExtrasStore import karasu.domain.manga.interactor.GetLibraryManga /** @@ -17,16 +19,27 @@ import karasu.domain.manga.interactor.GetLibraryManga * upload dates and has not been watched long enough to learn the rhythm from fetch times. These * are shown rather than hidden, because a calendar missing half the library with no explanation * reads as broken. + * @param arrived entries that got a chapter today. The moment one lands its estimate moves on + * to the next cycle and it would vanish from today — exactly when the reader wants to see it. + * @param onHiatus entries the source, or a tracker, says are paused. Not "stalled": that would + * read as "the source seems to have stopped", when what happened is the author said so. */ data class ReleaseSchedule( val upcoming: List, val stalled: List, val unknown: List, + val arrived: List = emptyList(), + val onHiatus: List = emptyList(), ) +/** + * @param guessed the estimate is not the entry's own rhythm but its source's typical one, + * stood in until the entry has released enough times to speak for itself. + */ data class ScheduledRelease( val manga: Manga, val estimate: ReleaseEstimate, + val guessed: Boolean = false, ) /** One day of the calendar. Days with nothing expected are kept so the week reads as a week. */ @@ -79,40 +92,102 @@ fun ReleaseSchedule.calendar( ) } +/** + * One month, every day, for the grid view. Days before today are empty: what was expected then + * either arrived or is on today already. + */ +data class ReleaseMonth(val month: YearMonth, val days: List) + +fun ReleaseSchedule.month( + month: YearMonth, + now: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + grace: Long = ReleaseEstimate.MISS_GRACE, +): ReleaseMonth { + val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + val byDate = upcoming.groupBy { release -> + val date = Instant.ofEpochMilli(release.estimate.expectedRelease(now, grace)).atZone(zone).toLocalDate() + if (date.isBefore(today)) today else date + } + return ReleaseMonth( + month = month, + days = (1..month.lengthOfMonth()).map { day -> + val date = month.atDay(day) + ReleaseDay(date, byDate[date].orEmpty()) + }, + ) +} + /** Statuses under which no further chapter is coming. Hiatus is not one: it may end. */ private val FINISHED = setOf(SManga.COMPLETED, SManga.CANCELLED, SManga.LICENSED) class GetReleaseSchedule( private val getLibraryManga: GetLibraryManga, private val fetchInterval: FetchInterval, + private val extras: TrackerExtrasStore, ) { /** * @param categories which categories to include. Empty means the whole library, matching how * the same selection is read everywhere else. + * @param onlyCaughtUp leave out entries with more than [CAUGHT_UP_MAX_UNREAD] unread: a new + * chapter on a pile of thirty is not an event the reader is waiting for. */ suspend fun await( categories: Set = emptySet(), + onlyCaughtUp: Boolean = false, now: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), ): ReleaseSchedule { val estimates = fetchInterval.awaitAll() - val library = getLibraryManga.await() + val entries = getLibraryManga.await() .filter { categories.isEmpty() || it.category in categories } .distinctBy { it.manga.id } - .map { it.manga } + .filter { !onlyCaughtUp || it.unread <= CAUGHT_UP_MAX_UNREAD } // Nothing is expected of a finished series, so it belongs on no date and in no - // bucket: left in, every completed entry would sit under "stalled" forever. - .filter { it.status !in FINISHED } + // bucket: left in, every completed entry would sit under "stalled" forever. A tracker + // that says finished outranks a source that never updated its status. + .filter { effectiveStatus(it.manga) !in FINISHED } + + // The typical gap on each source, for entries too new to have one of their own. + val sourceInterval = entries + .mapNotNull { entry -> entry.manga.id?.let { estimates[it] }?.let { entry.manga.source to it.interval } } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, gaps) -> gaps.sorted()[gaps.size / 2] } + + val startOfToday = Instant.ofEpochMilli(now).atZone(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() val upcoming = mutableListOf() val stalled = mutableListOf() val unknown = mutableListOf() + val arrived = mutableListOf() + val onHiatus = mutableListOf() - library.forEach { manga -> + entries.forEach { entry -> + val manga = entry.manga + // When the app first saw the chapter, not what the source stamped on it: a source + // with no dates stamps "now" on everything, every fetch, and would "arrive" daily. + // Added today is not "a chapter arrived today": the whole backlog came in at once. + // The entry still learns its rhythm from every chapter that arrives from now on. + if (entry.lastFetch >= startOfToday && manga.date_added < startOfToday) arrived.add(manga) + if (effectiveStatus(manga) == SManga.ON_HIATUS) { + onHiatus.add(manga) + return@forEach + } val estimate = manga.id?.let { estimates[it] } when { - estimate == null -> unknown.add(manga) - estimate.isStalled(now) -> stalled.add(ScheduledRelease(manga, estimate)) - else -> upcoming.add(ScheduledRelease(manga, estimate)) + estimate != null && estimate.isStalled(now) -> stalled.add(ScheduledRelease(manga, estimate)) + estimate != null -> upcoming.add(ScheduledRelease(manga, estimate)) + else -> { + val typical = sourceInterval[manga.source] + val last = entry.lastFetch.takeIf { it > 0 } + if (typical != null && last != null) { + // Wide on purpose: half a cycle either way says "probably weekly", no more. + val guess = ReleaseEstimate(nextRelease = last + typical, interval = typical, spread = typical / 2) + if (guess.isStalled(now)) unknown.add(manga) else upcoming.add(ScheduledRelease(manga, guess, guessed = true)) + } else { + unknown.add(manga) + } + } } } @@ -120,6 +195,16 @@ class GetReleaseSchedule( upcoming = upcoming.sortedBy { it.estimate.nextRelease }, stalled = stalled.sortedByDescending { it.estimate.nextRelease }, unknown = unknown.sortedBy { it.title }, + arrived = arrived.sortedBy { it.title }, + onHiatus = onHiatus.sortedBy { it.title }, ) } + + /** What the trackers say when they say something; otherwise what the source says. */ + private fun effectiveStatus(manga: Manga): Int = extras.get(manga.id)?.status ?: manga.status + + companion object { + /** "Caught up" for the calendar's purposes. A couple unread is a backlog of an evening. */ + const val CAUGHT_UP_MAX_UNREAD = 2 + } } diff --git a/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt b/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt index a5a850eee5..c600499b4e 100644 --- a/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt +++ b/app/src/main/java/karasu/domain/manga/interval/ReleaseEstimate.kt @@ -1,6 +1,8 @@ package karasu.domain.manga.interval import kotlin.math.abs +import java.time.ZoneId +import java.time.Instant /** * What the app has worked out about when a manga releases. @@ -78,6 +80,14 @@ data class ReleaseEstimate( fun pollInterval(): Long = (checkWindow / CHECKS_PER_WINDOW).coerceIn(MIN_POLL, days(1)) companion object { + /** + * Bumped whenever the arithmetic here or in [releaseDates] changes. Stored estimates + * were produced by the old arithmetic and are only rewritten as chapters arrive, so on + * the first launch of a build with a new number the app recalculates them all at once + * rather than being quietly wrong for weeks. + */ + const val ESTIMATOR_VERSION = 2 + /** Never poll a manga faster than this, however sure the estimate is. */ val MIN_POLL = hours(2) @@ -117,6 +127,13 @@ data class ReleaseEstimate( /** Missed windows before a series counts as stalled rather than merely late. */ private const val STALL_CYCLES = 3 + /** An interval in this range is "weekly" for the purpose of holding a weekday. */ + private val WEEKLY_MIN = days(6) + private val WEEKLY_MAX = days(8) + + /** This share of releases on one weekday is a weekday the series keeps. */ + private const val WEEKDAY_AGREEMENT = 0.7 + /** * How long a missed release is still read as "any moment now" before the date rolls on. * The default only applies where there is no user preference to hand — the calendar and @@ -202,12 +219,34 @@ data class ReleaseEstimate( }.coerceAtMost(interval) return ReleaseEstimate( - nextRelease = events.first() + interval, + nextRelease = anchoredNext(events, interval), interval = interval, spread = spread, ) } + /** + * The next release, and for a weekly series that keeps its weekday, the next of *that* + * weekday rather than "last release plus seven days". + * + * The difference is one late chapter: a Monday series that posted Wednesday would + * otherwise expect next Wednesday, and slide a little further every time the source + * slips. Anchored, it expects Monday again. The earliest Monday that is at least half a + * cycle out, so a chapter that came a day *early* does not make tomorrow the next one. + */ + private fun anchoredNext(events: List, interval: Long, zone: ZoneId = ZoneId.systemDefault()): Long { + val plain = events.first() + interval + if (interval !in WEEKLY_MIN..WEEKLY_MAX || events.size < 3) return plain + val days = events.map { Instant.ofEpochMilli(it).atZone(zone) } + val weekday = days.groupingBy { it.dayOfWeek }.eachCount().maxByOrNull { it.value } ?: return plain + if (weekday.value < events.size * WEEKDAY_AGREEMENT) return plain + var candidate = Instant.ofEpochMilli(events.first() + interval / 2).atZone(zone) + while (candidate.dayOfWeek != weekday.key) candidate = candidate.plusDays(1) + // Same time of day the series actually posts, so the check window still fits around it. + val typical = days.first() + return candidate.withHour(typical.hour).withMinute(typical.minute).toInstant().toEpochMilli() + } + /** Newest first, dropping anything that lands inside the previous event's window. */ private fun collapse(datesNewestFirst: List): List { val kept = mutableListOf() diff --git a/app/src/main/java/karasu/domain/migration/MangaAliases.kt b/app/src/main/java/karasu/domain/migration/MangaAliases.kt index beb2d4c444..058e1e35d8 100644 --- a/app/src/main/java/karasu/domain/migration/MangaAliases.kt +++ b/app/src/main/java/karasu/domain/migration/MangaAliases.kt @@ -7,7 +7,10 @@ import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs +import karasu.domain.recommendation.TrackerExtrasStore import karasu.domain.track.interactor.GetTrack +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.buildJsonObject @@ -29,6 +32,7 @@ import okhttp3.RequestBody.Companion.toRequestBody class MangaAliases( private val getTrack: GetTrack, private val network: NetworkHelper, + private val extras: TrackerExtrasStore = Injekt.get(), ) { /** @@ -41,7 +45,10 @@ class MangaAliases( val tracked = runCatching { getTrack.awaitAllByMangaId(manga.id) } .getOrDefault(emptyList()) .map { it.title } - return (listOf(manga.title) + tracked).clean() + // Then every other name the bound trackers listed, fetched overnight: the scanlation + // group's own Portuguese title is exactly the kind of name only they know. + val known = extras.get(manga.id)?.titles.orEmpty() + return (listOf(manga.title) + tracked + known).clean() } /** diff --git a/app/src/main/java/karasu/domain/recommendation/BuildRecommendations.kt b/app/src/main/java/karasu/domain/recommendation/BuildRecommendations.kt new file mode 100644 index 0000000000..ce449becd9 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/BuildRecommendations.kt @@ -0,0 +1,451 @@ +package karasu.domain.recommendation + +import co.touchlab.kermit.Logger +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import eu.kanade.tachiyomi.source.model.SManga +import karasu.domain.chapter.interactor.GetChapter +import karasu.domain.manga.interactor.GetLibraryManga +import karasu.util.normalizedLevenshteinSimilarity +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.async +import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.serialization.json.putJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonNull +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.network.jsonMime +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.POST +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull + +/** + * The whole pipeline, from library to ranked list. Run by the nightly job and by the window when + * it has nothing to show. + * + * Candidates come from the enabled sources, searched with their own genre filter set to one of + * the reader's top tags. Reading a candidate's tags would cost one request per candidate; sending + * the tag in the search costs one request per source per tag, and every hit is known to carry + * that tag. That is only a preliminary rank, though: a hit knows one tag of itself, so page one + * of "Romance" is thirty entries tied at the same score. The shortlist that survives it gets its + * details and chapters read — a bounded number of requests — and is ranked again on everything + * it is. What was not read is not shown: a card with no chapter count is a card that lies. + */ +class BuildRecommendations( + private val getTasteProfile: GetTasteProfile, + private val getLibraryManga: GetLibraryManga, + private val getChapter: GetChapter, + private val sourceManager: SourceManager, + private val preferences: PreferencesHelper, + private val network: NetworkHelper, +) { + /** + * Gathers every candidate — the tag searches across every enabled source, plus the trackers' + * suggestions — and queues them best first. Nothing is read yet: that is [detailBatch]'s job, + * a batch at a time, so a target of five hundred can be met over several runs. + */ + suspend fun search( + now: Long = System.currentTimeMillis(), + onProgress: suspend (BuildProgress) -> Unit = {}, + ): RecommendationSnapshot { + onProgress(BuildProgress(BuildProgress.Stage.PROFILE)) + val profile = getTasteProfile.await() + val library = getLibraryManga.await().distinctBy { it.manga.id } + val known = library.map { it.manga.title.key() }.toSet() + + val fromLibrary = library.filter { it.read == 0 }.map { it.toRecommended() } + val hits = searchSources(profile, onProgress) + // Whatever the library already has, however another source spells it, is not a discovery. + .filter { it.title.key() !in known } + val suggested = suggestions(profile, library, onProgress).filter { it.title.key() !in known } + val suggestedKeys = suggested.map { it.title.key() }.toSet() + + // AniList's catalogue first — the only candidates that arrive already rated by people — + // then the trackers' "like this", then the hits by preliminary rank. Copies of one series + // on several sources stay together, so a batch reads all of them and the one with the + // most chapters can be kept. + val preliminary = profile.rank(hits.filter { it.title.key() !in suggestedKeys }) { it.tags } + .map { it.item.copy(score = it.score, because = it.because) } + val (fromCatalog, fromTrackers) = suggested.partition { it.fromCatalog } + val queue = fromCatalog + fromTrackers + preliminary.groupBy { it.title.key() }.values.flatMap { it.take(COPIES) } + + return RecommendationSnapshot( + builtAt = now, + profileTags = profile.tags, + items = rank(fromLibrary, profile, emptyMap(), now), + librarySize = library.size, + pending = queue, + ) + } + + /** + * Reads the next [batch] series off the queue and folds them into the window: details and + * chapters, one copy kept per series, ranked with everything already there. Small on + * purpose, with the caller pausing between batches, so a long build is a background hum + * rather than a burst that stalls the app. + */ + suspend fun detailBatch( + snapshot: RecommendationSnapshot, + batch: Int = BATCH, + now: Long = System.currentTimeMillis(), + onProgress: suspend (BuildProgress) -> Unit = {}, + ): RecommendationSnapshot { + if (snapshot.pending.isEmpty()) return snapshot + val profile = getTasteProfile.await() + // A batch is [batch] distinct series, copies included. + val keys = snapshot.pending.map { it.title.key() }.distinct().take(batch).toSet() + val (take, rest) = snapshot.pending.partition { it.title.key() in keys } + + val fetched = fetchDetails(take, onProgress) + // Only what was actually read makes the window. An entry whose details failed has no + // chapter count and no real tags: shown, it is the "0 chapters" card the reader opens + // and finds empty. + .filter { it.fetched } + .groupBy { it.title.key() } + .values + // The copy with the most chapters wins, but a tracker naming the series, or two of + // them agreeing, is about the series and travels with it. + .map { same -> + same.maxBy { it.chapters ?: -1 }.copy( + via = same.firstNotNullOfOrNull { it.via }, + votes = same.maxOf { it.votes }, + foundByTag = same.any { it.foundByTag }, + tags = same.flatMap { it.tags }.distinctBy { it.lowercase() }, + ) + } + + onProgress(BuildProgress(BuildProgress.Stage.RANKING)) + val fame = popularityOf(fetched) + val merged = snapshot.items + fetched.map { item -> + fame[item.title.key()]?.let { (pop, avg) -> item.copy(popularity = pop, averageScore = avg) } ?: item + } + return snapshot.copy( + builtAt = now, + items = rank(merged.distinctBy { it.sourceId to it.url }, profile, emptyMap(), now), + pending = rest, + ) + } + + /** The window's order: everything that is shown, scored fresh against [profile]. */ + private fun rank(items: List, profile: TasteProfile, fame: Map>, now: Long): List = + profile.rank(items) { it.tags } + .map { it.item.copy(score = it.score, because = it.because) } + // Nothing to read yet is nothing to recommend, whatever it is tagged with; nor is + // something its source stopped updating long ago for its size. + .filter { it.fromLibrary || (it.chapters != 0 && !it.isOneShot && !it.isStale(now)) } + .map { item -> fame[item.title.key()]?.let { (pop, avg) -> item.copy(popularity = pop, averageScore = avg) } ?: item } + .map { it.withBonuses(now) } + // Recommended on tags the library barely backs: the surprises, kept apart. + .map { item -> item.copy(explore = item.because.isNotEmpty() && item.because.all { (profile.support[it] ?: 0) <= Recommended.EXPLORE_SUPPORT }) } + .sortedByDescending { it.score } + .withExploreSlots() + + /** + * AniList's popularity and mean score for the shortlist, by title, [POPULARITY_BATCH] titles + * per request through aliased fields. Only a close title match counts: a wrong series' fame + * is worse than none. + */ + private suspend fun popularityOf(items: List): Map> { + val titles = items.map { it.title }.distinctBy { it.key() } + val found = mutableMapOf>() + titles.chunked(POPULARITY_BATCH).forEach { batch -> + runCatching { + val fields = batch.indices.joinToString("\n") { i -> + "m$i: Media(search: \$q$i, type: MANGA, format_not_in: [NOVEL]) { title { romaji english } popularity averageScore }" + } + val query = "query(" + batch.indices.joinToString(", ") { "\$q$it: String" } + ") { $fields }" + val payload = buildJsonObject { + put("query", query) + putJsonObject("variables") { batch.forEachIndexed { i, title -> put("q$i", title) } } + } + val data = network.client.newCall(POST(ANILIST, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + .parseAs()["data"]?.jsonObject ?: return@runCatching + batch.forEachIndexed { i, title -> + val media = data["m$i"]?.takeIf { it !is JsonNull }?.jsonObject ?: return@forEachIndexed + val names = media["title"]?.jsonObject + val close = listOfNotNull(names?.get("romaji"), names?.get("english")) + .mapNotNull { it.jsonPrimitive.contentOrNull } + .any { normalizedLevenshteinSimilarity(it.key(), title.key()) >= POPULARITY_MATCH } + if (!close) return@forEachIndexed + val pop = media["popularity"]?.jsonPrimitive?.intOrNull ?: return@forEachIndexed + found[title.key()] = pop to media["averageScore"]?.jsonPrimitive?.intOrNull + } + }.onFailure { Logger.w(it) { "AniList popularity batch failed" } } + } + return found + } + + /** The catalogues and the trackers' "like this", located in the sources the reader reads from. */ + private suspend fun suggestions( + profile: TasteProfile, + library: List, + onProgress: suspend (BuildProgress) -> Unit, + ): List { + // Every enabled source the library actually uses, most used first: the lookup stops at + // the first that has the series, so the order is what keeps the cost down, and the + // breadth is what lets a big target be met. + val enabled = enabledSources().associateBy { it.id } + val mostRead = library.groupingBy { it.manga.source }.eachCount().entries + .sortedByDescending { it.value } + .mapNotNull { enabled[it.key] } + val languages = preferences.enabledLanguages().get().toList() + val external = ExternalSuggestions(network, languages) + val similar = external.await(profile.seeds.map { it.title }, mostRead) { done, total -> + onProgress(BuildProgress(BuildProgress.Stage.TRACKERS, done, total)) + } + val catalog = runCatching { external.catalog(profile, countryFor(profile), preferences.recommendationRecentYears().get()) } + .onFailure { Logger.w(it) { "AniList catalogue pass failed" } } + .getOrDefault(emptyList()) + val located = external.locate(catalog, mostRead) { done, total -> + onProgress(BuildProgress(BuildProgress.Stage.TRACKERS, done, total)) + } + return (located + similar) + .map { + Recommended( + sourceId = it.sourceId, + url = it.url, + title = it.title, + thumbnail = it.thumbnail, + tags = it.suggestion.genres, + score = 0f, + because = emptyList(), + via = it.suggestion.seed.takeUnless { seed -> it.suggestion.fromCatalog }, + votes = it.suggestion.votes, + popularity = it.suggestion.popularity, + averageScore = it.suggestion.averageScore, + fromCatalog = it.suggestion.fromCatalog, + ) + } + .distinctBy { it.title.key() } + } + + /** + * One search per enabled source per tag the reader is at least [TAG_MIN_WEIGHT] into, sources + * in parallel, tags in series per source. Every tag, not a top few: the point of a periodic + * build is that it can afford to be thorough. Deeper where the taste is stronger + * ([pagesFor]): page one of a tag at 15%, pages one to three of a tag at 100%. + */ + private suspend fun searchSources(profile: TasteProfile, onProgress: suspend (BuildProgress) -> Unit): List { + val liked = profile.tags.filterValues { it >= TAG_MIN_WEIGHT }.entries.take(TAG_CAP).associate { it.key to it.value } + if (liked.isEmpty()) return emptyList() + val sources = enabledSources() + val searched = AtomicInteger() + onProgress(BuildProgress(BuildProgress.Stage.SEARCHING, 0, sources.size)) + val hits = coroutineScope { + sources.map { source -> + async { + runCatching { searchSource(source, liked) }.getOrDefault(emptyList()) + .also { onProgress(BuildProgress(BuildProgress.Stage.SEARCHING, searched.incrementAndGet(), sources.size)) } + } + }.awaitAll() + }.flatten() + + // The same manga found under two tags is one candidate carrying both. + return hits.groupBy { (hit, _) -> hit.sourceId to hit.url } + .values + .map { found -> found.first().first.copy(tags = found.map { (_, tag) -> tag }.distinct()) } + } + + /** Deeper where the taste is stronger. */ + private fun pagesFor(weight: Float): Int = when { + weight >= 0.8f -> 3 + weight >= 0.5f -> 2 + else -> 1 + } + + private suspend fun searchSource(source: CatalogueSource, liked: Map): List> { + val out = mutableListOf>() + for ((tag, weight) in liked) { + for (page in 1..pagesFor(weight)) { + val filters = source.getFilterList() + if (!filters.select(tag)) break + val result = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + runCatching { source.getSearchManga(page, "", filters) } + .onFailure { Logger.w(it) { "Recommendation search failed on ${source.name} for $tag" } } + .getOrNull() + } ?: break + result.mangas.forEach { + out += Recommended(source.id, it.url, it.title, it.thumbnail_url, listOf(tag), 0f, emptyList(), foundByTag = true) to tag + } + if (!result.hasNextPage) break + } + } + return out + } + + /** + * Where the reader's reading comes from, as AniList spells it, when it is lopsided enough to + * say: a library that is mostly manhwa wants Korean results. Balanced tastes get no filter. + */ + private fun countryFor(profile: TasteProfile): String? { + val formats = profile.formats.entries.sortedByDescending { it.value } + val top = formats.firstOrNull() ?: return null + val second = formats.getOrNull(1)?.value ?: 0f + if (top.value < second * COUNTRY_DOMINANCE) return null + return when (top.key.lowercase()) { + "manhwa", "webtoon" -> "KR" + "manhua" -> "CN" + "manga" -> "JP" + else -> null + } + } + + private fun enabledSources(): List { + val languages = preferences.enabledLanguages().get() + val hidden = preferences.hiddenSources().get() + return sourceManager.getCatalogueSources().filter { + it.lang in languages && it.id.toString() !in hidden && it.id != LocalSource.ID && it.id != RecommendationSource.ID + } + } + + /** + * Reads details and chapters for a batch: full tags to rank on, chapter count and dates to + * tell a new release from an old series. Sources in parallel, one entry at a time within a + * source. An entry that fails stays as it was, unfetched, and is dropped by the caller. + */ + private suspend fun fetchDetails( + batch: List, + onProgress: suspend (BuildProgress) -> Unit, + ): List = coroutineScope { + val detailed = AtomicInteger() + onProgress(BuildProgress(BuildProgress.Stage.DETAILS, 0, batch.size)) + batch.groupBy { it.sourceId }.map { (sourceId, entries) -> + async { + val source = sourceManager.get(sourceId) as? CatalogueSource ?: return@async entries + entries.map { entry -> + onProgress(BuildProgress(BuildProgress.Stage.DETAILS, detailed.incrementAndGet(), batch.size)) + runCatching { + val update = withTimeoutOrNull(REQUEST_TIMEOUT_MS) { + source.getMangaUpdate(entry.toSManga(), emptyList(), fetchDetails = true, fetchChapters = true) + } ?: return@runCatching entry + val genres = update.manga.getGenres().orEmpty() + val raw = update.chapters.map { it.date_upload } + val dates = honestUploadDates(raw) + val names = update.chapters.map { it.name } + entry.copy( + datesUntrusted = uploadDatesUntrusted(raw), + oneShotChapters = names.isNotEmpty() && names.count { Recommended.ONE_SHOT.containsMatchIn(it) } * 2 >= names.size, + title = update.manga.title.ifBlank { entry.title }, + thumbnail = update.manga.thumbnail_url ?: entry.thumbnail, + // AniList's genres and the source's: more is known, not less. + tags = (entry.takeIf { it.via != null }?.tags.orEmpty() + genres).ifEmpty { entry.tags }, + chapters = update.chapters.size, + firstUpload = dates.minOrNull(), + lastUpload = dates.maxOrNull(), + completed = update.manga.status == SManga.COMPLETED || update.manga.status == SManga.PUBLISHING_FINISHED, + fetched = true, + ) + }.onFailure { Logger.w(it) { "Recommendation details failed for ${entry.title} on ${source.name}" } } + .getOrDefault(entry) + } + } + }.awaitAll().flatten() + } + + private suspend fun LibraryManga.toRecommended(): Recommended { + val raw = getChapter.awaitAll(manga.id!!, false).map { it.date_upload } + val dates = honestUploadDates(raw) + return Recommended( + datesUntrusted = uploadDatesUntrusted(raw), + sourceId = manga.source, + url = manga.url, + title = manga.title, + thumbnail = manga.thumbnail_url, + tags = manga.getOriginalGenres().orEmpty(), + score = 0f, + because = emptyList(), + chapters = totalChapters, + firstUpload = dates.minOrNull(), + lastUpload = dates.maxOrNull(), + completed = manga.status == SManga.COMPLETED || manga.status == SManga.PUBLISHING_FINISHED, + fromLibrary = true, + fetched = true, + ) + } + + private fun Recommended.toSManga() = SManga.create().also { + it.url = url + it.title = title + it.thumbnail_url = thumbnail + } + + /** + * Turns on the option that spells [tag] the way this source does, or returns false when the + * source has no such option. Sources name genres in their own language, so the canonical + * name is tried first and an approximate match is allowed after; "Status: Completed" never + * comes close to a genre. + */ + private fun FilterList.select(tag: String): Boolean { + fun like(name: String) = canonicalTag(name).equals(tag, true) || + normalizedLevenshteinSimilarity(name.lowercase(), tag.lowercase()) >= TAG_MATCH + for (filter in this) { + when (filter) { + is Filter.Group<*> -> for (item in filter.state) { + when { + item is Filter.CheckBox && like(item.name) -> return true.also { item.state = true } + item is Filter.TriState && like(item.name) -> return true.also { item.state = Filter.TriState.STATE_INCLUDE } + } + } + is Filter.Select<*> -> filter.values.indexOfFirst { like(it.toString()) } + .takeIf { it >= 0 }?.let { filter.state = it; return true } + else -> {} + } + } + return false + } + + companion object { + /** A tag with at least this much of the top tag's weight is searched for. */ + private const val TAG_MIN_WEIGHT = 0.15f + + /** A sanity ceiling on tags searched, however wide the profile. */ + private const val TAG_CAP = 60 + + + /** Series read per batch. A batch is one unit of pause, progress and persistence. */ + const val BATCH = 50 + + /** The top format must outweigh the next this many times before results are kept to its country. */ + private const val COUNTRY_DOMINANCE = 2f + + /** + * How many sources' copies of one series are detailed, so the one with the most chapters + * can be picked. Requests per build ≤ [SHORTLIST] × this, spread over sources. + */ + private const val COPIES = 2 + + /** How close a source's option name must be to a tag to count as the same genre. */ + private const val TAG_MATCH = 0.85 + + /** One request's allowance. A source that hangs costs this, not the build. */ + private const val REQUEST_TIMEOUT_MS = 30_000L + + private const val ANILIST = "https://graphql.anilist.co/" + + /** Aliased searches per GraphQL request. AniList's complexity limit allows a few dozen. */ + private const val POPULARITY_BATCH = 25 + + /** How alike the found title must be to the candidate's before its numbers are trusted. */ + private const val POPULARITY_MATCH = 0.75 + + // ponytail: exact key after stripping case and punctuation; Levenshtein against the whole + // library is the upgrade if "Solo Leveling" and "Solo Leveling (Official)" keep coexisting. + fun String.key() = lowercase().filter { it.isLetterOrDigit() } + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/DiscardRecommendations.kt b/app/src/main/java/karasu/domain/recommendation/DiscardRecommendations.kt new file mode 100644 index 0000000000..182103b591 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/DiscardRecommendations.kt @@ -0,0 +1,31 @@ +package karasu.domain.recommendation + +import karasu.data.DatabaseHandler +import karasu.domain.manga.interactor.GetManga + +/** + * Throws the last build away before a new one: the list, and the manga rows the window created + * for it. Browsing inserts a row per card shown, so a build leaves a few hundred rows behind; + * kept, they are what makes a stale card reappear looking current. Rows that were added to the + * library, or read, are not the window's to remove and stay. + */ +class DiscardRecommendations( + private val store: RecommendationStore, + private val getManga: GetManga, + private val handler: DatabaseHandler, +) { + suspend fun await() { + val old = store.read() + store.clear() + if (old == null) return + val ids = old.items + .filterNot { it.fromLibrary } + .mapNotNull { runCatching { getManga.awaitByUrlAndSource(it.url, it.sourceId) }.getOrNull() } + .filterNot { it.favorite } + .mapNotNull { it.id } + if (ids.isEmpty()) return + runCatching { + handler.await { mangasQueries.deleteNotInLibraryAndNotReadByIds(ids) } + } + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/ExternalSuggestions.kt b/app/src/main/java/karasu/domain/recommendation/ExternalSuggestions.kt new file mode 100644 index 0000000000..fc966f3a83 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/ExternalSuggestions.kt @@ -0,0 +1,596 @@ +package karasu.domain.recommendation + +import co.touchlab.kermit.Logger +import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.jsonMime +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine +import eu.kanade.tachiyomi.source.CatalogueSource +import java.net.URLEncoder +import karasu.domain.manga.failures.ReadFailures +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * What the wider world says is like the reader's favourites, found in the reader's own sources. + * + * Tags say what a series is about; they never say it is *good*, or that people who liked X also + * liked Y. The trackers' recommendations are that — human-curated, per series, and served + * without a login by AniList, MyAnimeList and MangaUpdates — so each strong seed from the + * profile is asked "what is like this?" on all three, and every answer that exists in one of the + * reader's sources becomes a candidate. An answer two services agree on is located first and + * ranked higher: it is the one thing here that is corroborated. (Kitsu has no such endpoint; + * Komga, Kavita and Suwayomi are the reader's own servers and know nothing beyond them.) + * + * Names are the hard part: a Portuguese source lists a series under its Portuguese title, which + * the trackers rarely know. Romaji and English are tried first, then MangaUpdates' associated + * titles when it named the series; for whatever is still not found, MangaDex is asked for the + * series' alternative titles in the reader's languages and the search is repeated with those. + * + * ponytail: bounded by [SEEDS] × [PER_SEED] per service, [LOCATE] suggestions located at most, + * over the sources the library uses, most used first; only the nightly job calls it. Every + * failure is one suggestion fewer, never an error. + */ +class ExternalSuggestions( + private val network: NetworkHelper, + private val languages: List, + private val readFailures: ReadFailures = Injekt.get(), +) { + /** + * One thing a service said was like [seed]. + * + * @param titles every name it goes by that is known, international first. + * @param votes how many services named it, once merged. + */ + data class Suggestion( + val seed: String, + val titles: List, + val genres: List, + val votes: Int = 1, + /** From the catalogue pass: AniList's numbers, so they weigh from the start. */ + val popularity: Int? = null, + val averageScore: Int? = null, + val fromCatalog: Boolean = false, + ) + + private interface Provider { + val name: String + suspend fun similarTo(seed: String): List + } + + /** A suggestion located in a source. */ + data class Located(val suggestion: Suggestion, val sourceId: Long, val url: String, val title: String, val thumbnail: String?) + + /** + * @param onProgress called as work lands: first per seed asked about, then per suggestion + * searched for, over the sum of both. + */ + suspend fun await( + seeds: List, + sources: List, + onProgress: suspend (done: Int, total: Int) -> Unit = { _, _ -> }, + ): List { + if (seeds.isEmpty() || sources.isEmpty()) return emptyList() + val providers = listOf(AniList(), MyAnimeList(), MangaUpdates()) + val asked = seeds.take(SEEDS) + var done = 0 + val total = asked.size + LOCATE + onProgress(done, total) + val raw = asked.flatMap { seed -> + providers.flatMap { provider -> + withTimeoutOrNull(PROVIDER_TIMEOUT_MS) { + runCatching { provider.similarTo(seed) } + .onFailure { Logger.w(it) { "${provider.name} had nothing for $seed" } } + .getOrDefault(emptyList()) + }.orEmpty() + }.also { onProgress(++done, total) } + } + val suggestions = merge(raw).take(LOCATE) + return locate(suggestions, sources) { found, _ -> onProgress(asked.size + found, total) } + } + + /** + * Finds each suggestion in the reader's sources: international names first, then MangaDex's + * names in the reader's languages for what is still missing. + */ + suspend fun locate( + suggestions: List, + sources: List, + onProgress: suspend (done: Int, total: Int) -> Unit = { _, _ -> }, + ): List { + if (suggestions.isEmpty() || sources.isEmpty()) return emptyList() + val engine = SmartSearchEngine(Job() + Dispatchers.IO) + val located = mutableListOf() + val missing = mutableListOf() + var done = 0 + for (suggestion in suggestions) { + locate(engine, suggestion, suggestion.titles.take(TITLES), sources)?.let(located::add) ?: missing.add(suggestion) + onProgress(++done, suggestions.size) + } + // Second pass, only for what no source knew by its international names. + for (suggestion in missing) { + val local = runCatching { altTitles(suggestion.titles.first()) }.getOrDefault(emptyList()) + if (local.isEmpty()) continue + locate(engine, suggestion, local, sources)?.let(located::add) + } + return located + } + + /** + * AniList's own catalogue, searched by the reader's tags: what the community rates best and + * reads most under each, plus what started in the last [recentYears] years. This is the one place a search + * by several tags means "all of them", and the one place a candidate arrives with a score + * someone gave it. Aliased fields batch [CATALOG_BATCH] searches per request. + * + * @param country an ISO code to keep to, when the reader's formats say so, else null. + */ + suspend fun catalog(profile: TasteProfile, country: String?, recentYears: Int): List { + // Every tag the reader is at least somewhat into, the same cut the source searches use. + val tags = profile.tags.filterValues { it >= CATALOG_TAG_MIN_WEIGHT }.keys.take(CATALOG_TAG_CAP) + if (tags.isEmpty()) return emptyList() + val passes = listOf( + suspend { anilistCatalog(profile, tags, country, recentYears) }, + suspend { mangaUpdatesCatalog(tags, country) }, + suspend { kitsuCatalog(tags, country) }, + suspend { myAnimeListCatalog(tags, country) }, + ) + val all = passes.flatMap { pass -> runCatching { pass() }.onFailure { Logger.w(it) { "Catalogue pass failed" } }.getOrDefault(emptyList()) } + // The same series in two catalogues is one, with a vote from each and its best score: + // agreement between services is worth more than any one of them. + return all.groupBy { it.titles.first().lowercase().filter { c -> c.isLetterOrDigit() } } + .values + .map { same -> + Suggestion( + seed = same.map { it.seed }.distinct().joinToString(", "), + titles = same.flatMap { it.titles }.distinctBy { it.lowercase() }, + genres = same.flatMap { it.genres }.distinctBy { it.lowercase() }, + votes = same.map { it.seed }.distinct().size, + popularity = same.mapNotNull { it.popularity }.maxOrNull(), + averageScore = same.mapNotNull { it.averageScore }.maxOrNull(), + fromCatalog = true, + ) + } + .sortedWith(compareByDescending { it.votes }.thenByDescending { it.averageScore ?: 0 }) + .take(CATALOG_LOCATE) + } + + private suspend fun anilistCatalog(profile: TasteProfile, tags: List, country: String?, recentYears: Int): List { + val sinceYear = java.time.Year.now().value - recentYears + data class Ask(val tags: List, val sort: String, val since: Int?) + val asks = tags.flatMap { listOf(Ask(listOf(it), "SCORE_DESC", null), Ask(listOf(it), "POPULARITY_DESC", null), Ask(listOf(it), "SCORE_DESC", sinceYear)) } + + profile.pairs.keys.take(CATALOG_PAIRS).map { it.split('|') }.filter { it.size == 2 } + .map { halves -> Ask(halves.map { half -> tags.firstOrNull { t -> t.equals(half, true) } ?: half }, "SCORE_DESC", null) } + + val out = mutableListOf() + asks.chunked(CATALOG_BATCH).forEach { batch -> + runCatching { + val fields = batch.mapIndexed { i, ask -> + val genres = ask.tags.filter { it in ANILIST_GENRES } + val others = ask.tags.filterNot { it in ANILIST_GENRES } + val args = buildList { + add("type: MANGA") + add("format_not_in: [NOVEL]") + add("sort: ${ask.sort}") + add("averageScore_greater: $CATALOG_MIN_SCORE") + if (genres.isNotEmpty()) add("genre_in: [" + genres.joinToString { it.graphqlLiteral() } + "]") + if (others.isNotEmpty()) add("tag_in: [" + others.joinToString { it.graphqlLiteral() } + "]") + if (ask.since != null) add("startDate_greater: ${ask.since}0000") + if (country != null) add("countryOfOrigin: \"$country\"") + }.joinToString(", ") + "a$i: Page(perPage: $CATALOG_PER_PAGE) { media($args) { title { romaji english } synonyms genres tags { name rank } averageScore popularity } }" + }.joinToString("\n") + val root = network.client.newCall(POST(ANILIST, body = buildJsonObject { put("query", "{ $fields }") }.toString().toRequestBody(jsonMime))) + .awaitSuccess() + .parseAs()["data"]?.jsonObject ?: return@runCatching + batch.indices.forEach { i -> + root["a$i"]?.jsonObject?.get("media")?.jsonArray.orEmpty().forEach { m -> + val obj = m.jsonObject + val names = obj["title"]?.jsonObject + val titles = listOfNotNull(names?.get("romaji")?.jsonPrimitive?.contentOrNull, names?.get("english")?.jsonPrimitive?.contentOrNull) + + obj["synonyms"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + if (titles.isEmpty()) return@forEach + val genres = obj["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + + obj["tags"]?.jsonArray.orEmpty().mapNotNull { tag -> + val t = tag.jsonObject + t["name"]?.jsonPrimitive?.contentOrNull?.takeIf { (t["rank"]?.jsonPrimitive?.intOrNull ?: 0) >= TAG_RANK } + } + out += Suggestion( + seed = "AniList", + titles = titles.distinct(), + genres = genres, + popularity = obj["popularity"]?.jsonPrimitive?.intOrNull, + averageScore = obj["averageScore"]?.jsonPrimitive?.intOrNull, + fromCatalog = true, + ) + } + } + }.onFailure { Logger.w(it) { "AniList catalogue batch failed" } } + } + // The same series under several tags is one; the best-scored copy is as good as any. + return out.groupBy { it.titles.first().lowercase().filter { c -> c.isLetterOrDigit() } } + .values.map { same -> same.maxBy { it.averageScore ?: 0 } } + } + + /** + * MangaUpdates' search, rated best first, per tag. Its genres are a fixed list and anything + * else is a "category"; the search takes both and means all of them. + */ + private suspend fun mangaUpdatesCatalog(tags: List, country: String?): List { + val type = when (country) { "KR" -> "Manhwa"; "CN" -> "Manhua"; "JP" -> "Manga"; else -> null } + return tags.flatMap { tag -> + val body = buildJsonObject { + if (tag in MU_GENRES) put("genre", buildJsonArray { add(tag) }) else put("category", buildJsonArray { add(tag) }) + type?.let { put("type", buildJsonArray { add(it) }) } + put("orderby", "rating") + put("perpage", CATALOG_PER_PAGE) + put("filter_types", buildJsonArray { add("drama cd"); add("novel") }) + } + runCatching { + network.client.newCall(POST("$MU/v1/series/search", body = body.toString().toRequestBody(jsonMime))) + .awaitSuccess().parseAs()["results"]?.jsonArray.orEmpty() + .mapNotNull { r -> + val rec = r.jsonObject["record"]?.jsonObject ?: return@mapNotNull null + val title = rec["title"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val rating = rec["bayesian_rating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() + Suggestion( + seed = "MangaUpdates", + titles = listOf(title), + genres = rec["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject["genre"]?.jsonPrimitive?.contentOrNull }, + popularity = rec["rating_votes"]?.jsonPrimitive?.intOrNull, + averageScore = rating?.let { (it * 10).toInt() }, + fromCatalog = true, + ) + } + }.onFailure { Logger.w(it) { "MangaUpdates catalogue failed for $tag" } }.getOrDefault(emptyList()) + } + } + + /** Kitsu's catalogue, best rated first, per tag; genres and categories are both slugs. */ + private suspend fun kitsuCatalog(tags: List, country: String?): List { + val subtype = when (country) { "KR" -> "manhwa"; "CN" -> "manhua"; "JP" -> "manga"; else -> null } + return tags.flatMap { tag -> + // A slug, then URL-encoded: a tag is text a source or the reader wrote, not a path. + val slug = URLEncoder.encode(tag.lowercase().replace(' ', '-'), "UTF-8") + val filter = if (tag in KITSU_GENRES) "filter[genres]=$slug" else "filter[categories]=$slug" + val url = "$KITSU/manga?$filter&sort=-averageRating&page[limit]=$CATALOG_PER_PAGE" + + (subtype?.let { "&filter[subtype]=$it" } ?: "") + + "&fields[manga]=canonicalTitle,titles,abbreviatedTitles,averageRating,userCount" + runCatching { + network.client.newCall(GET(url.encodeBrackets(), kitsuHeaders)).awaitSuccess().parseAs()["data"]?.jsonArray.orEmpty() + .mapNotNull { m -> + val a = m.jsonObject["attributes"]?.jsonObject ?: return@mapNotNull null + val title = a["canonicalTitle"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val others = a["titles"]?.jsonObject?.values.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + + a["abbreviatedTitles"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + Suggestion( + seed = "Kitsu", + titles = (listOf(title) + others).distinct(), + genres = listOf(tag), + popularity = a["userCount"]?.jsonPrimitive?.intOrNull, + averageScore = a["averageRating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()?.toInt(), + fromCatalog = true, + ) + } + }.onFailure { Logger.w(it) { "Kitsu catalogue failed for $tag" } }.getOrDefault(emptyList()) + } + } + + /** + * MyAnimeList's ranking for the reader's kind of series, by score and by popularity. It + * cannot filter by genre, but it reports them: only entries carrying one of the reader's + * tags are kept. + */ + private suspend fun myAnimeListCatalog(tags: List, country: String?): List { + val type = when (country) { "KR" -> "manhwa"; "CN" -> "manhua"; else -> "manga" } + val wanted = tags.map { it.lowercase() }.toSet() + return listOf(type, "bypopularity").flatMap { ranking -> + runCatching { + network.client.newCall(GET("$MAL/manga/ranking?ranking_type=$ranking&limit=$MAL_RANKING&fields=mean,genres,num_list_users,alternative_titles", malHeaders)) + .awaitSuccess().parseAs()["data"]?.jsonArray.orEmpty() + .mapNotNull { r -> + val node = r.jsonObject["node"]?.jsonObject ?: return@mapNotNull null + val genres = node["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull } + if (genres.none { canonicalTag(it).lowercase() in wanted }) return@mapNotNull null + val title = node["title"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val alt = node["alternative_titles"]?.jsonObject + val others = listOfNotNull(alt?.get("en")?.jsonPrimitive?.contentOrNull) + + alt?.get("synonyms")?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + Suggestion( + seed = "MyAnimeList", + titles = (listOf(title) + others).distinct(), + genres = genres, + popularity = node["num_list_users"]?.jsonPrimitive?.intOrNull, + averageScore = node["mean"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()?.let { (it * 10).toInt() }, + fromCatalog = true, + ) + } + }.onFailure { Logger.w(it) { "MyAnimeList ranking failed for $ranking" } }.getOrDefault(emptyList()) + } + } + + /** Kitsu's JSON:API wants its brackets escaped, and OkHttp will not do it for us. */ + private fun String.encodeBrackets() = replace("[", "%5B").replace("]", "%5D") + + /** + * A tag as a GraphQL string literal. Tags come from sources and from the reader's own typing, + * so quotes, backslashes and line breaks are stripped rather than trusted: nothing typed + * into a tag can close the literal and rewrite the query. + */ + private fun String.graphqlLiteral(): String = "\"" + filter { it != '"' && it != '\\' && it >= ' ' }.take(64) + "\"" + + /** + * The same series named by two services is one suggestion with two votes and the union of + * their names. Matched on the first title stripped of case and punctuation: services + * romanise alike far more often than they agree on an English name. + */ + private fun merge(raw: List): List = raw + .groupBy { it.titles.first().lowercase().filter { c -> c.isLetterOrDigit() } } + .values + .map { same -> + Suggestion( + seed = same.first().seed, + titles = same.flatMap { it.titles }.distinctBy { it.lowercase() }, + genres = same.flatMap { it.genres }.distinctBy { it.lowercase() }, + votes = same.size, + ) + } + .sortedByDescending { it.votes } + + /** + * All sources at once, each cut off at [SEARCH_TIMEOUT_MS]; the first in the reader's order + * that has it wins. A source that times out is marked failing and left out for the rest of + * the run, so one dead source costs one timeout, not one per title. + */ + private suspend fun locate( + engine: SmartSearchEngine, + suggestion: Suggestion, + titles: List, + sources: List, + ): Located? { + val live = sources.filterNot { readFailures.isSourceFailing(it.id) } + if (live.isEmpty()) return null + val hits = coroutineScope { + live.map { source -> + async { + // Optional.empty = searched and not there; null = timed out or failed. + val outcome = withTimeoutOrNull(SEARCH_TIMEOUT_MS) { + runCatching { java.util.Optional.ofNullable(engine.normalSearchAliases(source, titles)) } + .getOrNull() + } + if (outcome == null) readFailures.recordSource(source.id) + source to outcome?.orElse(null) + } + }.awaitAll() + } + val (source, found) = hits.firstOrNull { (_, hit) -> hit != null } ?: return null + return Located(suggestion, source.id, found!!.url, found.title, found.thumbnail_url) + } + + /** One GraphQL call per seed: the best match and its recommendations, best rated first. */ + private inner class AniList : Provider { + override val name = "AniList" + + override suspend fun similarTo(seed: String): List { + val payload = buildJsonObject { + put("query", ANILIST_QUERY) + putJsonObject("variables") { put("q", seed) } + } + val root = network.client.newCall(POST(ANILIST, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + .parseAs() + val media = root["data"]?.jsonObject?.get("Media")?.jsonObject ?: return emptyList() + val seedName = media["title"]?.jsonObject?.get("romaji")?.jsonPrimitive?.contentOrNull ?: seed + return media["recommendations"]?.jsonObject?.get("nodes")?.jsonArray.orEmpty() + .mapNotNull { node -> + val rec = node.jsonObject["mediaRecommendation"]?.takeIf { it !is JsonNull }?.jsonObject + ?: return@mapNotNull null + val names = rec["title"]?.jsonObject + val titles = listOfNotNull( + names?.get("romaji")?.jsonPrimitive?.contentOrNull, + names?.get("english")?.jsonPrimitive?.contentOrNull, + ) + rec["synonyms"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + if (titles.isEmpty()) return@mapNotNull null + val genres = rec["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + + rec["tags"]?.jsonArray.orEmpty().mapNotNull { tag -> + val obj = tag.jsonObject + obj["name"]?.jsonPrimitive?.contentOrNull + ?.takeIf { (obj["rank"]?.jsonPrimitive?.intOrNull ?: 0) >= TAG_RANK } + } + Suggestion(seedName, titles.distinct(), genres) + } + .take(PER_SEED) + } + } + + /** + * Two calls per seed, with the app's own client id and no user token: the search, then the + * details with the `recommendations` field. Nodes carry a title only; the source's details + * fill the genres in later. + */ + private inner class MyAnimeList : Provider { + override val name = "MyAnimeList" + + override suspend fun similarTo(seed: String): List { + val search = network.client.newCall(GET("$MAL/manga?q=${seed.take(64).encode()}&limit=1&nsfw=true", malHeaders)) + .awaitSuccess() + .parseAs() + val node = search["data"]?.jsonArray?.firstOrNull()?.jsonObject?.get("node")?.jsonObject ?: return emptyList() + val id = node["id"]?.jsonPrimitive?.contentOrNull ?: return emptyList() + val seedName = node["title"]?.jsonPrimitive?.contentOrNull ?: seed + val details = network.client.newCall(GET("$MAL/manga/$id?fields=recommendations,alternative_titles", malHeaders)) + .awaitSuccess() + .parseAs() + return details["recommendations"]?.jsonArray.orEmpty() + .mapNotNull { rec -> + val title = rec.jsonObject["node"]?.jsonObject?.get("title")?.jsonPrimitive?.contentOrNull + ?: return@mapNotNull null + Suggestion(seedName, listOf(title), emptyList()) + } + .take(PER_SEED) + } + } + + /** + * Search, then the series page, then one page per recommended series: that last hop is what + * buys the associated titles, which is where a Portuguese release name is most likely to be. + */ + private inner class MangaUpdates : Provider { + override val name = "MangaUpdates" + + override suspend fun similarTo(seed: String): List { + val body = buildJsonObject { + put("search", seed) + put("filter_types", buildJsonArray { add("drama cd"); add("novel") }) + } + val search = network.client.newCall(POST("$MU/v1/series/search", body = body.toString().toRequestBody(jsonMime))) + .awaitSuccess() + .parseAs() + val record = search["results"]?.jsonArray?.firstOrNull()?.jsonObject?.get("record")?.jsonObject ?: return emptyList() + val id = record["series_id"]?.jsonPrimitive?.contentOrNull ?: return emptyList() + val seedName = record["title"]?.jsonPrimitive?.contentOrNull ?: seed + val series = network.client.newCall(GET("$MU/v1/series/$id")).awaitSuccess().parseAs() + val recommended = series["recommendations"]?.jsonArray.orEmpty() + .sortedByDescending { it.jsonObject["weight"]?.jsonPrimitive?.intOrNull ?: 0 } + .take(PER_SEED) + return recommended.mapNotNull { rec -> + val recId = rec.jsonObject["series_id"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val page = runCatching { + network.client.newCall(GET("$MU/v1/series/$recId")).awaitSuccess().parseAs() + }.getOrNull() + val title = page?.get("title")?.jsonPrimitive?.contentOrNull + ?: rec.jsonObject["series_name"]?.jsonPrimitive?.contentOrNull + ?: return@mapNotNull null + val associated = page?.get("associated")?.jsonArray.orEmpty() + .mapNotNull { it.jsonObject["title"]?.jsonPrimitive?.contentOrNull } + val genres = page?.get("genres")?.jsonArray.orEmpty() + .mapNotNull { it.jsonObject["genre"]?.jsonPrimitive?.contentOrNull } + Suggestion(seedName, (listOf(title) + associated).distinct(), genres) + } + } + } + + private val malHeaders = okhttp3.Headers.headersOf("X-MAL-CLIENT-ID", MyAnimeListApi.CLIENT_ID) + private val kitsuHeaders = okhttp3.Headers.headersOf("Accept", "application/vnd.api+json") + + private fun String.encode() = URLEncoder.encode(this, "UTF-8") + + /** MangaDex's alternative titles for [title] in the reader's languages, most local first. */ + private suspend fun altTitles(title: String): List { + val url = "$MANGADEX/manga?limit=1&title=" + title.encode() + val root = network.client.newCall(GET(url)).awaitSuccess().parseAs() + val entry = root["data"]?.jsonArray?.firstOrNull()?.jsonObject ?: return emptyList() + val alts = entry["attributes"]?.jsonObject?.get("altTitles")?.jsonArray.orEmpty() + .flatMap { it.jsonObject.entries } + return languages.flatMap { lang -> + alts.filter { (key, _) -> key.equals(lang, true) }.mapNotNull { (_, v) -> v.jsonPrimitive.contentOrNull } + }.distinct() + } + + companion object { + private const val ANILIST = "https://graphql.anilist.co/" + private const val MAL = "https://api.myanimelist.net/v2" + private const val MU = "https://api.mangaupdates.com" + private const val KITSU = "https://kitsu.io/api/edge" + private const val MANGADEX = "https://api.mangadex.org" + + /** How many of the profile's seeds are asked about. */ + private const val SEEDS = 5 + + /** How many answers per seed per service are kept. */ + private const val PER_SEED = 5 + + /** How many merged suggestions are searched for in the sources, most agreed first. */ + private const val LOCATE = 40 + + /** International titles tried per suggestion per source before falling back to MangaDex. */ + private const val TITLES = 2 + + /** One tracker call's allowance. */ + private const val PROVIDER_TIMEOUT_MS = 20_000L + + /** One source search's allowance when locating a title. */ + private const val SEARCH_TIMEOUT_MS = 20_000L + + /** AniList tags below this rank are noise ("Male Protagonist" at 30%). */ + private const val TAG_RANK = 60 + + /** Catalogue pass: tags asked about (all at or above this weight), pairs, searches per request, results each. */ + private const val CATALOG_TAG_MIN_WEIGHT = 0.15f + private const val CATALOG_TAG_CAP = 60 + private const val CATALOG_PAIRS = 5 + private const val CATALOG_BATCH = 6 + private const val CATALOG_PER_PAGE = 20 + + /** Below this AniList score the community has said no. */ + private const val CATALOG_MIN_SCORE = 65 + + /** How many catalogue finds are looked for in the sources, most agreed on first. */ + private const val CATALOG_LOCATE = 150 + + /** Entries of each MyAnimeList ranking read. */ + private const val MAL_RANKING = 200 + + /** MangaUpdates' genre list; anything else is one of its categories. */ + private val MU_GENRES = setOf( + "Action", "Adult", "Adventure", "Comedy", "Doujinshi", "Drama", "Ecchi", "Fantasy", "Gender Bender", "Harem", + "Hentai", "Historical", "Horror", "Josei", "Martial Arts", "Mature", "Mecha", "Mystery", "Psychological", + "Romance", "School Life", "Sci-fi", "Seinen", "Shoujo", "Shoujo Ai", "Shounen", "Shounen Ai", "Slice of Life", + "Smut", "Sports", "Supernatural", "Tragedy", "Yaoi", "Yuri", + ) + + /** Kitsu's genre list; anything else is one of its categories. */ + private val KITSU_GENRES = setOf( + "Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy", "Harem", "Historical", "Horror", "Josei", + "Martial Arts", "Mecha", "Mystery", "Psychological", "Romance", "School", "Sci-Fi", "Seinen", "Shoujo", + "Shounen", "Slice of Life", "Sports", "Supernatural", "Thriller", "Tragedy", "Yaoi", "Yuri", + ) + + /** AniList's fixed genre list; everything else is a tag, and the query says which is which. */ + private val ANILIST_GENRES = setOf( + "Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy", "Horror", "Mahou Shoujo", "Mecha", + "Music", "Mystery", "Psychological", "Romance", "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller", + ) + + private val ANILIST_QUERY = """ + |query (${'$'}q: String) { + |Media(search: ${'$'}q, type: MANGA, format_not_in: [NOVEL]) { + |title { romaji } + |recommendations(sort: RATING_DESC, perPage: 8) { + |nodes { + |rating + |mediaRecommendation { + |title { romaji english } + |synonyms + |genres + |tags { name rank } + |} + |} + |} + |} + |} + """.trimMargin() + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/GetTasteProfile.kt b/app/src/main/java/karasu/domain/recommendation/GetTasteProfile.kt new file mode 100644 index 0000000000..57a350b8a0 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/GetTasteProfile.kt @@ -0,0 +1,139 @@ +package karasu.domain.recommendation + +import android.content.Context +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import karasu.domain.chapter.interactor.GetChapter +import karasu.domain.manga.interactor.GetLibraryManga +import karasu.domain.track.interactor.GetTrack +import karasu.i18n.MR +import karasu.util.lang.getString +import kotlinx.serialization.json.Json + +/** + * The library and everything the trackers said about it, folded into a [TasteProfile]. + * + * Reads only the local `manga_sync` rows — the trackers' opinion is already on the phone, and it + * is there for every service, logged in or not. + */ +class GetTasteProfile( + private val context: Context, + private val getLibraryManga: GetLibraryManga, + private val getTrack: GetTrack, + private val getChapter: GetChapter, + private val trackManager: TrackManager, + private val preferences: PreferencesHelper, + private val feedback: RecommendationFeedbackStore, + private val extras: TrackerExtrasStore, +) { + init { + // Every path that normalises a tag goes through a profile first; this is where the + // reader's merges get loaded. + TagMerges.user = merges() + } + + fun merges(): Map = + runCatching { Json.decodeFromString>(preferences.recommendationTagMerges().get()) } + .getOrDefault(emptyMap()) + + /** [tags] become one tag called [into]; any override on the absorbed ones goes with them. */ + fun merge(tags: Collection, into: String) { + val name = into.trim().ifBlank { return } + val absorbed = tags.map { canonicalTag(it) }.filter { !it.equals(name, ignoreCase = true) } + val next = merges() + absorbed.associate { it.lowercase() to name } + preferences.recommendationTagMerges().set(Json.encodeToString(next)) + TagMerges.user = next + setOverrides(overrides().filterKeys { key -> absorbed.none { it.equals(key, ignoreCase = true) } }) + } + + fun unmergeAll() { + preferences.recommendationTagMerges().set("{}") + TagMerges.user = emptyMap() + } + + /** + * The profile the recommender uses: what the library taught, shrunk by what the reader + * dismissed, with the reader's overrides on top. In that order, so a slider the reader set + * always wins. + */ + suspend fun await(): TasteProfile = awaitLearned() + .boosted(feedback.addedTags()) + .penalized(feedback.dismissedTags()) + .adjusted(overrides()) + + /** What the library taught alone, for the screen that shows the reader what to override. */ + suspend fun awaitLearned(): TasteProfile { + // Only the chosen categories teach the profile: a "maybe later" shelf full of things + // never really read says nothing about taste. Empty means all of them. + val categories = preferences.recommendationCategories().get().mapNotNull { it.toIntOrNull() }.toSet() + val library = getLibraryManga.await() + .filter { categories.isEmpty() || it.category in categories } + .distinctBy { it.manga.id } + if (library.isEmpty()) return TasteProfile() + val tracks = getTrack.awaitAll().groupBy { it.manga_id } + // A source that lists the scanlation group among the genres is teaching the profile a + // group's name. The library knows every group's name, so those never get in — nor does + // anything the reader struck out by hand. + val junk = getChapter.awaitAllScanlators() + banned().map { it.lowercase() } + val entries = library.map { manga -> + manga.toEntry(tracks[manga.manga.id].orEmpty().mapNotNull(::ratingOf)) + .let { entry -> entry.copy(tags = entry.tags.filterNot { isJunk(it, junk) }) } + } + // The tag helpers live on the Manga interface and use nothing of the instance. + val any = library.first().manga + return buildTasteProfile(entries, isFormatTag = { any.isSeriesTag(it) }, now = System.currentTimeMillis()) + } + + /** Struck by hand, spelled as the profile spells them. */ + fun banned(): Set = preferences.recommendationBannedTags().get() + + fun ban(tag: String) = preferences.recommendationBannedTags().set(banned() + canonicalTag(tag)) + + fun unbanAll() = preferences.recommendationBannedTags().set(emptySet()) + + private fun isJunk(tag: String, junk: Set): Boolean { + val raw = tag.trim().lowercase() + val canonical = normalizeTag(tag)?.lowercase() ?: return true + return raw in junk || canonical in junk + } + + fun overrides(): Map = + runCatching { Json.decodeFromString>(preferences.recommendationTagWeights().get()) } + .getOrDefault(emptyMap()) + + fun setOverrides(overrides: Map) = + preferences.recommendationTagWeights().set(Json.encodeToString(overrides)) + + private fun LibraryManga.toEntry(ratings: List) = LibraryEntry( + mangaId = manga.id!!, + title = manga.title, + tags = manga.getOriginalGenres().orEmpty() + extras.get(manga.id)?.tags.orEmpty(), + readFraction = if (totalChapters > 0) read.toFloat() / totalChapters else 0f, + favorite = manga.favorite, + ratings = ratings, + lastReadAt = lastRead.takeIf { it > 0 }, + ) + + /** + * Komga and Suwayomi have no score scale, so their rows always say `0f`; read as a score that + * would be "the reader hated all 300 of these". A score only counts when the service has a + * scale and the reader used it. + */ + private fun ratingOf(track: Track): Rating? { + val service = trackManager.getService(track.sync_id) ?: return null + val status = when { + service.getGlobalStatus(track.status) == context.getString(MR.strings.dropped) -> Rating.Status.DROPPED + track.status == service.completedStatus() -> Rating.Status.COMPLETED + track.status == service.readingStatus() -> Rating.Status.READING + else -> Rating.Status.OTHER + } + val score = if (service.getScoreList().isNotEmpty() && track.score > 0f) { + (service.get10PointScore(track.score) / 10f).coerceIn(0f, 1f) + } else { + null + } + return Rating(score, status) + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/MergeSuggestions.kt b/app/src/main/java/karasu/domain/recommendation/MergeSuggestions.kt new file mode 100644 index 0000000000..f574a441ea --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/MergeSuggestions.kt @@ -0,0 +1,161 @@ +package karasu.domain.recommendation + +import android.content.Context +import co.touchlab.kermit.Logger +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.smartsearch.SmartSearchEngine +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.RecommendationSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.util.lang.toNormalized +import java.io.File +import karasu.domain.manga.interactor.GetLibraryManga +import karasu.domain.manga.merged.interactor.MergedSources +import karasu.domain.migration.MangaAliases +import karasu.util.normalizedLevenshteinSimilarity +import karasu.domain.manga.failures.ReadFailures +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** The same series, found on another of the reader's sources. */ +@Serializable +data class MergeSuggestion(val sourceId: Long, val url: String, val title: String) + +/** What the last look found for one entry, and when it looked. */ +@Serializable +data class MergeCheck(val checkedAt: Long, val found: List) + +/** + * ponytail: one JSON file. Rebuilt by the nightly pass, so nothing here needs a backup; the + * "not this one" list is the exception and small enough to live in the same file. + */ +class MergeSuggestionsStore(context: Context) { + + @Serializable + private data class State(val checks: Map = emptyMap(), val ignored: Set = emptySet()) + + private val file = File(context.filesDir, "merge_suggestions.json") + + @Volatile + private var cached: State? = null + + private fun state(): State = cached ?: runCatching { + if (file.exists()) Json.decodeFromString(file.readText()) else State() + }.getOrDefault(State()).also { cached = it } + + private fun write(next: State) { + file.writeText(Json.encodeToString(next)) + cached = next + } + + fun check(mangaId: Long?): MergeCheck? = mangaId?.let { state().checks[it] } + + fun put(mangaId: Long, check: MergeCheck) = write(state().copy(checks = state().checks + (mangaId to check))) + + /** What to offer for [mangaId]: found, and not waved away before. */ + fun pending(mangaId: Long?): List = check(mangaId)?.found.orEmpty() + .filter { "$mangaId:${it.sourceId}" !in state().ignored } + + /** The reader said no to this source for this entry; do not offer it again. */ + fun ignore(mangaId: Long, sourceId: Long) = write(state().copy(ignored = state().ignored + "$mangaId:$sourceId")) +} + +/** + * Looks, overnight, for library entries on other sources the reader has, so a merge is one tap + * instead of a search. Everything migration already knows how to do — the names a series goes + * by, the search, the confidence — pointed at the sources the reader has *not* got it on. + * + * Bounded: [PER_NIGHT] entries a night, the ones looked at longest ago first, searched only on + * sources in the same language as their own, all sources of one entry at once, each search cut + * off at [SEARCH_TIMEOUT_MS] and the whole pass at [STAGE_BUDGET_MS] — a source that hangs must + * cost one timeout, not the night. A miss is remembered too, so the same series is not searched + * again for [RECHECK_MS]. + */ +class FindMergeCandidates( + private val getLibraryManga: GetLibraryManga, + private val mergedSources: MergedSources, + private val sourceManager: SourceManager, + private val preferences: PreferencesHelper, + private val aliases: MangaAliases, + private val store: MergeSuggestionsStore, + private val readFailures: ReadFailures, +) { + suspend fun await(now: Long = System.currentTimeMillis(), onProgress: suspend (done: Int, total: Int) -> Unit = { _, _ -> }) { + // A source that failed today is skipped, not waited on again. + val enabled = enabledSources().filterNot { readFailures.isSourceFailing(it.id) } + if (enabled.size < 2) return + val due = getLibraryManga.await().distinctBy { it.manga.id }.map { it.manga } + .filter { (store.check(it.id)?.checkedAt ?: 0L) < now - RECHECK_MS } + .sortedBy { store.check(it.id)?.checkedAt ?: 0L } + .take(PER_NIGHT) + if (due.isEmpty()) return + onProgress(0, due.size) + + val engine = SmartSearchEngine(Job() + Dispatchers.IO) + val deadline = System.currentTimeMillis() + STAGE_BUDGET_MS + due.forEachIndexed { index, manga -> + // Out of time: the rest keeps its old check date and comes first tomorrow. + if (System.currentTimeMillis() > deadline) return + val id = manga.id ?: return@forEachIndexed + val own = sourceManager.get(manga.source) + val taken = mergedSources.await(id).map { it.source }.toSet() + manga.source + // Re-checked per entry: a source that timed out on the last one is out for tonight. + val targets = enabled.filter { it.id !in taken && (own == null || it.lang == own.lang) && !readFailures.isSourceFailing(it.id) } + val names = aliases.local(manga).take(NAMES) + val found = coroutineScope { + targets.map { source -> + async { + val hit = withTimeoutOrNull(SEARCH_TIMEOUT_MS) { + runCatching { engine.normalSearchAliases(source, names) } + .onFailure { + Logger.w(it) { "Merge lookup failed on ${source.name} for ${manga.title}" } + readFailures.recordSource(source.id) + } + .getOrNull() + } + if (hit == null) readFailures.recordSource(source.id) + hit?.takeIf { h -> names.any { normalizedLevenshteinSimilarity(it.toNormalized(), h.title.toNormalized()) >= CONFIDENT } } + ?.let { MergeSuggestion(source.id, it.url, it.title) } + } + }.awaitAll().filterNotNull() + } + store.put(id, MergeCheck(now, found)) + onProgress(index + 1, due.size) + } + } + + private fun enabledSources(): List { + val languages = preferences.enabledLanguages().get() + val hidden = preferences.hiddenSources().get() + return sourceManager.getCatalogueSources().filter { + it.lang in languages && it.id.toString() !in hidden && it.id != LocalSource.ID && it.id != RecommendationSource.ID + } + } + + companion object { + /** Entries looked at per night. Requests ≈ this × same-language sources × names tried. */ + private const val PER_NIGHT = 20 + + /** Names tried per source before giving up on it. */ + private const val NAMES = 2 + + /** A found title this close to one of the names is the same series, not a lookalike. */ + private const val CONFIDENT = 0.9 + + /** How long a look, hit or miss, stays fresh. */ + private const val RECHECK_MS = 30L * 24 * 60 * 60 * 1000 + + /** One search's allowance. Past this the source is treated as failing for the night. */ + private const val SEARCH_TIMEOUT_MS = 20_000L + + /** The whole pass's allowance; what is left waits for the next night. */ + private const val STAGE_BUDGET_MS = 8L * 60 * 1000 + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/RecommendationFeedbackStore.kt b/app/src/main/java/karasu/domain/recommendation/RecommendationFeedbackStore.kt new file mode 100644 index 0000000000..23b18e3928 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/RecommendationFeedbackStore.kt @@ -0,0 +1,69 @@ +package karasu.domain.recommendation + +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * What the reader said about a recommendation. The one signal in the whole system that is a + * gesture rather than an inference. + * + * Three gestures, kept apart because they mean different things: "not interested" is evidence + * against the tags it was recommended for; "already read" says they liked it enough to have read + * it, and must never penalise anything; "added" is the reader taking the recommendation, the + * clearest yes there is. The first two hide the entry; an added one is in the library now and + * leaves on the next build by itself. + * + * @param titleKey the title stripped of case and punctuation, so the same series from another + * source stays hidden too. + * @param because the tags the recommendation was made for. Only those are penalised: the reader + * is rejecting the reason, not every tag the series happens to carry. + */ +@Serializable +data class RecommendationFeedback( + val sourceId: Long, + val url: String, + val titleKey: String, + val kind: Kind, + val because: List, + val at: Long, +) { + enum class Kind { NOT_INTERESTED, ALREADY_READ, ADDED } +} + +/** + * Kept in a preference rather than a file: preferences travel in backups, and these verdicts are + * the reader's own work. A table is the upgrade if this grows past a few thousand entries. + */ +class RecommendationFeedbackStore(private val preferences: PreferencesHelper) { + + @Volatile + private var cached: List? = null + + fun all(): List = cached ?: runCatching { + Json.decodeFromString>(preferences.recommendationFeedback().get()) + }.getOrDefault(emptyList()).also { cached = it } + + fun add(feedback: RecommendationFeedback) = write( + all().filterNot { it.sourceId == feedback.sourceId && it.url == feedback.url } + feedback, + ) + + /** The tag sets behind every "not interested", for [TasteProfile.penalized]. */ + fun dismissedTags(): List> = + all().filter { it.kind == RecommendationFeedback.Kind.NOT_INTERESTED }.map { it.because } + + /** The tag sets behind every add from the window, for [TasteProfile.boosted]. */ + fun addedTags(): List> = + all().filter { it.kind == RecommendationFeedback.Kind.ADDED }.map { it.because } + + /** What should no longer be shown: everything but the adds. */ + fun hidden(): List = all().filter { it.kind != RecommendationFeedback.Kind.ADDED } + + /** "Zero what was learned": the reader's way out of a bad run of dismissals. */ + fun clear() = write(emptyList()) + + private fun write(list: List) { + preferences.recommendationFeedback().set(Json.encodeToString(list)) + cached = list + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/RecommendationStore.kt b/app/src/main/java/karasu/domain/recommendation/RecommendationStore.kt new file mode 100644 index 0000000000..5043eb893e --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/RecommendationStore.kt @@ -0,0 +1,50 @@ +package karasu.domain.recommendation + +import android.content.Context +import java.io.File +import kotlinx.serialization.json.Json + +/** + * The last built [RecommendationSnapshot], on disk, so the window opens without touching the + * network. One JSON file: a few hundred entries, rewritten once a night. + */ +class RecommendationStore(context: Context) { + + private val file = File(context.filesDir, "recommendations.json") + + @Volatile + private var cached: Pair? = null + + fun read(): RecommendationSnapshot? { + if (!file.exists()) return null + val stamp = file.lastModified() + cached?.takeIf { (at, _) -> at == stamp }?.let { (_, snapshot) -> return snapshot } + return runCatching { Json.decodeFromString(file.readText()) } + .getOrNull() + ?.also { cached = stamp to it } + } + + @Synchronized + fun write(snapshot: RecommendationSnapshot) { + file.writeText(Json.encodeToString(snapshot)) + cached = file.lastModified() to snapshot + } + + /** + * Writes [next] only if what is on disk is still the build it was derived from. The window + * re-ranks a snapshot it read a moment ago while the job may be adding a batch to a newer + * one; without this check the re-rank would put the older snapshot back and lose the batch. + */ + @Synchronized + fun writeIfUnchanged(derivedFrom: Long, next: RecommendationSnapshot): Boolean { + if (read()?.builtAt != derivedFrom) return false + write(next) + return true + } + + @Synchronized + fun clear() { + file.delete() + cached = null + } +} diff --git a/app/src/main/java/karasu/domain/recommendation/Recommended.kt b/app/src/main/java/karasu/domain/recommendation/Recommended.kt new file mode 100644 index 0000000000..a806fa0b28 --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/Recommended.kt @@ -0,0 +1,279 @@ +package karasu.domain.recommendation + +import kotlin.math.log10 +import kotlinx.serialization.Serializable + +/** + * One recommendation, with everything the shop window needs to draw, sort and explain it. + * + * @param tags what is known about it: the full genre list once details were fetched, otherwise + * only the tags it was found under. + * @param because the tags it was recommended for, strongest first. + * @param chapters how many chapters it has, null until known. + * @param firstUpload when its earliest chapter went up, null until known or when the source + * reports no dates. + * @param lastUpload when its latest chapter went up, same caveats. + * @param completed the source says the series is finished: it stopped because it ended. + * @param fromLibrary saved by the reader and never opened. Shown only on request. + * @param fetched whether details were read: a fetched entry is ranked on what it is, an + * unfetched one on what it was found under, so the two do not compare and the unfetched ones + * always go after. + * @param via the series this was suggested as being like, when it came from outside rather + * than from a tag search. Shown in place of the tags: "like X" says more than "Romance". + * @param votes how many tracking services named it. Two agreeing is worth more than one. + * @param popularity AniList's member count, null when unknown. A tie-breaker, never a reason. + * @param averageScore AniList's mean score 0..100, same caveat. + * @param explore recommended on tags the library barely backs: the slice kept for surprises. + * @param foundByTag it came up in a genre search of a source, not only from a tracker. + * @param fromCatalog found in AniList's own catalogue under the reader's tags, with a score the + * community gave it. The one signal here that says "good", not only "matches", so it weighs + * most: see [withBonuses]. + * @param oneShotChapters most of its chapters call themselves a one-shot. The tags and the + * title do not always say; the chapter list usually does. + * @param datesUntrusted the source reports no real dates (none, or the fetch time on every + * chapter), so nothing about when this series moves can be known from it. Ranked lower and + * hidden by default: on equal tags, the entry whose source tells the truth is the better + * recommendation. Its chapter count is not trusted as "new" either: such sources often list + * volumes, and eleven volumes is not a new series. + */ +@Serializable +data class Recommended( + val sourceId: Long, + val url: String, + val title: String, + val thumbnail: String?, + val tags: List, + val score: Float, + val because: List, + val chapters: Int? = null, + val firstUpload: Long? = null, + val lastUpload: Long? = null, + val completed: Boolean = false, + val fromLibrary: Boolean = false, + val fetched: Boolean = false, + val via: String? = null, + val votes: Int = 0, + val popularity: Int? = null, + val averageScore: Int? = null, + val explore: Boolean = false, + val foundByTag: Boolean = false, + val datesUntrusted: Boolean = false, + val oneShotChapters: Boolean = false, + val fromCatalog: Boolean = false, +) { + /** + * Everything that moves a freshly ranked score, in one place so a build and a re-rank agree: + * silence costs, a tracker naming what the tags also found pays, two trackers agreeing pays + * more, and what the wider world thinks breaks the ties the tags leave — a mild lift that + * orders candidates the tags could not tell apart and never moves one past a better match. + */ + fun withBonuses(now: Long): Recommended { + var s = score + if (datesUntrusted) s *= UNTRUSTED_DATES_PENALTY + if (isQuiet(now)) s *= QUIET_PENALTY + if (via != null && foundByTag) s *= BOTH_WAYS + if (votes > 1) s *= 1f + AGREEMENT * (votes - 1) + popularity?.let { p -> + val pop = (log10(p + 1f) / POPULARITY_LOG_CEILING).coerceIn(0f, 1f) + val avg = averageScore?.let { it / 100f } ?: pop + s *= 1f + QUALITY_LIFT * (pop + avg) / 2f + } + // The community's verdict is the strongest thing known about a series: a catalogue find + // rated 85 is lifted by nearly half, on top of what its tags earned. + if (fromCatalog) averageScore?.let { s *= 1f + CATALOG_LIFT * (it / 100f) } + return copy(score = s) + } + + /** + * A new release: it started recently, or it is short *and still moving*. Short alone is not + * enough — a two-chapter crossover from 2022 is short, and it is not news. + */ + fun isNew(now: Long): Boolean { + val startedRecently = firstUpload != null && firstUpload > 0 && now - firstUpload <= NEW_MAX_AGE_MS + val shortAndMoving = chapters != null && chapters <= NEW_MAX_CHAPTERS && !datesUntrusted && + lastUpload != null && lastUpload > 0 && now - lastUpload <= NEW_MAX_AGE_MS + return startedRecently || shortAndMoving + } + + /** + * Gone quiet for longer than a series its size gets: one chapter and nothing for a year is a + * drop or a source nobody updates; a hundred and fifty chapters and nothing for a year is a + * season break. The allowance grows with the chapter count, from [STALE_BASE_MONTHS] up to + * [STALE_MAX_MONTHS]. A finished series is never stale, and one whose source gives no dates + * cannot be judged and is let through. + */ + fun isStale(now: Long): Boolean = quietRatio(now) >= 1f + + /** + * A one-shot is not something to follow. Known by its tag, its title, or by being one + * finished chapter — the last is how a completed one slips past the staleness rule. + */ + val isOneShot: Boolean + get() = oneShotChapters || + tags.any { normalizeTag(it) == "One Shot" } || + ONE_SHOT.containsMatchIn(title) || + (chapters == 1 && completed) + + /** Past half its allowance the series is suspect: still shown, ranked lower. */ + fun isQuiet(now: Long): Boolean = quietRatio(now) >= QUIET_FROM + + /** How much of its silence allowance the series has used, 0 when it cannot be judged. */ + private fun quietRatio(now: Long): Float { + if (completed) return 0f + val last = lastUpload?.takeIf { it > 0 } ?: return 0f + val count = chapters ?: return 0f + val allowedMonths = (STALE_BASE_MONTHS + count / STALE_CHAPTERS_PER_MONTH).coerceAtMost(STALE_MAX_MONTHS) + return (now - last).toFloat() / (allowedMonths * MONTH_MS) + } + + companion object { + const val NEW_MAX_CHAPTERS = 30 + const val NEW_MAX_AGE_MS = 180L * 24 * 60 * 60 * 1000 + + private const val MONTH_MS = 30L * 24 * 60 * 60 * 1000 + + /** What a brand-new series gets before silence counts against it. */ + const val STALE_BASE_MONTHS = 3 + + /** One more month of allowance per this many chapters. */ + const val STALE_CHAPTERS_PER_MONTH = 10 + + /** The reader's ceiling: past two years nothing is a season break. */ + const val STALE_MAX_MONTHS = 24 + + /** From this share of the allowance on, silence costs rank. */ + const val QUIET_FROM = 0.5f + + /** What it costs. */ + const val QUIET_PENALTY = 0.6f + + /** What a source that cannot say when anything came out costs its entries. */ + const val UNTRUSTED_DATES_PENALTY = 0.7f + + val ONE_SHOT = Regex("(?i)\\bone[\\s-]?shot\\b") + + /** Score multiplier for a candidate the tags found and a tracker named. */ + private const val BOTH_WAYS = 1.25f + + /** Extra per additional tracker naming the same series. */ + private const val AGREEMENT = 0.15f + + /** log10 of a million members: what "everyone read it" looks like on AniList. */ + private const val POPULARITY_LOG_CEILING = 6f + + /** The most the quality lift can add. */ + private const val QUALITY_LIFT = 0.15f + + /** The most AniList's score can add to a catalogue find. */ + private const val CATALOG_LIFT = 0.5f + + /** One explore slot in every this many. */ + const val EXPLORE_EVERY = 5 + + /** A tag on this many liked entries or fewer is barely backed: recommending on it is exploring. */ + const val EXPLORE_SUPPORT = 3 + } +} + +/** + * A window that only exploits converges on the same five tags and stops surprising. One slot in + * [Recommended.EXPLORE_EVERY] goes to a candidate recommended on tags the library barely backs; + * the rest keep their rank. Explore candidates come in their own order, so the best of them lands + * first. + */ +fun List.withExploreSlots(): List { + val (explore, main) = partition { it.explore } + if (explore.isEmpty() || main.isEmpty()) return this + val out = ArrayList(size) + val mainIt = main.iterator() + val exploreIt = explore.iterator() + var i = 0 + while (mainIt.hasNext() || exploreIt.hasNext()) { + val takeExplore = (i + 1) % Recommended.EXPLORE_EVERY == 0 + out += when { + takeExplore && exploreIt.hasNext() -> exploreIt.next() + mainIt.hasNext() -> mainIt.next() + else -> exploreIt.next() + } + i++ + } + return out +} + +/** What the last build produced, and the profile it was ranked against. */ +/** + * What the last build produced, and the profile it was ranked against. + * + * @param items what has been read, ranked: the window. + * @param pending candidates found but not read yet, best first. A build is resumable: each run + * takes a batch off this queue, and "done" is when [items] reaches the reader's target or this + * runs dry. + */ +@Serializable +data class RecommendationSnapshot( + val builtAt: Long, + val profileTags: Map, + val items: List, + /** How big the library was: a much bigger one means the window is out of date. */ + val librarySize: Int = 0, + val pending: List = emptyList(), +) { + /** Cards the window can show: what was read, not counting the reader's own unread library. */ + val shown: Int get() = items.count { !it.fromLibrary } +} + +/** + * The same items ranked against a different profile — the reader moved a slider — without going + * back to the sources. Unfetched entries keep their place after the fetched ones. + */ +fun RecommendationSnapshot.rerank(profile: TasteProfile): RecommendationSnapshot { + if (profile.tags == profileTags) return this + val (fetched, rest) = items.partition { it.fetched || it.fromLibrary } + val now = System.currentTimeMillis() + val ranked = profile.rank(fetched) { it.tags } + .map { it.item.copy(score = it.score, because = it.because).withBonuses(now) } + .map { item -> item.copy(explore = item.because.isNotEmpty() && item.because.all { (profile.support[it] ?: 0) <= Recommended.EXPLORE_SUPPORT }) } + .sortedByDescending { it.score } + .withExploreSlots() + + profile.rank(rest) { it.tags }.map { it.item.copy(score = it.score, because = it.because) } + return copy(profileTags = profile.tags, items = ranked) +} + +/** + * Where a build is, for whoever is watching it: a notification, a snack, a settings row. + * + * @param done and [total] count the stage's own unit — sources searched, entries detailed, + * seeds asked about. Zero total means the stage has no countable unit. + */ +data class BuildProgress(val stage: Stage, val done: Int = 0, val total: Int = 0) { + enum class Stage { ENRICHING, MERGES, PROFILE, SEARCHING, TRACKERS, DETAILS, RANKING } +} + +/** + * Upload dates worth believing, or none. + * + * A source with no dates of its own hands back the moment it was asked, for every chapter: a + * hundred chapters "0 minutes ago". Read literally that is a brand-new, never-quiet series, and + * it climbs the window on a lie. Chapters of one series are not all released within a day of + * each other, so when three or more are, the dates are the fetch, not the release, and say + * nothing. + */ +fun honestUploadDates(dates: List): List { + val real = dates.filter { it > 0 } + if (real.size < 3) return real + return if (real.max() - real.min() < SAME_MOMENT_MS) emptyList() else real +} + +/** + * Whether the source is telling nothing about when this series moves: either it reports no + * dates at all (every chapter at zero — the app then shows the fetch time, "0 minutes ago"), + * or it stamps every chapter with the moment it was asked. Either way, with three or more + * chapters to judge by, the dates are not evidence. + */ +fun uploadDatesUntrusted(dates: List): Boolean { + if (dates.size < 3) return false + val real = dates.filter { it > 0 } + return real.isEmpty() || honestUploadDates(real).isEmpty() +} + +private const val SAME_MOMENT_MS = 24L * 60 * 60 * 1000 diff --git a/app/src/main/java/karasu/domain/recommendation/TrackerExtras.kt b/app/src/main/java/karasu/domain/recommendation/TrackerExtras.kt new file mode 100644 index 0000000000..c8719e4e0c --- /dev/null +++ b/app/src/main/java/karasu/domain/recommendation/TrackerExtras.kt @@ -0,0 +1,285 @@ +package karasu.domain.recommendation + +import co.touchlab.kermit.Logger +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.library.CustomMangaManager +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi +import eu.kanade.tachiyomi.domain.manga.models.Manga +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.jsonMime +import eu.kanade.tachiyomi.network.parseAs +import karasu.domain.manga.interactor.GetLibraryManga +import karasu.domain.library.custom.model.CustomMangaInfo +import karasu.domain.track.interactor.GetTrack +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import okhttp3.Headers +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * What the bound trackers know about a library entry that its source did not say: the genres + * and tags they file it under, and every other name it goes by. + * + * @param key which tracks this was built from (`syncId:mediaId`, sorted), so binding a new + * tracker gets it fetched again and nothing else does. + * @param status what the trackers say the series is doing, as an [SManga] status, or null when + * none said. A source rarely updates its own; the trackers are kept current by people. + * @param fetchedAt when this was read. Status changes, so it is read again after a month. + */ +@Serializable +data class TrackerExtra( + val key: String, + val tags: List, + val titles: List, + val status: Int? = null, + val fetchedAt: Long = 0, +) + +/** In a preference, keyed by manga id, so a backup carries it and a restore keeps the names. */ +class TrackerExtrasStore(private val preferences: PreferencesHelper) { + + @Volatile + private var cached: Map? = null + + fun all(): Map = cached ?: runCatching { + Json.decodeFromString>(preferences.recommendationTrackerExtras().get()) + }.getOrDefault(emptyMap()).also { cached = it } + + fun get(mangaId: Long?): TrackerExtra? = mangaId?.let { all()[it] } + + fun putAll(extras: Map) { + if (extras.isEmpty()) return + val next = all() + extras + preferences.recommendationTrackerExtras().set(Json.encodeToString(next)) + cached = next + } +} + +/** + * Fills [TrackerExtrasStore] for every tracked entry whose tracks changed since last time, and + * writes the tags into the entry's own genre list so they show on its page and teach the + * profile from then on. + * + * A pt-BR source often lists a series under the name the scanlation group gave it, which no + * tracker knows; the trackers' names are what lets migration and merged-source search find the + * same series elsewhere. AniList is asked in batches of fifty; the others one entry at a time, + * which is why this only runs at night and only for what changed. + */ +class EnrichFromTrackers( + private val getLibraryManga: GetLibraryManga, + private val getTrack: GetTrack, + private val network: NetworkHelper, + private val store: TrackerExtrasStore, + private val customMangaManager: CustomMangaManager, +) { + suspend fun await(now: Long = System.currentTimeMillis(), onProgress: suspend (done: Int, total: Int) -> Unit = { _, _ -> }) { + val library = getLibraryManga.await().distinctBy { it.manga.id }.map { it.manga } + val tracks = getTrack.awaitAll().groupBy { it.manga_id } + val pending = library.mapNotNull { manga -> + val its = tracks[manga.id].orEmpty().filter { it.sync_id in KNOWN } + if (its.isEmpty()) return@mapNotNull null + val key = its.map { "${it.sync_id}:${it.media_id}" }.sorted().joinToString(",") + val have = store.get(manga.id) + if (have?.key == key && have.fetchedAt > now - REFRESH_MS) null else Triple(manga, its, key) + } + if (pending.isEmpty()) return + onProgress(0, pending.size) + + // AniList answers fifty at once; ask it first, for everyone, then fill the gaps. + val anilist = pending.flatMap { (_, its, _) -> its.filter { it.sync_id == TrackManager.ANILIST }.map { it.media_id } } + .distinct() + .chunked(50) + .flatMap { ids -> runCatching { fromAniList(ids) }.onFailure { Logger.w(it) { "AniList batch failed" } }.getOrDefault(emptyMap()).entries } + .associate { it.key to it.value } + + val collected = mutableMapOf() + pending.forEachIndexed { index, (manga, its, key) -> + val found = its.mapNotNull { track -> + when (track.sync_id) { + TrackManager.ANILIST -> anilist[track.media_id] + TrackManager.MYANIMELIST -> withTimeoutOrNull(REQUEST_TIMEOUT_MS) { runCatching { fromMyAnimeList(track) }.getOrNull() } + TrackManager.KITSU -> withTimeoutOrNull(REQUEST_TIMEOUT_MS) { runCatching { fromKitsu(track) }.getOrNull() } + TrackManager.MANGA_UPDATES -> withTimeoutOrNull(REQUEST_TIMEOUT_MS) { runCatching { fromMangaUpdates(track) }.getOrNull() } + else -> null + } + } + onProgress(index + 1, pending.size) + // Nothing answered — the network, most likely. Not stored, so it is asked again. + if (found.isEmpty()) return@forEachIndexed + val extra = TrackerExtra( + key = key, + tags = found.flatMap { it.tags }.mapNotNull(::normalizeTag).distinctBy { it.lowercase() }, + titles = found.flatMap { it.titles }.map { it.trim() }.filter { it.isNotEmpty() }.distinctBy { it.lowercase() }, + // Finished beats hiatus beats releasing: the most final word any tracker gave. + status = found.mapNotNull { it.status }.maxByOrNull { STATUS_RANK.indexOf(it) }, + fetchedAt = now, + ) + collected[manga.id!!] = extra + if (extra.tags.isNotEmpty()) runCatching { addToGenres(manga, extra.tags) } + } + // One write: the preference is the whole map, and rewriting it per entry is quadratic. + store.putAll(collected) + } + + /** + * The tags become part of the entry's genre list, as a custom edit so a refresh from the + * source does not wipe them. An edit the reader already made is extended, never replaced. + */ + private suspend fun addToGenres(manga: Manga, tags: List) { + val current = customMangaManager.getManga(manga) + val genres = (current?.genre ?: manga.originalGenre) + ?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }.orEmpty() + val known = genres.mapNotNull(::normalizeTag).map { it.lowercase() }.toSet() + val missing = tags.filter { it.lowercase() !in known } + if (missing.isEmpty()) return + customMangaManager.saveMangaInfo( + CustomMangaInfo( + mangaId = manga.id!!, + title = current?.title?.takeIf { it.isNotBlank() }, + author = current?.author, + artist = current?.artist, + description = current?.description, + genre = (genres + missing).joinToString(", "), + status = current?.status?.takeIf { it != -1 }, + ), + ) + } + + private data class Found(val tags: List, val titles: List, val status: Int? = null) + + /** A tracker's status word as the app's status number, or null for anything it does not know. */ + private fun statusOf(word: String?): Int? = when (word?.lowercase()) { + "releasing", "currently_publishing", "current", "ongoing" -> SManga.ONGOING + "hiatus", "on_hiatus" -> SManga.ON_HIATUS + "finished", "complete", "completed" -> SManga.COMPLETED + "cancelled", "canceled", "discontinued" -> SManga.CANCELLED + else -> null + } + + private suspend fun fromAniList(ids: List): Map { + val payload = buildJsonObject { + put("query", ANILIST_QUERY) + putJsonObject("variables") { put("ids", kotlinx.serialization.json.JsonArray(ids.map { kotlinx.serialization.json.JsonPrimitive(it) })) } + } + val root = network.client.newCall(POST(ANILIST, body = payload.toString().toRequestBody(jsonMime))) + .awaitSuccess() + .parseAs() + return root["data"]?.jsonObject?.get("Page")?.jsonObject?.get("media")?.jsonArray.orEmpty() + .mapNotNull { media -> + val obj = media.jsonObject + val id = obj["id"]?.jsonPrimitive?.contentOrNull?.toLongOrNull() ?: return@mapNotNull null + val names = obj["title"]?.jsonObject + val titles = listOfNotNull("romaji", "english", "native").mapNotNull { names?.get(it)?.jsonPrimitive?.contentOrNull } + + obj["synonyms"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + val tags = obj["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + + obj["tags"]?.jsonArray.orEmpty().mapNotNull { tag -> + val t = tag.jsonObject + t["name"]?.jsonPrimitive?.contentOrNull?.takeIf { (t["rank"]?.jsonPrimitive?.intOrNull ?: 0) >= TAG_RANK } + } + id to Found(tags, titles, statusOf(obj["status"]?.jsonPrimitive?.contentOrNull)) + }.toMap() + } + + private suspend fun fromMyAnimeList(track: Track): Found { + val obj = network.client.newCall(GET("$MAL/manga/${track.media_id}?fields=genres,alternative_titles,title,status", malHeaders)) + .awaitSuccess() + .parseAs() + val alt = obj["alternative_titles"]?.jsonObject + val titles = listOfNotNull(obj["title"]?.jsonPrimitive?.contentOrNull, alt?.get("en")?.jsonPrimitive?.contentOrNull, alt?.get("ja")?.jsonPrimitive?.contentOrNull) + + alt?.get("synonyms")?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + val tags = obj["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull } + return Found(tags, titles, statusOf(obj["status"]?.jsonPrimitive?.contentOrNull)) + } + + private suspend fun fromKitsu(track: Track): Found { + val root = network.client.newCall(GET("$KITSU/manga/${track.media_id}?include=categories", kitsuHeaders)) + .awaitSuccess() + .parseAs() + val attributes = root["data"]?.jsonObject?.get("attributes")?.jsonObject + val titles = attributes?.get("titles")?.jsonObject?.values.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + + attributes?.get("abbreviatedTitles")?.jsonArray.orEmpty().mapNotNull { it.jsonPrimitive.contentOrNull } + + listOfNotNull(attributes?.get("canonicalTitle")?.jsonPrimitive?.contentOrNull) + val tags = root["included"]?.jsonArray.orEmpty() + .filter { it.jsonObject["type"]?.jsonPrimitive?.contentOrNull == "categories" } + .mapNotNull { it.jsonObject["attributes"]?.jsonObject?.get("title")?.jsonPrimitive?.contentOrNull } + return Found(tags, titles, statusOf(attributes?.get("status")?.jsonPrimitive?.contentOrNull)) + } + + private suspend fun fromMangaUpdates(track: Track): Found { + val obj = network.client.newCall(GET("$MU/v1/series/${track.media_id}")).awaitSuccess().parseAs() + val titles = listOfNotNull(obj["title"]?.jsonPrimitive?.contentOrNull) + + obj["associated"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject["title"]?.jsonPrimitive?.contentOrNull } + val tags = obj["genres"]?.jsonArray.orEmpty().mapNotNull { it.jsonObject["genre"]?.jsonPrimitive?.contentOrNull } + + obj["categories"]?.jsonArray.orEmpty() + .sortedByDescending { it.jsonObject["votes"]?.jsonPrimitive?.intOrNull ?: 0 } + .take(MU_CATEGORIES) + .mapNotNull { it.jsonObject["category"]?.jsonPrimitive?.contentOrNull } + // MangaUpdates' status is prose ("Ongoing", "Complete (Hiatus)"): the strongest word wins. + val prose = obj["status"]?.jsonPrimitive?.contentOrNull?.lowercase().orEmpty() + val status = when { + obj["completed"]?.jsonPrimitive?.contentOrNull == "true" || "complete" in prose -> SManga.COMPLETED + "hiatus" in prose -> SManga.ON_HIATUS + "cancel" in prose || "discontinued" in prose || "dropped" in prose -> SManga.CANCELLED + "ongoing" in prose -> SManga.ONGOING + else -> null + } + return Found(tags, titles, status) + } + + private val malHeaders = Headers.headersOf("X-MAL-CLIENT-ID", MyAnimeListApi.CLIENT_ID) + private val kitsuHeaders = Headers.headersOf("Accept", "application/vnd.api+json") + + companion object { + private const val ANILIST = "https://graphql.anilist.co/" + private const val MAL = "https://api.myanimelist.net/v2" + private const val KITSU = "https://kitsu.io/api/edge" + private const val MU = "https://api.mangaupdates.com" + + private val KNOWN = setOf(TrackManager.ANILIST, TrackManager.MYANIMELIST, TrackManager.KITSU, TrackManager.MANGA_UPDATES) + + /** AniList tags below this rank are noise. */ + private const val TAG_RANK = 60 + + /** MangaUpdates lists dozens of reader-voted categories; the top few are the real ones. */ + private const val MU_CATEGORIES = 8 + + private const val REQUEST_TIMEOUT_MS = 20_000L + + private val ANILIST_QUERY = """ + |query (${'$'}ids: [Int]) { + |Page(perPage: 50) { + |media(id_in: ${'$'}ids, type: MANGA) { + |id + |title { romaji english native } + |synonyms + |genres + |tags { name rank } + |status + |} + |} + |} + """.trimMargin() + + /** Least to most final. */ + private val STATUS_RANK = listOf(SManga.ONGOING, SManga.ON_HIATUS, SManga.CANCELLED, SManga.COMPLETED) + + /** How long a read stays fresh before status is asked about again. */ + private const val REFRESH_MS = 30L * 24 * 60 * 60 * 1000 + } +} diff --git a/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt b/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt index 7d765c5536..bea8740d30 100644 --- a/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt +++ b/app/src/main/java/karasu/presentation/category/CategoryRuleScreen.kt @@ -10,10 +10,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -39,7 +37,6 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -57,7 +54,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.pluralStringResource import dev.icerock.moko.resources.compose.stringResource @@ -66,6 +62,12 @@ import karasu.domain.category.models.RuleCondition import karasu.domain.category.models.RuleField import karasu.domain.category.models.RuleOperator import karasu.i18n.MR +import karasu.presentation.core.enterAlwaysAppBarScrollBehavior +import karasu.presentation.KarasuScaffold +import karasu.presentation.AppBarType +import eu.kanade.tachiyomi.util.compose.LocalBackPress +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.foundation.lazy.rememberLazyListState import kotlinx.coroutines.launch /** How much a switched-off rule fades, while staying legible and editable. */ @@ -95,42 +97,30 @@ fun CategoryRuleScreen( val scope = rememberCoroutineScope() val deletedMessage = stringResource(MR.strings.category_rule_deleted) val undoLabel = stringResource(MR.strings.undo) + val onBack = LocalBackPress.current ?: {} + val listState = rememberLazyListState() - Box(modifier = Modifier.fillMaxSize()) { + // The same small app bar every other pushed screen has: back arrow, the category as title, + // "Rules" underneath. Nothing invented to stand in for it. + KarasuScaffold( + onNavigationIconClicked = onBack, + title = categoryName, + appBarType = AppBarType.SMALL, + scrollBehavior = enterAlwaysAppBarScrollBehavior( + canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, + ), + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> LazyColumn( - // The screen has no app bar, so without this the category name draws under the status bar. + state = listState, modifier = Modifier - .fillMaxWidth() - .statusBarsPadding(), + .fillMaxSize() + .padding(innerPadding), contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { Column { - // A real header, not a bare line of text. Category names are often a single - // emoji or two words, which floating loose at the top of a screen reads as - // debris rather than as a title — the overline and the filled bar are what - // say "these are the rules of this category". - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - Text( - text = stringResource(MR.strings.category_rules), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = categoryName, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } Text( text = stringResource( if (transitions.isEmpty()) MR.strings.category_rules_empty @@ -210,15 +200,6 @@ fun CategoryRuleScreen( } } } - - SnackbarHost( - hostState = snackbarHostState, - // Same reason the list needs statusBarsPadding: no scaffold here, so the undo action - // would otherwise sit under the navigation bar and be unreachable. - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding(), - ) } } @@ -347,23 +328,32 @@ private fun RuleHeader( .padding(end = 4.dp) .semantics { contentDescription = toggleDescription }, ) - IconButton(onClick = { onMove(position - 1) }, enabled = position > 0) { - Icon( - Icons.Default.KeyboardArrowUp, - contentDescription = stringResource(MR.strings.category_rule_move_up), - ) - } - IconButton(onClick = { onMove(position + 1) }, enabled = position < count - 1) { - Icon( - Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(MR.strings.category_rule_move_down), - ) - } - IconButton(onClick = onDelete) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(MR.strings.category_rule_delete), - ) + // Reorder and delete are rare next to enable/disable; behind the overflow, as the rest of + // the app keeps its secondary actions, the header stops reading as a toolbar. + var menu by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = stringResource(MR.strings.more)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(MR.strings.category_rule_move_up)) }, + leadingIcon = { Icon(Icons.Default.KeyboardArrowUp, contentDescription = null) }, + enabled = position > 0, + onClick = { menu = false; onMove(position - 1) }, + ) + DropdownMenuItem( + text = { Text(stringResource(MR.strings.category_rule_move_down)) }, + leadingIcon = { Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) }, + enabled = position < count - 1, + onClick = { menu = false; onMove(position + 1) }, + ) + DropdownMenuItem( + text = { Text(stringResource(MR.strings.category_rule_delete)) }, + leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, + onClick = { menu = false; onDelete() }, + ) + } } } } diff --git a/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt b/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt index 3571f7aeb5..a9522f712b 100644 --- a/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt +++ b/app/src/main/java/karasu/presentation/library/ReleaseCalendarScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -36,6 +35,33 @@ import karasu.domain.manga.interval.ReleaseSchedule import karasu.domain.manga.interval.ScheduledRelease import karasu.domain.manga.models.cover import karasu.i18n.MR +import karasu.domain.manga.interval.ReleaseMonth +import java.time.YearMonth +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.material3.LocalContentColor +import androidx.compose.material.icons.outlined.ViewList +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import karasu.presentation.core.enterAlwaysAppBarScrollBehavior +import karasu.presentation.KarasuScaffold +import karasu.presentation.AppBarType +import eu.kanade.tachiyomi.util.compose.LocalBackPress +import androidx.compose.foundation.lazy.rememberLazyListState import karasu.presentation.manga.components.MangaCover import karasu.presentation.manga.components.MangaCoverRatio @@ -57,86 +83,145 @@ private val COVER_WIDTH = 96.dp fun ReleaseCalendarScreen( schedule: ReleaseSchedule?, calendar: ReleaseCalendar?, + month: ReleaseMonth?, + monthView: Boolean, + onMonthViewChange: (Boolean) -> Unit, + onlyCaughtUp: Boolean, + onOnlyCaughtUpChange: (Boolean) -> Unit, onMangaClick: (Manga) -> Unit, onSettingsClick: () -> Unit, contentPadding: PaddingValues = PaddingValues(0.dp), ) { - if (schedule == null || calendar == null) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - return - } + val onBack = LocalBackPress.current ?: {} + val listState = rememberLazyListState() - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding(), - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp, top = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(MR.strings.release_calendar), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), + // The same small app bar every other pushed screen has; the settings shortcut is its action. + KarasuScaffold( + onNavigationIconClicked = onBack, + title = stringResource(MR.strings.release_calendar), + appBarType = AppBarType.SMALL, + scrollBehavior = enterAlwaysAppBarScrollBehavior( + canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, + ), + actions = { + // Two toggles that change what is on screen, then the settings that change how it + // is computed. Toggles are filled when on, the way filter icons read elsewhere. + IconButton(onClick = { onOnlyCaughtUpChange(!onlyCaughtUp) }) { + Icon( + imageVector = if (onlyCaughtUp) Icons.Filled.CheckCircle else Icons.Outlined.CheckCircle, + contentDescription = stringResource(MR.strings.release_calendar_only_caught_up), + tint = if (onlyCaughtUp) MaterialTheme.colorScheme.primary else LocalContentColor.current, + ) + } + IconButton(onClick = { onMonthViewChange(!monthView) }) { + Icon( + imageVector = if (monthView) Icons.Outlined.ViewList else Icons.Outlined.CalendarMonth, + contentDescription = stringResource(if (monthView) MR.strings.release_calendar_list_view else MR.strings.release_calendar_month_view), + ) + } + IconButton(onClick = onSettingsClick) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(MR.strings.settings), ) - IconButton(onClick = onSettingsClick) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(MR.strings.settings), + } + }, + ) { innerPadding -> + if (schedule == null || calendar == null || month == null) { + Box(modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@KarasuScaffold + } + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .padding(innerPadding), + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // What landed today comes first: "did it come out?" is the question the day starts with. + if (schedule.arrived.isNotEmpty()) { + item { + SectionHeader(title = stringResource(MR.strings.release_calendar_arrived)) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(schedule.arrived, key = { "arrived-" + (it.id ?: it.url) }) { manga -> + MangaCard(manga = manga, hint = "✓", onClick = { onMangaClick(manga) }) + } + } + } + } + + if (monthView) { + item { + MonthGrid(month = month, onMangaClick = onMangaClick) + } + } else { + items(calendar.days, key = { it.date.toString() }) { day -> + DaySection( + title = day.date.label(), + releases = day.releases, + onMangaClick = onMangaClick, ) } } - } - items(calendar.days, key = { it.date.toString() }) { day -> - DaySection( - title = day.date.label(), - releases = day.releases, - onMangaClick = onMangaClick, - ) - } + if (calendar.later.isNotEmpty()) { + item { + DaySection( + title = stringResource(MR.strings.release_calendar_later), + releases = calendar.later, + onMangaClick = onMangaClick, + ) + } + } - if (calendar.later.isNotEmpty()) { - item { - DaySection( - title = stringResource(MR.strings.release_calendar_later), - releases = calendar.later, - onMangaClick = onMangaClick, - ) + if (schedule.onHiatus.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(MR.strings.release_calendar_hiatus), + subtitle = stringResource(MR.strings.release_calendar_hiatus_summary), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(schedule.onHiatus, key = { "hiatus-" + (it.id ?: it.url) }) { manga -> + MangaCard(manga = manga, hint = null, onClick = { onMangaClick(manga) }) + } + } + } } - } - if (schedule.stalled.isNotEmpty()) { - item { - DaySection( - title = stringResource(MR.strings.release_calendar_stalled), - subtitle = stringResource(MR.strings.release_calendar_stalled_summary), - releases = schedule.stalled, - onMangaClick = onMangaClick, - ) + if (schedule.stalled.isNotEmpty()) { + item { + DaySection( + title = stringResource(MR.strings.release_calendar_stalled), + subtitle = stringResource(MR.strings.release_calendar_stalled_summary), + releases = schedule.stalled, + onMangaClick = onMangaClick, + ) + } } - } - if (schedule.unknown.isNotEmpty()) { - item { - SectionHeader( - title = stringResource(MR.strings.release_calendar_unknown), - subtitle = stringResource(MR.strings.release_calendar_unknown_summary), - ) - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(schedule.unknown, key = { it.id ?: it.url }) { manga -> - MangaCard(manga = manga, hint = null, onClick = { onMangaClick(manga) }) + if (schedule.unknown.isNotEmpty()) { + item { + SectionHeader( + title = stringResource(MR.strings.release_calendar_unknown), + subtitle = stringResource(MR.strings.release_calendar_unknown_summary), + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(schedule.unknown, key = { it.id ?: it.url }) { manga -> + MangaCard(manga = manga, hint = null, onClick = { onMangaClick(manga) }) + } } } } @@ -169,7 +254,9 @@ private fun DaySection( items(releases, key = { it.manga.id ?: it.manga.url }) { release -> MangaCard( manga = release.manga, - hint = release.estimate.accuracyLabel(), + // A guess borrowed from the source's other series says so, instead of + // dressing up as a measurement with a "±" on it. + hint = if (release.guessed) stringResource(MR.strings.release_calendar_probably) else release.estimate.accuracyLabel(), onClick = { onMangaClick(release.manga) }, ) } @@ -257,3 +344,80 @@ private fun karasu.domain.manga.interval.ReleaseEstimate.accuracyLabel(): String else -> stringResource(MR.strings.release_calendar_give_or_take_days, days.toString()) } } + +/** + * The month as a grid: a cell per day, a dot per expected release, the day's covers below the + * grid for whichever day is tapped. Today is tapped by default, so the grid opens on something. + */ +@Composable +private fun MonthGrid(month: ReleaseMonth, onMangaClick: (Manga) -> Unit) { + val today = LocalDate.now() + var selected by remember(month.month) { mutableStateOf(if (today.yearMonth == month.month) today else month.month.atDay(1)) } + val byDate = month.days.associateBy { it.date } + // Cells before the first of the month, so weekdays line up with their column. + val firstColumn = (month.days.first().date.dayOfWeek.value % 7) + val cells: List = List(firstColumn) { null } + month.days.map { it.date } + val rows = cells.chunked(7) + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = month.month.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + .replaceFirstChar { it.titlecase(Locale.getDefault()) } + " " + month.month.year, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 4.dp), + ) + Row(modifier = Modifier.fillMaxWidth()) { + for (i in 0 until 7) { + Text( + text = java.time.DayOfWeek.of(if (i == 0) 7 else i).getDisplayName(TextStyle.NARROW, Locale.getDefault()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + rows.forEach { row -> + Row(modifier = Modifier.fillMaxWidth()) { + row.forEach { date -> DayCell(date, byDate[date]?.releases?.size ?: 0, date == selected, date == today) { date?.let { selected = it } } } + repeat(7 - row.size) { Spacer(modifier = Modifier.weight(1f)) } + } + } + val releases = byDate[selected]?.releases.orEmpty() + DaySection(title = selected.label(), releases = releases, onMangaClick = onMangaClick) + } +} + +@Composable +private fun RowScope.DayCell(date: LocalDate?, count: Int, selected: Boolean, isToday: Boolean, onClick: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .padding(2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + .then(if (date != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = 6.dp), + ) { + Text( + text = date?.dayOfMonth?.toString().orEmpty(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + // Up to three dots; past that the number says it. + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.height(8.dp)) { + if (count in 1..3) repeat(count) { Dot() } + if (count > 3) Text(text = count.toString(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun Dot() { + Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) +} + +private val LocalDate.yearMonth: YearMonth get() = YearMonth.from(this) diff --git a/app/src/main/java/karasu/presentation/recommendation/RecommendationTagsController.kt b/app/src/main/java/karasu/presentation/recommendation/RecommendationTagsController.kt new file mode 100644 index 0000000000..0a72044d42 --- /dev/null +++ b/app/src/main/java/karasu/presentation/recommendation/RecommendationTagsController.kt @@ -0,0 +1,94 @@ +package karasu.presentation.recommendation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController +import eu.kanade.tachiyomi.util.system.launchIO +import eu.kanade.tachiyomi.util.system.withIOContext +import eu.kanade.tachiyomi.util.system.withUIContext +import karasu.domain.recommendation.GetTasteProfile +import karasu.domain.recommendation.RecommendationFeedbackStore +import uy.kohesive.injekt.injectLazy + +/** + * The taste profile, open for editing. + * + * Every change is written as it is made: an override is one number, and a save button would only + * add a way to lose it. + */ +class RecommendationTagsController : BaseComposeController() { + + private val getTasteProfile: GetTasteProfile by injectLazy() + private val feedback: RecommendationFeedbackStore by injectLazy() + + private var learned by mutableStateOf?>(null) + private var support by mutableStateOf(emptyMap()) + private var overrides by mutableStateOf(emptyMap()) + private var verdicts by mutableIntStateOf(0) + private var banned by mutableIntStateOf(0) + private var merges by mutableIntStateOf(0) + + @Composable + override fun ScreenContent() { + LaunchedEffect(Unit) { + overrides = getTasteProfile.overrides() + verdicts = feedback.all().size + banned = getTasteProfile.banned().size + merges = getTasteProfile.merges().size + val profile = withIOContext { getTasteProfile.awaitLearned() } + support = profile.support + learned = profile.tags + } + RecommendationTagsScreen( + learned = learned, + support = support, + overrides = overrides, + onChange = { changed -> + overrides = changed + getTasteProfile.setOverrides(changed) + }, + verdicts = verdicts, + onForgetVerdicts = { + feedback.clear() + verdicts = 0 + }, + onBan = { tag -> + getTasteProfile.ban(tag) + banned += 1 + learned = learned?.filterKeys { !it.equals(tag, ignoreCase = true) } + }, + banned = banned, + onRestoreBanned = { + getTasteProfile.unbanAll() + banned = 0 + reload() + }, + merges = merges, + onMerge = { tags, into -> + getTasteProfile.merge(tags, into) + overrides = getTasteProfile.overrides() + merges = getTasteProfile.merges().size + reload() + }, + onUnmergeAll = { + getTasteProfile.unmergeAll() + merges = 0 + reload() + }, + ) + } + + private fun reload() { + viewScope.launchIO { + val profile = getTasteProfile.awaitLearned() + withUIContext { + support = profile.support + learned = profile.tags + } + } + } +} diff --git a/app/src/main/java/karasu/presentation/recommendation/RecommendationTagsScreen.kt b/app/src/main/java/karasu/presentation/recommendation/RecommendationTagsScreen.kt new file mode 100644 index 0000000000..618a2f21c7 --- /dev/null +++ b/app/src/main/java/karasu/presentation/recommendation/RecommendationTagsScreen.kt @@ -0,0 +1,353 @@ +package karasu.presentation.recommendation + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.pluralStringResource +import dev.icerock.moko.resources.compose.stringResource +import karasu.i18n.MR +import karasu.presentation.core.enterAlwaysAppBarScrollBehavior +import karasu.presentation.KarasuScaffold +import karasu.presentation.AppBarType +import eu.kanade.tachiyomi.util.compose.LocalBackPress +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState +import kotlin.math.roundToInt + +/** + * Every tag the profile knows, with its weight on a slider. + * + * @param learned what the library taught, null while still being read. + * @param overrides what the reader fixed by hand. A tag in here shows its fixed weight and a way + * back to the learned one; a tag in here but not in [learned] was added by the reader and the + * way back is to remove it. + */ +@Composable +fun RecommendationTagsScreen( + learned: Map?, + overrides: Map, + support: Map = emptyMap(), + onChange: (Map) -> Unit, + verdicts: Int = 0, + onForgetVerdicts: () -> Unit = {}, + onBan: (String) -> Unit = {}, + banned: Int = 0, + onRestoreBanned: () -> Unit = {}, + merges: Int = 0, + onMerge: (tags: Set, into: String) -> Unit = { _, _ -> }, + onUnmergeAll: () -> Unit = {}, + contentPadding: PaddingValues = PaddingValues(bottom = 32.dp), +) { + // Long-press picks tags to merge; while any are picked the app bar is the merge bar, the way + // a selection takes over the toolbar everywhere else in the app. + var selected by remember { mutableStateOf(emptySet()) } + var naming by remember { mutableStateOf(false) } + val onBack = LocalBackPress.current ?: {} + val listState = rememberLazyListState() + + KarasuScaffold( + onNavigationIconClicked = { if (selected.isNotEmpty()) selected = emptySet() else onBack() }, + navigationIcon = if (selected.isNotEmpty()) Icons.Filled.Close else Icons.AutoMirrored.Filled.ArrowBack, + title = if (selected.isNotEmpty()) { + pluralStringResource(MR.plurals.recommendation_tags_selected, selected.size, selected.size) + } else { + stringResource(MR.strings.recommendation_tags) + }, + appBarType = AppBarType.SMALL, + scrollBehavior = enterAlwaysAppBarScrollBehavior( + canScroll = { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 }, + ), + actions = { + if (selected.isNotEmpty()) { + TextButton(onClick = { naming = true }, enabled = selected.size >= 2) { + Text(stringResource(MR.strings.recommendation_tags_merge)) + } + } + }, + ) { innerPadding -> + if (learned == null) { + Box(modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@KarasuScaffold + } + TagList( + learned = learned, + support = support, + overrides = overrides, + onChange = onChange, + verdicts = verdicts, + onForgetVerdicts = onForgetVerdicts, + onBan = onBan, + banned = banned, + onRestoreBanned = onRestoreBanned, + merges = merges, + onUnmergeAll = onUnmergeAll, + selected = selected, + onSelect = { tag -> selected = if (tag in selected) selected - tag else selected + tag }, + listState = listState, + contentPadding = contentPadding, + modifier = Modifier.padding(innerPadding), + ) + } + + if (naming) { + MergeNameDialog( + initial = selected.firstOrNull().orEmpty(), + onConfirm = { name -> + onMerge(selected, name) + selected = emptySet() + naming = false + }, + onDismiss = { naming = false }, + ) + } +} + +@Composable +private fun TagList( + learned: Map, + support: Map, + overrides: Map, + onChange: (Map) -> Unit, + verdicts: Int, + onForgetVerdicts: () -> Unit, + onBan: (String) -> Unit, + banned: Int, + onRestoreBanned: () -> Unit, + merges: Int, + onUnmergeAll: () -> Unit, + selected: Set, + onSelect: (String) -> Unit, + listState: LazyListState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + // Overrides are keyed however the reader typed them; match them to the learned spelling. + fun overrideOf(tag: String) = overrides.entries.firstOrNull { it.key.equals(tag, ignoreCase = true) } + val rows = (learned.keys + overrides.keys.filter { key -> learned.keys.none { it.equals(key, true) } }) + .map { tag -> Triple(tag, learned[tag], overrideOf(tag)?.value) } + // A weight that rounds to nothing is a tag on one entry the reader barely opened. It + // would only be noise on this list; it is still there to be found by name. + .filter { (_, learnedWeight, override) -> override != null || (learnedWeight ?: 0f) >= MIN_SHOWN_WEIGHT } + .sortedByDescending { (_, learnedWeight, override) -> override ?: learnedWeight ?: 0f } + + // One field: typing narrows the list to matching tags; the + adds what was typed as a new one. + var newTag by remember { mutableStateOf("") } + val shown = newTag.trim().let { q -> if (q.isEmpty()) rows else rows.filter { (tag, _, _) -> tag.contains(q, ignoreCase = true) } } + fun add() { + val tag = newTag.trim() + if (tag.isNotEmpty()) onChange(overrides + (tag to 1f)) + newTag = "" + } + + LazyColumn( + state = listState, + modifier = modifier.fillMaxWidth(), + contentPadding = contentPadding, + ) { + item { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = stringResource( + if (selected.isNotEmpty()) MR.strings.recommendation_tags_merge_explainer + else MR.strings.recommendation_tags_explainer, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 12.dp), + ) { + OutlinedTextField( + value = newTag, + onValueChange = { newTag = it }, + label = { Text(stringResource(MR.strings.recommendation_tag_search_or_add)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = ::add, enabled = newTag.isNotBlank()) { + Icon(Icons.Filled.Add, contentDescription = stringResource(MR.strings.recommendation_tag_add)) + } + } + // A bad run of "not interested" must be undoable, or the profile is poisoned for good. + if (verdicts > 0) { + TextButton(onClick = onForgetVerdicts) { + Text(pluralStringResource(MR.plurals.recommendation_forget_verdicts, verdicts, verdicts)) + } + } + if (banned > 0) { + TextButton(onClick = onRestoreBanned) { + Text(pluralStringResource(MR.plurals.recommendation_restore_banned, banned, banned)) + } + } + if (merges > 0) { + TextButton(onClick = onUnmergeAll) { + Text(pluralStringResource(MR.plurals.recommendation_unmerge, merges, merges)) + } + } + } + } + items(shown, key = { (tag, _, _) -> tag.lowercase() }) { (tag, learnedWeight, override) -> + TagRow( + tag = tag, + learned = learnedWeight, + support = support[tag], + override = override, + selected = tag in selected, + selecting = selected.isNotEmpty(), + onSelect = { onSelect(tag) }, + onWeight = { weight -> onChange(overrides.without(tag) + (tag to weight)) }, + onReset = { onChange(overrides.without(tag)) }, + // Striking a learned tag says "this is not a genre"; it leaves the list for good. + // A tag the reader typed is simply un-typed. + onRemove = if (learnedWeight != null) ({ onChange(overrides.without(tag)); onBan(tag) }) else null, + ) + } + } +} + +private fun Map.without(tag: String) = filterKeys { !it.equals(tag, ignoreCase = true) } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TagRow( + tag: String, + learned: Float?, + support: Int?, + override: Float?, + onWeight: (Float) -> Unit, + onReset: () -> Unit, + onRemove: (() -> Unit)? = null, + selected: Boolean = false, + selecting: Boolean = false, + onSelect: () -> Unit = {}, +) { + val effective = override ?: learned ?: 0f + // Dragging is local; the number is only written when the finger lifts. + var value by remember(effective) { mutableFloatStateOf(effective) } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) + // A long press starts picking; once picking, a tap picks too. + .combinedClickable(onClick = { if (selecting) onSelect() }, onLongClick = onSelect) + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = tag, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (override != null) FontWeight.Bold else FontWeight.Normal, + ) + Text( + // "1%" on forty series and "1%" on one series are different things: the count + // is what says whether the weight is a pattern or a single entry. + text = when { + learned != null && support != null && support > 0 -> + stringResource(MR.strings.recommendation_tag_learned, learned.percent()) + " · " + + pluralStringResource(MR.plurals.recommendation_support, support, support) + learned != null -> stringResource(MR.strings.recommendation_tag_learned, learned.percent()) + else -> stringResource(MR.strings.recommendation_tag_added) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = value.percent(), + style = MaterialTheme.typography.labelLarge, + color = if (value < 0f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + if (override != null) { + IconButton(onClick = onReset) { + Icon( + imageVector = if (learned != null) Icons.Outlined.Refresh else Icons.Outlined.Delete, + contentDescription = stringResource(MR.strings.recommendation_tag_reset), + ) + } + } + if (onRemove != null) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Outlined.Block, + contentDescription = stringResource(MR.strings.recommendation_tag_remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Slider( + value = value, + onValueChange = { value = it }, + onValueChangeFinished = { onWeight(value) }, + valueRange = -1f..1f, + ) + } +} + +private fun Float.percent() = "${(this * 100).roundToInt()}%" + +@Composable +private fun MergeNameDialog(initial: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) { + var name by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(MR.strings.recommendation_tags_merge)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(MR.strings.recommendation_tags_merge_name)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text(stringResource(MR.strings.recommendation_tags_merge)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(MR.strings.cancel)) } }, + ) +} + +/** Below this a learned weight rounds to 0% on screen. */ +private const val MIN_SHOWN_WEIGHT = 0.005f diff --git a/app/src/main/res/menu/browse_source.xml b/app/src/main/res/menu/browse_source.xml index 25aa82791d..2f9532145e 100644 --- a/app/src/main/res/menu/browse_source.xml +++ b/app/src/main/res/menu/browse_source.xml @@ -28,6 +28,30 @@ android:title="@string/open_in_webview" app:showAsAction="ifRoom" /> + + + + + + + + + + + #BD93F9 + #282A36 + #44475A + #644AC9 + #FF79C6 + #282A36 + #8BE9FD + #1FFF79C6 + #44475A + #F8F8F2 + #BD93F9 + #282A36 + #F8F8F2 + #343746 + #F8F8F2 + @color/dracula_primary + diff --git a/app/src/main/res/values/color_dracula.xml b/app/src/main/res/values/color_dracula.xml new file mode 100644 index 0000000000..816f163ae7 --- /dev/null +++ b/app/src/main/res/values/color_dracula.xml @@ -0,0 +1,25 @@ + + + + #644AC9 + #FFFBEB + #CFCFDE + #BD93F9 + #A3144D + #FFFBEB + #036A96 + #1F644AC9 + #CFCFDE + #644AC9 + #644AC9 + #FFFBEB + #1F1F1F + #FFFBEB + #1F1F1F + @color/dracula_primary + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 725c71d7b9..015cc2f03a 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -228,6 +228,25 @@ @color/backgroundDoki @color/colorOnDownloadBadgeDayNight + +