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.
This commit is contained in:
AntsyLich
2026-10-10 17:37:44 +06:00
parent 6c1b0702ba
commit e42e01049d
3 changed files with 577 additions and 208 deletions
@@ -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)
@@ -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<Chapter>): List<ChapterNumbering> {
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<IntRange>): 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<String>, chapters: List<Chapter>): List<List<IntRange>> {
val matches = names.map { anyNumber.findAll(it).toList() }
val sharedNumbers = MutableList(names.size) { emptyList<IntRange>() }
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<String>,
chapterNumbers: Array<RecognizedNumber?>,
volumes: Array<Double?>,
) {
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<RecognizedNumber?>.nullRuns(): List<IntRange> {
val runs = mutableListOf<IntRange>()
var start = 0
while (start < size) {
if (this[start] != null) {
start++
continue
}
val end = (start..<size).firstOrNull { this[it] != null } ?: size
runs.add(start..<end)
start = end
}
return runs
}
/**
* Check for decimal in received strings
* @param decimal decimal value of regex
* @param alpha alpha value of regex
* @return decimal/alpha float value
* Whether the chapters of [run] are in no volume or in [volume], the one of the chapters their numbers come from.
*/
private fun checkForDecimal(decimal: String?, alpha: String?): Double {
if (!decimal.isNullOrEmpty()) {
return decimal.toDouble()
}
if (!alpha.isNullOrEmpty()) {
if (alpha.contains("extra")) {
return 0.99
}
if (alpha.contains("omake")) {
return 0.98
}
if (alpha.contains("special")) {
return 0.97
}
val trimmedAlpha = alpha.trimStart('.')
if (trimmedAlpha.length == 1) {
return parseAlphaPostFix(trimmedAlpha[0])
}
}
return 0.0
private fun Array<Double?>.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<RecognizedNumber?>.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' })
}
}
}
@@ -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<String, ChapterNumbering>) = 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<String, ChapterNumbering>) {
val chapters = expected.map { (name, _) -> Chapter.create().copy(name = name, chapterNumber = -1.0) }
ChapterRecognition.parseChapterNumbers(mangaTitle, chapters) shouldBe expected.map { it.second }
}
}