137 lines
5.6 KiB
Go
137 lines
5.6 KiB
Go
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
|
||
|
|
}
|