Files

137 lines
5.6 KiB
Go
Raw Permalink Normal View History

package scanner
import (
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// mangaSeriesWhitespace collapses any run of whitespace to a single space so a
// series name keys identically regardless of incidental spacing.
var mangaSeriesWhitespace = regexp.MustCompile(`\s+`)
// mangaTrailingParen matches a single trailing parenthetical group, allowing
// optional whitespace before it. Applied repeatedly to strip all trailing
// groups (year, year-range, "Digital", release-group names, etc.).
var mangaTrailingParen = regexp.MustCompile(`\s*\([^)]*\)\s*$`)
// cleanMangaSeriesName removes all trailing parenthetical groups (scene-release
// metadata such as years, "Digital", and release-group tags) from a manga
// folder name, then trims any dangling whitespace or trailing " -".
//
// Parentheticals in the middle of the name are left untouched so titles like
// "JoJo's Bizarre Adventure - Part 8 - JoJolion (something) extra" are
// preserved. The function is pure and idempotent. If stripping would produce
// an empty string the original trimmed input is returned unchanged so a series
// name is never empty.
func cleanMangaSeriesName(name string) string {
s := strings.TrimSpace(name)
for {
stripped := mangaTrailingParen.ReplaceAllString(s, "")
if stripped == s {
break
}
s = stripped
}
// Trim any trailing dash (with optional surrounding spaces) left after
// stripping, e.g. "Series Name - (Digital)" → "Series Name -" → "Series Name".
s = strings.TrimRight(s, " -")
s = strings.TrimSpace(s)
if s == "" {
return strings.TrimSpace(name)
}
return s
}
// mangaSeriesGroupKey is the stable, library-scoped content-group key that all
// chapters of one series resolve their series item by. It lowercases, trims,
// and collapses internal whitespace so cosmetic variations of the same folder
// name yield the same key. Returns "" for an empty name (caller must skip).
func mangaSeriesGroupKey(folderID int, name string) string {
normalized := strings.ToLower(strings.TrimSpace(name))
normalized = strings.TrimSpace(mangaSeriesWhitespace.ReplaceAllString(normalized, " "))
if normalized == "" {
return ""
}
return fmt.Sprintf("manga:series:%d:%s", folderID, normalized)
}
// mangaVolumeFolder matches directory names that are volume markers, not series.
var mangaVolumeFolder = regexp.MustCompile(`(?i)^v(?:ol(?:ume)?\.?)?\s*\d+$`)
var (
mangaVolYearIssue = regexp.MustCompile(`(?i)\b(Vol\.?\s*\d{4})\b.*?#\s*(\d+(?:\.\d+)?)`)
mangaVolYearLabel = regexp.MustCompile(`(?i)\bvol\.?\s*\d{4}\b`) // strip a year-style "Vol.YYYY" so it never reads as an index
// mangaVolume / mangaChapterC only match the abbreviated forms (v13, vol.4, c128, ch.5).
// Full English words ("volume 3", "chapter 5") intentionally fall through to the bare-number path.
mangaVolume = regexp.MustCompile(`(?i)\bv(?:ol\.?)?\s*(\d+(?:\.\d+)?)\b`)
mangaChapterC = regexp.MustCompile(`(?i)\bc(?:h\.?)?\s*(\d+(?:\.\d+)?)\b`)
mangaBareNumber = regexp.MustCompile(`\b(\d+(?:\.\d+)?)\b`)
mangaParenNoise = regexp.MustCompile(`\([^)]*\)`) // (year) (Digital) (group) (Month, Year)
)
// mangaSeriesFromPath returns the series name: the nearest ancestor directory of
// the file whose name is not a volume marker.
func mangaSeriesFromPath(filePath string) string {
dir := filepath.Dir(filePath)
for dir != "" && dir != "." && dir != string(filepath.Separator) {
base := filepath.Base(dir)
if !mangaVolumeFolder.MatchString(strings.TrimSpace(base)) {
return cleanMangaSeriesName(base)
}
dir = filepath.Dir(dir)
}
return ""
}
// mangaIndexForFile parses the volume/chapter index from a manga file's base
// name (extension already stripped), first removing the series-name prefix so
// numbers inside the series title (e.g. "404 Demons", "365 Days") are not
// mistaken for the chapter/volume number. Falls back to the full base name when
// the file does not start with the series name.
func mangaIndexForFile(base, seriesName string) (volume string, index float64, has bool) {
trimmedBase := strings.TrimSpace(base)
trimmedSeries := strings.TrimSpace(seriesName)
if trimmedSeries != "" && strings.HasPrefix(strings.ToLower(trimmedBase), strings.ToLower(trimmedSeries)) {
remainder := trimmedBase[len(trimmedSeries):]
return parseMangaIndex(remainder)
}
return parseMangaIndex(trimmedBase)
}
// parseMangaIndex extracts the ordering index (volume or chapter number) and the
// raw volume token from a manga release filename (extension already stripped).
// Returns has=false when no number is present (e.g. a one-shot).
//
// The returned volume is a display token (e.g. "v13" or "Vol.2003") and is not
// normalized across forms; callers should treat it as label text, not a key.
func parseMangaIndex(name string) (volume string, index float64, has bool) {
if m := mangaVolYearIssue.FindStringSubmatch(name); m != nil {
if n, err := strconv.ParseFloat(m[2], 64); err == nil {
return "v" + strings.TrimSpace(m[2]), n, true
}
}
clean := strings.TrimSpace(mangaParenNoise.ReplaceAllString(name, " "))
// A bare "Vol.YYYY" (no "#issue") is a year, not an index — strip it so it
// never leaks into the volume/chapter/bare-number scans below.
clean = mangaVolYearLabel.ReplaceAllString(clean, " ")
if m := mangaVolume.FindStringSubmatch(clean); m != nil {
if n, err := strconv.ParseFloat(m[1], 64); err == nil {
return "v" + m[1], n, true
}
}
if m := mangaChapterC.FindStringSubmatch(clean); m != nil {
if n, err := strconv.ParseFloat(m[1], 64); err == nil {
return "", n, true
}
}
if m := mangaBareNumber.FindStringSubmatch(clean); m != nil {
if n, err := strconv.ParseFloat(m[1], 64); err == nil {
return "", n, true
}
}
return "", 0, false
}