From e42e01049d6ba9dbc12c762e90d972a1b4f0813e Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:55:04 +0600 Subject: [PATCH] Recognize volumes and use neighbouring chapters for chapter numbers Sync now recognizes the chapters of a manga together, which tells what a name alone can't: - Numbers every similar name shares, like the group in "Group 2 Chapter 386", aren't the chapter number. - Chapters without a number get one from the chapters around them, as long as none of them is in another volume, since numbers may start over in the next one. Between two chapters they get the missing whole numbers when those fit (2, ?, 4 -> 3), or .5, .6 and so on (10, ?, ?, 11 -> 10.5, 10.6). Bonus chapters after the newest chapter go above it. A prologue gets 0, and volume 0 when the chapter after it has a volume. Recognition returns a ChapterNumbering of the volume and chapter number as strings, null when there is none. Sub-chapters keep their letter (19a) and parts become one (Chapter 1.5 Part I -> 1.5a); sync turns the chapter number into a double the way letters always counted. Volumes, seasons included, are recognized from names and are no longer taken as the chapter number. Sync only uses the chapter number for now. --- .../interactor/SyncChaptersWithSource.kt | 29 +- .../chapter/service/ChapterRecognition.kt | 356 ++++++++++++---- .../chapter/service/ChapterRecognitionTest.kt | 400 ++++++++++++------ 3 files changed, 577 insertions(+), 208 deletions(-) diff --git a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt index 56d53a211..d0215f38e 100644 --- a/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/chapter/interactor/SyncChaptersWithSource.kt @@ -84,30 +84,35 @@ class SyncChaptersWithSource( // to a higher value than newer chapters var maxSeenUploadDate: Instant? = null - for (sourceChapter in sourceChapters) { - var chapter = sourceChapter - - // Update metadata from source if necessary. - if (source is HttpSource) { + // Update metadata from source if necessary. + val preparedChapters = if (source is HttpSource) { + val sManga = manga.toSManga() + sourceChapters.map { chapter -> val sChapter = chapter.toSChapter() @Suppress("DEPRECATION") - source.prepareNewChapter(sChapter, manga.toSManga()) - chapter = chapter.copyFromSChapter(sChapter) + source.prepareNewChapter(sChapter, sManga) + chapter.copyFromSChapter(sChapter) + } + } else { + sourceChapters + } + + // Recognize chapter numbers, from the whole list since names alone don't always tell. + val recognizedChapters = preparedChapters + .zip(ChapterRecognition.parseChapterNumbers(manga.title, preparedChapters)) { chapter, numbering -> + chapter.copy(chapterNumber = numbering.chapter?.let(ChapterRecognition::toDouble) ?: -1.0) } + for (chapter in recognizedChapters) { if (!sourceUrls.add(chapter.url)) continue - // Recognize chapter number for the chapter. - val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapterNumber) - chapter = chapter.copy(chapterNumber = chapterNumber) - val dbChapter = dbChaptersByUrl[chapter.url] if (dbChapter == null) { val toAddChapter = if (chapter.dateUpload == null) { chapter.copy(dateUpload = maxSeenUploadDate ?: nowInstant) } else { - maxSeenUploadDate = listOfNotNull(maxSeenUploadDate, sourceChapter.dateUpload).maxOrNull() + maxSeenUploadDate = listOfNotNull(maxSeenUploadDate, chapter.dateUpload).maxOrNull() chapter } newChapters.add(toAddChapter) diff --git a/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt b/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt index 3190e0456..5d294f0dc 100644 --- a/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt +++ b/domain/src/main/java/tachiyomi/domain/chapter/service/ChapterRecognition.kt @@ -1,15 +1,29 @@ package tachiyomi.domain.chapter.service +import tachiyomi.domain.chapter.model.Chapter +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import kotlin.math.floor +import kotlin.math.round + /** + * Recognizes the volume and chapter number of chapters from their names. + * + * Each name is first read on its own: the volume or season is taken out, a part becomes the letter of a sub-chapter, + * and the chapter number is the number after "ch." or else the first one left. Reading the whole chapter list then + * fills in what single names don't tell, see [parseChapterNumbers]. + * * -R> = regex conversion. */ object ChapterRecognition { + /** + * A number with optional decimals and a trailing word or letter, e.g. 12, 12.5, 12a, 12.extra + */ private const val NUMBER_PATTERN = """([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""" /** - * All cases with Ch.xx - * Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4 + * Example: Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4 */ private val basic = Regex("""(?<=ch\.) *$NUMBER_PATTERN""") @@ -19,113 +33,301 @@ object ChapterRecognition { private val number = Regex(NUMBER_PATTERN) /** - * Regex used to remove unwanted tags - * Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12 + * Example: Onepunch-Man Punch Ver002 028 -R> Onepunch-Man Punch 028 */ - private val unwanted = Regex("""\b(?:v|ver|vol|version|volume|season|s)[^a-z]?[0-9]+""") + private val unwanted = Regex("""\b(?:ver|version)[^a-z]?[0-9]+""") /** - * Regex used to remove unwanted whitespace - * Example One Piece 12 special -R> One Piece 12special + * Example: One Piece 12 special -R> One Piece 12special */ private val unwantedWhiteSpace = Regex("""\s(?=extra|special|omake)""") + /** + * A volume or season, the short forms only right before the number since "s" also ends words. + * Example: Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 1, The Gamer S3 - Chapter 20 -R> 3 + */ + private val volume = Regex("""\b(?:(?:vol(?:ume)?|season)[^a-z0-9]? *|[vs]\.?)([0-9]+(?:\.[0-9]+)?)\b""") + + /** + * Example: Chapter 1.5 Part II -R> b + */ + private val part = Regex("""\bpart\.? *([0-9]+|i{1,3}|iv|vi{0,3}|ix|x)\b""") + + private val romanNumerals = listOf("i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x") + + /** + * Example: Group 2 Chapter 386 -R> Group # Chapter # + */ + private val anyNumber = Regex("""[0-9]+(?:\.[0-9]+)?""") + + /** + * A number after this is the chapter number, even when every similar name has the same one. + * Example: Oshi no Ko Chapter 5 Part 1 -R> 5a + */ + private val chapterPrefix = Regex("""(?:\b(?:ch|chap|chapter|ep|episode)\.? *|#)$""") + + /** + * Example: Bonus Chapter: Beach Episode + */ + private val bonus = Regex("""\b(?:extra|bonus|omake|special|side ?story|afterword|interlude)""") + + /** + * Example: Prologue: The Beginning -R> 0 + */ + private val prologue = Regex("""\b(?:prologue|one.?shot|pilot)\b""") + + /** + * Formats numbers without trailing zeros and with a dot whatever the locale, e.g. 10.5, 3, 24.005 + */ + private val formatter = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' }) + + /** + * The volume and chapter number recognized for a chapter, e.g. "10.5" or "19a", `null` when there is none. + */ + data class ChapterNumbering(val volume: String?, val chapter: String?) + + /** + * A recognized number, with the letter of a sub-chapter like the a in 19a. Kept apart from the value so that + * chapters can still be compared and numbered after each other. + */ + private data class RecognizedNumber(val value: Double, val letter: Char? = null) { + override fun toString(): String = formatter.format(value) + (letter?.toString() ?: "") + } + + /** + * Recognizes the chapter number of a single chapter, keeping [chapterNumber] when the source gave one. + */ fun parseChapterNumber( mangaTitle: String, chapterName: String, chapterNumber: Double? = null, ): Double { - // If chapter number is known return. - if (chapterNumber != null && (chapterNumber == -2.0 || chapterNumber > -1.0)) { - return chapterNumber + if (chapterNumber != null && chapterNumber.isKnown()) return chapterNumber + return parseName(cleanName(mangaTitle, chapterName))?.let { toDouble(it.toString()) } ?: chapterNumber ?: -1.0 + } + + /** + * Recognizes the numbers of [chapters], given from the most to the least recent, using the whole list for what a + * name alone doesn't tell: numbers every similar name shares aren't the chapter number, and chapters without a + * number are numbered after the chapters around them. Numbers the source gave are kept. + */ + fun parseChapterNumbers(mangaTitle: String, chapters: List): List { + val names = chapters.map { cleanName(mangaTitle, it.name) } + // Taken out before reading the names, so a number like the 2 in "Group 2 Chapter 386" can't be picked + val sharedNumbers = findSharedNumbers(names, chapters) + val chapterNumbers = Array(chapters.size) { index -> + chapters[index].chapterNumber.takeIf { it.isKnown() }?.let(::RecognizedNumber) + ?: parseName(names[index].removeRanges(sharedNumbers[index])) } + // Only from the name: newer chapters may not be in a volume yet, so neighbours don't tell + val volumes = Array(chapters.size) { volume.find(names[it])?.groupValues?.get(1)?.toDouble() } - // Get chapter title with lower case - val cleanChapterName = chapterName.lowercase() - // Remove manga title from chapter title. - .replace(mangaTitle.lowercase(), "").trim() - // Remove comma's or hyphens. - .replace(',', '.') - .replace('-', '.') - // Remove unwanted white spaces. - .replace(unwantedWhiteSpace, "") + fillChapterNumbers(names, chapterNumbers, volumes) + return chapters.indices.map { + ChapterNumbering(volumes[it]?.let(formatter::format), chapterNumbers[it]?.toString()) + } + } - val numberMatch = number.findAll(cleanChapterName) + /** + * Returns [number], a number of a [ChapterNumbering], as a double. A letter only counts without decimals, from .1 + * for a up to .9 for i, e.g. 19a -R> 19.1 and 1.5a -R> 1.5. + */ + fun toDouble(number: String): Double { + val letter = number.last().takeIf { it.isLetter() } + val value = number.removeSuffix(letter?.toString() ?: "").toDouble() + val tenths = letter?.let { it - 'a' + 1 } + return if (tenths != null && tenths < 10 && '.' !in number) value + tenths / 10.0 else value + } - when { - numberMatch.none() -> { - return chapterNumber ?: -1.0 + /** + * Whether the source gave a number: -1 and below mean it didn't, except -2 which some sources use on purpose. + */ + private fun Double.isKnown(): Boolean = this == -2.0 || this > -1.0 + + private fun cleanName(mangaTitle: String, chapterName: String): String { + return chapterName.lowercase().replace(mangaTitle.lowercase(), "") + } + + /** + * Replaces [ranges] with spaces, from the last so the earlier ones stay where they are. + */ + private fun String.removeRanges(ranges: List): String { + return ranges.sortedByDescending { it.first }.fold(this) { name, range -> name.replaceRange(range, " ") } + } + + /** + * Finds the numbers that are the same in every name of a group of at least three names that only differ in their + * numbers, e.g. the 2 in "Group 2 Chapter 385". Chapters with a number from the source are left out. + */ + private fun findSharedNumbers(names: List, chapters: List): List> { + val matches = names.map { anyNumber.findAll(it).toList() } + val sharedNumbers = MutableList(names.size) { emptyList() } + names.indices + .filterNot { chapters[it].chapterNumber.isKnown() } + // Grouped by all but their numbers, so that the nth number of each name means the same thing + .groupBy { names[it].replace(anyNumber, "#") } + .values + // Fewer names could share a number by chance + .filter { it.size >= 3 } + .forEach { group -> + val first = matches[group.first()] + val (sharedSlots, otherSlots) = first.indices.partition { slot -> + group.all { matches[it][slot].value == first[slot].value } + } + // Something has to be left to tell the chapters apart + if (otherSlots.isEmpty()) return@forEach + // Parts of a chapter share its number, e.g. the 5 of "Chapter 5 Part 1" and "Chapter 5 Part 2" + val removableSlots = sharedSlots.filterNot { slot -> + chapterPrefix.containsMatchIn(names[group.first()].substring(0, first[slot].range.first)) + } + group.forEach { index -> sharedNumbers[index] = removableSlots.map { matches[index][it].range } } } - numberMatch.count() > 1 -> { - // Remove unwanted tags. - unwanted.replace(cleanChapterName, "").let { name -> - // Check base case ch.xx - basic.find(name)?.let { return getChapterNumberFromMatch(it) } + return sharedNumbers + } - // need to find again first number might already removed - number.find(name)?.let { return getChapterNumberFromMatch(it) } + /** + * Numbers runs of chapters without a number after the chapters around them, only within a volume since chapter + * numbers may start over in the next one: + * - between two chapters, the missing whole numbers when they fit, e.g. 2, ?, 4 -R> 3, or else 10, ?, 11 -R> 10.5 + * - after the newest chapter, only bonus chapters, e.g. 100, Afterword -R> 100.5 + * - before the oldest chapter, only a prologue, which gets 0, and volume 0 when the chapter after it has a volume + */ + private fun fillChapterNumbers( + names: List, + chapterNumbers: Array, + volumes: Array, + ) { + chapterNumbers.nullRuns().forEach { run -> + // A -2 from the source says nothing about where a chapter is, so it can't be numbered after + val newer = chapterNumbers.getOrNull(run.first - 1)?.takeIf { it.value >= 0 } + val older = chapterNumbers.getOrNull(run.last + 1)?.takeIf { it.value >= 0 } + val olderVolume = volumes.getOrNull(run.last + 1) + val bonuses = run.count { bonus.containsMatchIn(names[it]) } + when { + newer != null && older != null -> { + val isSameVolume = volumes[run.first - 1] == olderVolume && volumes.isWithinVolume(run, olderVolume) + // Out of order chapters tell nothing about the ones between them + if (newer.value <= older.value || !isSameVolume) return@forEach + val isWhole = listOf(newer, older).all { it.letter == null && it.value % 1.0 == 0.0 } + // Bonus chapters aren't missing chapters even when they would fit, e.g. 2, Bonus, 4 -R> 2.5 + if (bonuses == 0 && isWhole && newer.value - older.value - 1 == run.count().toDouble()) { + run.forEach { chapterNumbers[it] = RecognizedNumber(older.value + (run.last + 1 - it)) } + } else { + // Never past the next whole number, which may still be a chapter to come + chapterNumbers.fillBetween(run, older.value, minOf(newer.value, floor(older.value) + 1)) + } + } + older != null -> { + // Newer chapters without a number may as well be announcements + if (bonuses < run.count() || !volumes.isWithinVolume(run, olderVolume)) return@forEach + chapterNumbers.fillBetween(run, older.value, floor(older.value) + 1) + } + // When the first chapter is already 0, that's the prologue + newer != null && newer.value > 0 -> { + run.filter { prologue.containsMatchIn(names[it]) }.forEach { + chapterNumbers[it] = RecognizedNumber(0.0) + if (volumes[it] == null && volumes[run.first - 1] != null) volumes[it] = 0.0 + } } } } - - // return the first number encountered - return getChapterNumberFromMatch(numberMatch.first()) } /** - * Check if chapter number is found and return it - * @param match result of regex - * @return chapter number if found else null + * Returns the ranges of consecutive chapters without a number. */ - private fun getChapterNumberFromMatch(match: MatchResult): Double { - return match.let { - val initial = it.groups[1]?.value?.toDouble()!! - val subChapterDecimal = it.groups[2]?.value - val subChapterAlpha = it.groups[3]?.value - val addition = checkForDecimal(subChapterDecimal, subChapterAlpha) - initial.plus(addition) + private fun Array.nullRuns(): List { + val runs = mutableListOf() + var start = 0 + while (start < size) { + if (this[start] != null) { + start++ + continue + } + val end = (start...isWithinVolume(run: IntRange, volume: Double?): Boolean { + return run.all { this[it] == null || this[it] == volume } } /** - * x.a -> x.1, x.b -> x.2, etc + * Numbers [run] between [lower] and [upper], the least recent getting the lowest: .5, .6 and so on after the whole + * number, e.g. 10, ?, ?, 11 -R> 10.5, 10.6, or evenly spread when those don't fit. */ - private fun parseAlphaPostFix(alpha: Char): Double { - val number = alpha.code - ('a'.code - 1) - if (number >= 10) return 0.0 - return number / 10.0 + private fun Array.fillBetween(run: IntRange, lower: Double, upper: Double) { + val tenths = (5..9).map { floor(lower) + it / 10.0 }.filter { it > lower && it < upper } + if (tenths.size >= run.count()) { + // Rounded since tenths can't be exact doubles, e.g. 10 + 0.6 + run.reversed().forEachIndexed { index, chapter -> + this[chapter] = RecognizedNumber(round(tenths[index] * 10) / 10) + } + return + } + // Tenths run out after e.g. a 2.9 or past five chapters, and the chapters still have to fit below upper + val step = (upper - lower) / (run.count() + 1) + run.forEach { this[it] = RecognizedNumber(round((lower + step * (run.last + 1 - it)) * 1000) / 1000) } + } + + /** + * Returns the chapter number in [name], a chapter name with the manga title removed, or `null` if it has none. A + * part after the chapter number becomes its letter, e.g. Chapter 1.5 Part I -R> 1.5a. + */ + private fun parseName(name: String): RecognizedNumber? { + // The volume can't also be the chapter number + val withoutVolume = volume.replace(name, " ") + val partMatch = part.find(withoutVolume) + val partLetter = partMatch?.groupValues?.get(1)?.let(::parsePart)?.takeIf { it in 1..26 }?.let { 'a' + it - 1 } + if (partMatch != null && partLetter != null) { + parseNumber(withoutVolume.removeRange(partMatch.range))?.let { number -> + // A letter written in the number is what the source numbers the chapter by + return if (number.letter == null) number.copy(letter = partLetter) else number + } + } + // Without another number, e.g. "Part 3", the part is the chapter number + return parseNumber(withoutVolume) + } + + private fun parsePart(part: String): Int = part.toIntOrNull() ?: (romanNumerals.indexOf(part) + 1) + + /** + * Returns the number after "ch." or else the first one, leaving out version tags when there are several. + */ + private fun parseNumber(name: String): RecognizedNumber? { + val cleanName = name + .trim() + // Some sources write decimals and letters with these, e.g. 300,a and 122-a + .replace(',', '.') + .replace('-', '.') + .replace(unwantedWhiteSpace, "") + + val numbers = number.findAll(cleanName).toList() + if (numbers.size > 1) { + val withoutTags = unwanted.replace(cleanName, "") + (basic.find(withoutTags) ?: number.find(withoutTags))?.let { return it.toRecognizedNumber() } + } + return numbers.firstOrNull()?.toRecognizedNumber() + } + + /** + * Example: 12.5 -R> 12.5, 12.extra -R> 12.99, 12.a -R> 12a + */ + private fun MatchResult.toRecognizedNumber(): RecognizedNumber { + val (whole, decimal, alpha) = destructured + val value = whole.toDouble() + (decimal.toDoubleOrNull() ?: 0.0) + return when { + decimal.isEmpty() && "extra" in alpha -> RecognizedNumber(value + 0.99) + decimal.isEmpty() && "omake" in alpha -> RecognizedNumber(value + 0.98) + decimal.isEmpty() && "special" in alpha -> RecognizedNumber(value + 0.97) + // Only up to i, like the decimals they used to stand for, since others are rather words like 5v2 + else -> RecognizedNumber(value, alpha.trimStart('.').singleOrNull()?.takeIf { it in 'a'..'i' }) + } } } diff --git a/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt b/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt index 8369bfa5c..b7830ba15 100644 --- a/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt +++ b/domain/src/test/java/tachiyomi/domain/chapter/service/ChapterRecognitionTest.kt @@ -4,160 +4,149 @@ import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode +import tachiyomi.domain.chapter.model.Chapter +import tachiyomi.domain.chapter.service.ChapterRecognition.ChapterNumbering @Execution(ExecutionMode.CONCURRENT) class ChapterRecognitionTest { @Test fun `Basic Ch prefix`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4: Misrepresentation", 4.0) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4: Misrepresentation" to ChapterNumbering("1", "4"), + ) } @Test fun `Basic Ch prefix with space after period`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol. 1 Ch. 4: Misrepresentation", 4.0) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol. 1 Ch. 4: Misrepresentation" to ChapterNumbering("1", "4"), + ) } @Test fun `Basic Ch prefix with decimal`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.1: Misrepresentation", 4.1) - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.4: Misrepresentation", 4.4) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.1: Misrepresentation" to ChapterNumbering("1", "4.1"), + ) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.4: Misrepresentation" to ChapterNumbering("1", "4.4"), + ) } @Test fun `Basic Ch prefix with alpha postfix`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.a: Misrepresentation", 4.1) - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.b: Misrepresentation", 4.2) - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch.4.extra: Misrepresentation", 4.99) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.a: Misrepresentation" to ChapterNumbering("1", "4a"), + ) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.b: Misrepresentation" to ChapterNumbering("1", "4b"), + ) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch.4.extra: Misrepresentation" to ChapterNumbering("1", "4.99"), + ) } @Test fun `Name containing one number`() { - val mangaTitle = "Bleach" - - assertChapter(mangaTitle, "Bleach 567 Down With Snowwhite", 567.0) + assertNumbers("Bleach", "Bleach 567 Down With Snowwhite" to ChapterNumbering(null, "567")) } @Test fun `Name containing one number and decimal`() { - val mangaTitle = "Bleach" - - assertChapter(mangaTitle, "Bleach 567.1 Down With Snowwhite", 567.1) - assertChapter(mangaTitle, "Bleach 567.4 Down With Snowwhite", 567.4) + assertNumbers("Bleach", "Bleach 567.1 Down With Snowwhite" to ChapterNumbering(null, "567.1")) + assertNumbers("Bleach", "Bleach 567.4 Down With Snowwhite" to ChapterNumbering(null, "567.4")) } @Test fun `Name containing one number and alpha`() { - val mangaTitle = "Bleach" - - assertChapter(mangaTitle, "Bleach 567.a Down With Snowwhite", 567.1) - assertChapter(mangaTitle, "Bleach 567.b Down With Snowwhite", 567.2) - assertChapter(mangaTitle, "Bleach 567.extra Down With Snowwhite", 567.99) + assertNumbers("Bleach", "Bleach 567.a Down With Snowwhite" to ChapterNumbering(null, "567a")) + assertNumbers("Bleach", "Bleach 567.b Down With Snowwhite" to ChapterNumbering(null, "567b")) + assertNumbers("Bleach", "Bleach 567.extra Down With Snowwhite" to ChapterNumbering(null, "567.99")) } @Test fun `Chapter containing manga title and number`() { - val mangaTitle = "Solanin" - - assertChapter(mangaTitle, "Solanin 028 Vol. 2", 28.0) + assertNumbers("Solanin", "Solanin 028 Vol. 2" to ChapterNumbering("2", "28")) } @Test fun `Chapter containing manga title and number decimal`() { - val mangaTitle = "Solanin" - - assertChapter(mangaTitle, "Solanin 028.1 Vol. 2", 28.1) - assertChapter(mangaTitle, "Solanin 028.4 Vol. 2", 28.4) + assertNumbers("Solanin", "Solanin 028.1 Vol. 2" to ChapterNumbering("2", "28.1")) + assertNumbers("Solanin", "Solanin 028.4 Vol. 2" to ChapterNumbering("2", "28.4")) } @Test fun `Chapter containing manga title and number alpha`() { - val mangaTitle = "Solanin" - - assertChapter(mangaTitle, "Solanin 028.a Vol. 2", 28.1) - assertChapter(mangaTitle, "Solanin 028.b Vol. 2", 28.2) - assertChapter(mangaTitle, "Solanin 028.extra Vol. 2", 28.99) + assertNumbers("Solanin", "Solanin 028.a Vol. 2" to ChapterNumbering("2", "28a")) + assertNumbers("Solanin", "Solanin 028.b Vol. 2" to ChapterNumbering("2", "28b")) + assertNumbers("Solanin", "Solanin 028.extra Vol. 2" to ChapterNumbering("2", "28.99")) } @Test fun `Extreme case`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028", 28.0) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028" to ChapterNumbering(null, "28")) } @Test fun `Extreme case with decimal`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.1", 28.1) - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.4", 28.4) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.1" to ChapterNumbering(null, "28.1")) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.4" to ChapterNumbering(null, "28.4")) } @Test fun `Extreme case with alpha`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.a", 28.1) - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.b", 28.2) - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 028.extra", 28.99) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.a" to ChapterNumbering(null, "28a")) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.b" to ChapterNumbering(null, "28b")) + assertNumbers("Onepunch-Man", "Onepunch-Man Punch Ver002 028.extra" to ChapterNumbering(null, "28.99")) } @Test fun `Chapter containing dot v2`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Vol.1 Ch.5v.2: Alones", 5.0) + assertNumbers("random", "Vol.1 Ch.5v.2: Alones" to ChapterNumbering("1", "5")) } @Test fun `Number in manga title`() { - val mangaTitle = "Ayame 14" - - assertChapter(mangaTitle, "Ayame 14 1 - The summer of 14", 1.0) + assertNumbers("Ayame 14", "Ayame 14 1 - The summer of 14" to ChapterNumbering(null, "1")) } @Test fun `Space between ch x`() { - val mangaTitle = "Mokushiroku Alice" - - assertChapter(mangaTitle, "Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation", 4.0) + assertNumbers( + "Mokushiroku Alice", + "Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation" to ChapterNumbering("1", "4"), + ) } @Test fun `Chapter title with ch substring`() { - val mangaTitle = "Ayame 14" - - assertChapter(mangaTitle, "Vol.1 Ch.1: March 25 (First Day Cohabiting)", 1.0) + assertNumbers("Ayame 14", "Vol.1 Ch.1: March 25 (First Day Cohabiting)" to ChapterNumbering("1", "1")) } @Test fun `Chapter containing multiple zeros`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Vol.001 Ch.003: Kaguya Doesn't Know Much", 3.0) + assertNumbers("random", "Vol.001 Ch.003: Kaguya Doesn't Know Much" to ChapterNumbering("1", "3")) } @Test fun `Chapter with version before number`() { - val mangaTitle = "Onepunch-Man" - - assertChapter(mangaTitle, "Onepunch-Man Punch Ver002 086 : Creeping Darkness [3]", 86.0) + assertNumbers( + "Onepunch-Man", + "Onepunch-Man Punch Ver002 086 : Creeping Darkness [3]" to ChapterNumbering(null, "86"), + ) } @Test fun `Version attached to chapter number`() { - val mangaTitle = "Ansatsu Kyoushitsu" - - assertChapter(mangaTitle, "Ansatsu Kyoushitsu 011v002: Assembly Time", 11.0) + assertNumbers("Ansatsu Kyoushitsu", "Ansatsu Kyoushitsu 011v002: Assembly Time" to ChapterNumbering(null, "11")) } /** @@ -166,9 +155,7 @@ class ChapterRecognitionTest { */ @Test fun `Number after manga title with chapter in chapter title case`() { - val mangaTitle = "Tokyo ESP" - - assertChapter(mangaTitle, "Tokyo ESP 027: Part 002: Chapter 001", 027.0) + assertNumbers("Tokyo ESP", "Tokyo ESP 027: Part 002: Chapter 001" to ChapterNumbering(null, "27b")) } /** @@ -177,101 +164,276 @@ class ChapterRecognitionTest { */ @Test fun `Number after unwanted tag`() { - val mangaTitle = "One-punch Man" - - assertChapter(mangaTitle, "Mag Version 195.5", 195.5) + assertNumbers("One-punch Man", "Mag Version 195.5" to ChapterNumbering(null, "195.5")) } @Test fun `Unparseable chapter`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Foo", -1.0) + assertNumbers("random", "Foo" to ChapterNumbering(null, null)) } @Test fun `Chapter with time in title`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Fairy Tail 404: 00:00", 404.0) + assertNumbers("random", "Fairy Tail 404: 00:00" to ChapterNumbering(null, "404")) } @Test fun `Chapter with alpha without dot`() { - val mangaTitle = "random" - - assertChapter(mangaTitle, "Asu No Yoichi 19a", 19.1) + assertNumbers("random", "Asu No Yoichi 19a" to ChapterNumbering(null, "19a")) } @Test fun `Chapter title containing extra and vol`() { - val mangaTitle = "Fairy Tail" - - assertChapter(mangaTitle, "Fairy Tail 404.extravol002", 404.99) - assertChapter(mangaTitle, "Fairy Tail 404 extravol002", 404.99) + assertNumbers("Fairy Tail", "Fairy Tail 404.extravol002" to ChapterNumbering(null, "404.99")) + assertNumbers("Fairy Tail", "Fairy Tail 404 extravol002" to ChapterNumbering(null, "404.99")) } @Test fun `Chapter title containing omake (japanese extra) and vol`() { - val mangaTitle = "Fairy Tail" - - assertChapter(mangaTitle, "Fairy Tail 404.omakevol002", 404.98) - assertChapter(mangaTitle, "Fairy Tail 404 omakevol002", 404.98) + assertNumbers("Fairy Tail", "Fairy Tail 404.omakevol002" to ChapterNumbering(null, "404.98")) + assertNumbers("Fairy Tail", "Fairy Tail 404 omakevol002" to ChapterNumbering(null, "404.98")) } @Test fun `Chapter title containing special and vol`() { - val mangaTitle = "Fairy Tail" - - assertChapter(mangaTitle, "Fairy Tail 404.specialvol002", 404.97) - assertChapter(mangaTitle, "Fairy Tail 404 specialvol002", 404.97) + assertNumbers("Fairy Tail", "Fairy Tail 404.specialvol002" to ChapterNumbering(null, "404.97")) + assertNumbers("Fairy Tail", "Fairy Tail 404 specialvol002" to ChapterNumbering(null, "404.97")) } @Test fun `Chapter title containing commas`() { - val mangaTitle = "One Piece" - - assertChapter(mangaTitle, "One Piece 300,a", 300.1) - assertChapter(mangaTitle, "One Piece Ch,123,extra", 123.99) - assertChapter(mangaTitle, "One Piece the sunny, goes swimming 024,005", 24.005) + assertNumbers("One Piece", "One Piece 300,a" to ChapterNumbering(null, "300a")) + assertNumbers("One Piece", "One Piece Ch,123,extra" to ChapterNumbering(null, "123.99")) + assertNumbers("One Piece", "One Piece the sunny, goes swimming 024,005" to ChapterNumbering(null, "24.005")) } @Test fun `Chapter title containing hyphens`() { - val mangaTitle = "Solo Leveling" - - assertChapter(mangaTitle, "ch 122-a", 122.1) - assertChapter(mangaTitle, "Solo Leveling Ch.123-extra", 123.99) - assertChapter(mangaTitle, "Solo Leveling, 024-005", 24.005) - assertChapter(mangaTitle, "Ch.191-200 Read Online", 191.200) + assertNumbers("Solo Leveling", "ch 122-a" to ChapterNumbering(null, "122a")) + assertNumbers("Solo Leveling", "Solo Leveling Ch.123-extra" to ChapterNumbering(null, "123.99")) + assertNumbers("Solo Leveling", "Solo Leveling, 024-005" to ChapterNumbering(null, "24.005")) + assertNumbers("Solo Leveling", "Ch.191-200 Read Online" to ChapterNumbering(null, "191.2")) } @Test fun `Chapters containing season`() { - assertChapter("D.I.C.E", "D.I.C.E[Season 001] Ep. 007", 7.0) + assertNumbers("D.I.C.E", "D.I.C.E[Season 001] Ep. 007" to ChapterNumbering("1", "7")) } @Test fun `Chapters in format sx - chapter xx`() { - assertChapter("The Gamer", "S3 - Chapter 20", 20.0) + assertNumbers("The Gamer", "S3 - Chapter 20" to ChapterNumbering("3", "20")) } @Test fun `Chapters ending with s`() { - assertChapter("One Outs", "One Outs 001", 1.0) + assertNumbers("One Outs", "One Outs 001" to ChapterNumbering(null, "1")) } @Test fun `Chapters containing ordinals`() { val mangaTitle = "The Sister of the Woods with a Thousand Young" - assertChapter(mangaTitle, "The 1st Night", 1.0) - assertChapter(mangaTitle, "The 2nd Night", 2.0) - assertChapter(mangaTitle, "The 3rd Night", 3.0) - assertChapter(mangaTitle, "The 4th Night", 4.0) + assertNumbers(mangaTitle, "The 1st Night" to ChapterNumbering(null, "1")) + assertNumbers(mangaTitle, "The 2nd Night" to ChapterNumbering(null, "2")) + assertNumbers(mangaTitle, "The 3rd Night" to ChapterNumbering(null, "3")) + assertNumbers(mangaTitle, "The 4th Night" to ChapterNumbering(null, "4")) } - private fun assertChapter(mangaTitle: String, name: String, expected: Double) { - ChapterRecognition.parseChapterNumber(mangaTitle, name) shouldBe expected + @Test + fun `Parts are letters`() { + assertNumbers("Chapter 1.5 Part I" to ChapterNumbering(null, "1.5a")) + assertNumbers("Chapter 1.5 Part II" to ChapterNumbering(null, "1.5b")) + assertNumbers("Chapter 12 Part 3" to ChapterNumbering(null, "12c")) + assertNumbers("Vol.2 Chapter 7 - Part IV" to ChapterNumbering("2", "7d")) + assertNumbers("Part 3" to ChapterNumbering(null, "3")) + } + + @Test + fun `Numbers are turned into doubles`() { + ChapterRecognition.toDouble("12") shouldBe 12.0 + ChapterRecognition.toDouble("10.5") shouldBe 10.5 + ChapterRecognition.toDouble("19a") shouldBe 19.1 + ChapterRecognition.toDouble("19i") shouldBe 19.9 + ChapterRecognition.toDouble("1.5a") shouldBe 1.5 + ChapterRecognition.toDouble("-2") shouldBe -2.0 + } + + @Test + fun `Volume is not taken as the chapter number`() { + assertNumbers("Mokushiroku Alice", "Mokushiroku Alice Vol.3" to ChapterNumbering("3", null)) + assertNumbers("Mokushiroku Alice", "Vol.3 12" to ChapterNumbering("3", "12")) + } + + @Test + fun `Seasons are volumes`() { + assertNumbers("Vol-3 Chapter 2" to ChapterNumbering("3", "2")) + assertNumbers("Mob's 100" to ChapterNumbering(null, "100")) + } + + @Test + fun `Volumes are only taken from the name`() { + assertNumbers( + "Vol.2 Ch.5" to ChapterNumbering("2", "5"), + "Ch.4" to ChapterNumbering(null, "4"), + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + ) + } + + @Test + fun `Numbers every similar name shares are ignored`() { + assertNumbers( + "Group 2 Chapter 387" to ChapterNumbering(null, "387"), + "Group 2 Chapter 386" to ChapterNumbering(null, "386"), + "Group 2 Chapter 385" to ChapterNumbering(null, "385"), + ) + } + + @Test + fun `Shared numbers after a chapter prefix are kept`() { + assertNumbers( + "Chapter 5 Part 3" to ChapterNumbering(null, "5c"), + "Chapter 5 Part 2" to ChapterNumbering(null, "5b"), + "Chapter 5 Part 1" to ChapterNumbering(null, "5a"), + ) + } + + @Test + fun `Chapters without a number fill the missing whole numbers`() { + assertNumbers( + "Chapter 4" to ChapterNumbering(null, "4"), + "The Return" to ChapterNumbering(null, "3"), + "Chapter 2" to ChapterNumbering(null, "2"), + ) + assertNumbers( + "Chapter 5" to ChapterNumbering(null, "5"), + "Rain" to ChapterNumbering(null, "4"), + "Snow" to ChapterNumbering(null, "3"), + "Chapter 2" to ChapterNumbering(null, "2"), + ) + } + + @Test + fun `Chapters without a number go between the chapters around them`() { + assertNumbers( + "Chapter 11" to ChapterNumbering(null, "11"), + "Bonus: Beach Episode" to ChapterNumbering(null, "10.5"), + "Chapter 10" to ChapterNumbering(null, "10"), + ) + assertNumbers( + "Chapter 13" to ChapterNumbering(null, "13"), + "Intermission" to ChapterNumbering(null, "10.5"), + "Chapter 10" to ChapterNumbering(null, "10"), + ) + assertNumbers( + "Chapter 11" to ChapterNumbering(null, "11"), + "Rain" to ChapterNumbering(null, "10.6"), + "Snow" to ChapterNumbering(null, "10.5"), + "Chapter 10" to ChapterNumbering(null, "10"), + ) + assertNumbers( + "Chapter 4" to ChapterNumbering(null, "4"), + "Side Story" to ChapterNumbering(null, "2.5"), + "Chapter 2" to ChapterNumbering(null, "2"), + ) + assertNumbers( + "Chapter 3" to ChapterNumbering(null, "3"), + "Extra" to ChapterNumbering(null, "2.6"), + "Chapter 2.5" to ChapterNumbering(null, "2.5"), + ) + assertNumbers( + "Chapter 3" to ChapterNumbering(null, "3"), + "Extra" to ChapterNumbering(null, "2.95"), + "Chapter 2.9" to ChapterNumbering(null, "2.9"), + ) + } + + @Test + fun `Chapters without a number at the ends`() { + assertNumbers( + "Afterword" to ChapterNumbering(null, "100.5"), + "Chapter 100" to ChapterNumbering(null, "100"), + ) + assertNumbers( + "Hiatus Announcement" to ChapterNumbering(null, null), + "Chapter 100" to ChapterNumbering(null, "100"), + ) + assertNumbers( + "Chapter 1" to ChapterNumbering(null, "1"), + "Prologue" to ChapterNumbering(null, "0"), + ) + assertNumbers( + "Chapter 1" to ChapterNumbering(null, "1"), + "Character Profiles" to ChapterNumbering(null, null), + ) + } + + @Test + fun `A prologue before chapters in volumes is in volume 0`() { + assertNumbers( + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + "Prologue" to ChapterNumbering("0", "0"), + ) + assertNumbers( + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + "Vol.2 Prologue" to ChapterNumbering("2", "0"), + ) + } + + @Test + fun `Chapters without a number are only numbered within a volume`() { + assertNumbers( + "Vol.1 Ch.3" to ChapterNumbering("1", "3"), + "Rain" to ChapterNumbering(null, "2"), + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + ) + assertNumbers( + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + "Rain" to ChapterNumbering(null, null), + "Vol.1 Ch.1" to ChapterNumbering("1", "1"), + ) + assertNumbers( + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + "Vol.2 Rain" to ChapterNumbering("2", null), + "Ch.1" to ChapterNumbering(null, "1"), + ) + assertNumbers( + "Vol.2 Ch.3" to ChapterNumbering("2", "3"), + "Vol.1 Rain" to ChapterNumbering("1", null), + "Vol.2 Ch.1" to ChapterNumbering("2", "1"), + ) + assertNumbers( + "Extra" to ChapterNumbering(null, "10.5"), + "Vol.2 Ch.10" to ChapterNumbering("2", "10"), + ) + assertNumbers( + "Vol.2 Extra" to ChapterNumbering("2", "10.5"), + "Vol.2 Ch.10" to ChapterNumbering("2", "10"), + ) + assertNumbers( + "Vol.3 Extra" to ChapterNumbering("3", null), + "Vol.2 Ch.10" to ChapterNumbering("2", "10"), + ) + } + + @Test + fun `Numbers from the source are kept`() { + val chapters = listOf( + Chapter.create().copy(name = "Vol.1 Chapter 3", chapterNumber = 7.0), + Chapter.create().copy(name = "Chapter 2", chapterNumber = -1.0), + ) + + ChapterRecognition.parseChapterNumbers("", chapters) shouldBe + listOf(ChapterNumbering("1", "7"), ChapterNumbering(null, "2")) + } + + private fun assertNumbers(vararg expected: Pair) = assertNumbers("", *expected) + + /** + * Asserts the numbers recognized for chapters with the given names, listed from the most to the least recent. + */ + private fun assertNumbers(mangaTitle: String, vararg expected: Pair) { + val chapters = expected.map { (name, _) -> Chapter.create().copy(name = name, chapterNumber = -1.0) } + ChapterRecognition.parseChapterNumbers(mangaTitle, chapters) shouldBe expected.map { it.second } } }