mangayomi-mirror/lib/utils/chapter_recognition.dart
NBA2K1 5938401015 Recognize "S<n>" as a season prefix
The season keyword regex only matched full words (season, staffel, saison,
temporada) but not the common abbreviation "S". This caused "S1 - Episode 28"
and "S3 - Episode 28" to both parse as episode 28 with no season context.

Added "s" as the last alternative in _seasonKeyword so that "S1E28",
"S1 - Episode 28" etc. correctly extract the season and produce distinct
sort keys (season * 100000 + episode). Placement last in the alternation
ensures full words like "season" and "staffel" are still matched in
preference to the bare abbreviation.

Note: _unwanted already included "s" for stripping purposes, so this
change closes that inconsistency in the extraction side.
2026-05-29 20:37:43 +02:00

103 lines
3.4 KiB
Dart

class ChapterRecognition {
static final _unwanted = RegExp(
r"\b(?:v|ver|vol|version|volume|season|staffel|saison|temporada|s)[^a-z]?[0-9]+",
);
static final _unwantedWhiteSpace = RegExp(r"\s(?=extra|special|omake)");
static final _seasonKeyword = RegExp(
r"\b(?:staffel|season|saison|temporada|s)\s*([0-9]+)",
);
static final _episodeKeyword = RegExp(
r"\b(?:folge|episode|ep\.?)\s*([0-9]+(?:\.[0-9]+)?)",
);
// lookbehind for "ch." then zero or more spaces.
static final _chNotation = RegExp(
r"(?<=ch\.) *([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?",
);
static final _bareNumber = RegExp(r"([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?");
/// Sort key for the UI list. Encodes season into the key so multi-season
/// anime sort correctly: key = season * 100000 + episode.
int parseChapterNumber(String mangaTitle, String chapterName) =>
_parse(mangaTitle, chapterName, applySeason: true);
/// Episode number within a season, for tracker updates (MAL/AniList/Kitsu)
/// and AniSkip results. The tracker entry is already season-specific,
/// so season is stripped.
int parseEpisodeNumber(String mangaTitle, String chapterName) =>
_parse(mangaTitle, chapterName, applySeason: false);
int _parse(
String mangaTitle,
String chapterName, {
required bool applySeason,
}) {
// Normalize the chapter name by removing title, punctuation noise, etc.
final name = chapterName
.toLowerCase()
.replaceAll(mangaTitle.toLowerCase(), '')
.trim()
.replaceAll(',', '.')
.replaceAll('-', '.')
.replaceAll(_unwantedWhiteSpace, '');
final season = applySeason
? int.tryParse(_seasonKeyword.firstMatch(name)?.group(1) ?? '') ?? 0
: 0;
final epMatch = _episodeKeyword.firstMatch(name);
if (epMatch != null) {
final ep = double.parse(epMatch.group(1)!).toInt();
return _withSeason(season, ep);
}
// strip season/volume noise, then look for ch. or bare number.
final stripped = name.replaceAll(_unwanted, '');
final ep = _extractNumber(stripped);
return ep != null ? _withSeason(season, ep) : 0;
}
// Combines season + episode into a sortable integer.
int _withSeason(int season, int ep) => season > 0 ? season * 100000 + ep : ep;
int? _extractNumber(String name) {
final chMatch = _chNotation.firstMatch(name);
if (chMatch != null) return _fromMatch(chMatch).toInt();
final numMatch = _bareNumber.firstMatch(name);
if (numMatch != null) return _fromMatch(numMatch).toInt();
return null;
}
double _fromMatch(Match match) {
final base = double.parse(match.group(1)!);
return base + _decimalAddition(match.group(2), match.group(3));
}
double _decimalAddition(String? decimal, String? alpha) {
if (decimal != null && decimal.isNotEmpty) return double.parse(decimal);
if (alpha != null && alpha.isNotEmpty) {
if (alpha.contains("extra")) {
return 0.99;
}
if (alpha.contains("omake")) {
return 0.98;
}
if (alpha.contains("special")) {
return 0.97;
}
final trimmedAlpha = alpha.replaceFirst('.', '');
if (trimmedAlpha.length == 1) {
return _parseAlphaPostFix(trimmedAlpha[0]);
}
}
return 0.0;
}
double _parseAlphaPostFix(String alpha) {
final number = alpha.codeUnitAt(0) - ('a'.codeUnitAt(0) - 1);
if (number >= 10) return 0.0;
return number / 10.0;
}
}