Files
seanime-docker/seanime-2.9.10/internal/manga/providers/local.go
2025-09-20 14:08:38 +01:00

557 lines
14 KiB
Go

package manga_providers
import (
"archive/zip"
"bytes"
"fmt"
// "image/jpeg"
"io"
"os"
"path/filepath"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/util/comparison"
"slices"
"strconv"
"strings"
"sync"
// "github.com/gen2brain/go-fitz"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
const (
LocalServePath = "{{manga-local-assets}}"
)
type Local struct {
dir string // Directory to scan for manga
logger *zerolog.Logger
mu sync.Mutex
currentChapterPath string
currentZipCloser io.Closer
currentPages map[string]*loadedPage
}
type loadedPage struct {
buf []byte
page *hibikemanga.ChapterPage
}
// chapterEntry represents a potential chapter file or directory found during scanning
type chapterEntry struct {
RelativePath string // Path relative to manga root (e.g., "mangaID/chapter1.cbz" or "mangaID/vol1/ch1.cbz")
IsDir bool // Whether this entry is a directory
}
func NewLocal(dir string, logger *zerolog.Logger) hibikemanga.Provider {
_ = os.MkdirAll(dir, 0755)
return &Local{
dir: dir,
logger: logger,
currentPages: make(map[string]*loadedPage),
}
}
func (p *Local) GetSettings() hibikemanga.Settings {
return hibikemanga.Settings{
SupportsMultiScanlator: false,
SupportsMultiLanguage: false,
}
}
func (p *Local) SetSourceDirectory(dir string) {
if dir != "" {
p.dir = dir
}
}
func (p *Local) getAllManga() (res []*hibikemanga.SearchResult, err error) {
if p.dir == "" {
return make([]*hibikemanga.SearchResult, 0), nil
}
entries, err := os.ReadDir(p.dir)
if err != nil {
return nil, err
}
res = make([]*hibikemanga.SearchResult, 0)
for _, entry := range entries {
if entry.IsDir() {
res = append(res, &hibikemanga.SearchResult{
ID: entry.Name(),
Title: entry.Name(),
Provider: LocalProvider,
})
}
}
return res, nil
}
func (p *Local) Search(opts hibikemanga.SearchOptions) (res []*hibikemanga.SearchResult, err error) {
res = make([]*hibikemanga.SearchResult, 0)
all, err := p.getAllManga()
if err != nil {
return nil, err
}
if opts.Query == "" {
return all, nil
}
allTitles := make([]*string, len(all))
for i, manga := range all {
allTitles[i] = &manga.Title
}
compRes := comparison.CompareWithLevenshteinCleanFunc(&opts.Query, allTitles, cleanMangaTitle)
var bestMatch *comparison.LevenshteinResult
for _, res := range compRes {
if bestMatch == nil || res.Distance < bestMatch.Distance {
bestMatch = res
}
}
if bestMatch == nil {
return res, nil
}
if bestMatch.Distance > 3 {
// If the best match is too far away, return no results
return res, nil
}
manga, ok := lo.Find(all, func(manga *hibikemanga.SearchResult) bool {
return manga.Title == *bestMatch.Value
})
if !ok {
return res, nil
}
res = append(res, manga)
return res, nil
}
func cleanMangaTitle(title string) string {
title = strings.TrimSpace(title)
// Remove some characters to make comparison easier
title = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '!' || r == '"' || r == '<' || r == '>' || r == '|' || r == ',' {
return rune(0)
}
return r
}, title)
return title
}
// FindChapters scans the manga series directory and returns the chapters.
// Supports nested folder structures up to 2 levels deep.
//
// Example:
//
// Series title/
// ├── Chapter 1/
// │ ├── image_1.ext
// │ └── image_n.ext
// ├── Chapter 2.pdf
// └── Ch 1-10/
// ├── Ch 1/
// └── Ch 2/
func (p *Local) FindChapters(mangaID string) (res []*hibikemanga.ChapterDetails, err error) {
if p.dir == "" {
return make([]*hibikemanga.ChapterDetails, 0), nil
}
mangaPath := filepath.Join(p.dir, mangaID)
p.logger.Trace().Str("mangaPath", mangaPath).Msg("manga: Finding local chapters")
// Collect all potential chapter entries up to 2 levels deep
chapterEntries, err := p.collectChapterEntries(mangaPath, mangaID, 0)
if err != nil {
return nil, err
}
res = make([]*hibikemanga.ChapterDetails, 0)
// Go through all collected entries.
for _, entry := range chapterEntries {
scannedEntry, ok := scanChapterFilename(filepath.Base(entry.RelativePath))
if !ok {
continue
}
if len(scannedEntry.Chapter) != 1 {
// Handle one-shots (no chapter number and only one entry)
if len(scannedEntry.Chapter) == 0 && len(chapterEntries) == 1 {
chapterTitle := "Chapter 1"
if scannedEntry.ChapterTitle != "" {
chapterTitle += " - " + scannedEntry.ChapterTitle
}
res = append(res, &hibikemanga.ChapterDetails{
Provider: LocalProvider,
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
URL: "",
Title: chapterTitle,
Chapter: "1",
Index: 0, // placeholder, will be set later
LocalIsPDF: scannedEntry.IsPDF,
})
} else if len(scannedEntry.Chapter) == 2 {
// Handle combined chapters (e.g. "Chapter 1-2")
chapterTitle := "Chapter " + cleanChapter(scannedEntry.Chapter[0]) + "-" + cleanChapter(scannedEntry.Chapter[1])
if scannedEntry.ChapterTitle != "" {
chapterTitle += " - " + scannedEntry.ChapterTitle
}
res = append(res, &hibikemanga.ChapterDetails{
Provider: LocalProvider,
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
URL: "",
Title: chapterTitle,
// Use the last chapter number as the chapter for progress tracking
Chapter: cleanChapter(scannedEntry.Chapter[1]),
Index: 0, // placeholder, will be set later
LocalIsPDF: scannedEntry.IsPDF,
})
}
continue
}
ch := cleanChapter(scannedEntry.Chapter[0])
chapterTitle := "Chapter " + ch
if scannedEntry.ChapterTitle != "" {
chapterTitle += " - " + scannedEntry.ChapterTitle
}
res = append(res, &hibikemanga.ChapterDetails{
Provider: LocalProvider,
ID: filepath.ToSlash(entry.RelativePath), // ID is the relative filepath, e.g. "/series/chapter_1.cbz" or "/series/vol1/ch1.cbz"
URL: "",
Title: chapterTitle,
Chapter: ch,
Index: 0, // placeholder, will be set later
LocalIsPDF: scannedEntry.IsPDF,
})
}
// sort by chapter number (ascending)
slices.SortFunc(res, func(a, b *hibikemanga.ChapterDetails) int {
chA, _ := strconv.ParseFloat(a.Chapter, 64)
chB, _ := strconv.ParseFloat(b.Chapter, 64)
return int(chA - chB)
})
// set the indexes
for i, chapter := range res {
chapter.Index = uint(i)
}
return res, nil
}
// collectChapterEntries walks the directory tree up to maxDepth levels deep and collects
// all potential chapter files and directories.
func (p *Local) collectChapterEntries(currentPath, mangaID string, currentDepth int) (entries []*chapterEntry, err error) {
const maxDepth = 2
if currentDepth > maxDepth {
return entries, nil
}
dirEntries, err := os.ReadDir(currentPath)
if err != nil {
return nil, err
}
entries = make([]*chapterEntry, 0)
for _, entry := range dirEntries {
entryPath := filepath.Join(currentPath, entry.Name())
// Calculate relative path from manga root
var relativePath string
if currentDepth == 0 {
// At manga root level
relativePath = filepath.Join(mangaID, entry.Name())
} else {
// Get the relative part from current path
relativeFromManga, err := filepath.Rel(filepath.Join(p.dir, mangaID), entryPath)
if err != nil {
continue
}
relativePath = filepath.Join(mangaID, relativeFromManga)
}
if entry.IsDir() {
// Check if this directory contains only images (making it a chapter directory)
isImageDirectory, _ := p.isImageOnlyDirectory(entryPath)
if isImageDirectory {
// Directory contains only images, treat it as a chapter
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: true,
})
} else if currentDepth < maxDepth {
// Directory doesn't contain only images, recursively scan subdirectories
subEntries, err := p.collectChapterEntries(entryPath, mangaID, currentDepth+1)
if err != nil {
continue
}
// If subdirectory contains chapters, add them
if len(subEntries) > 0 {
entries = append(entries, subEntries...)
} else {
// If no sub-chapters found, treat directory itself as potential chapter
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: true,
})
}
} else {
// At max depth, treat directory as potential chapter
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: true,
})
}
} else {
// File entry - check if it's a potential chapter file
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext == ".cbz" || ext == ".cbr" || ext == ".pdf" || ext == ".zip" {
entries = append(entries, &chapterEntry{
RelativePath: relativePath,
IsDir: false,
})
}
}
}
return entries, nil
}
// isImageOnlyDirectory checks if a directory contains only image files (no subdirectories or other files)
func (p *Local) isImageOnlyDirectory(dirPath string) (bool, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
return false, err
}
if len(entries) == 0 {
return false, nil
}
hasImages := false
for _, entry := range entries {
if entry.IsDir() {
return false, nil
}
if isFileImage(entry.Name()) {
hasImages = true
} else {
return false, nil
}
}
return hasImages, nil
}
// "0001" -> "1", "0" -> "0"
func cleanChapter(ch string) string {
if ch == "" {
return ""
}
if ch == "0" {
return "0"
}
if strings.HasPrefix(ch, "0") {
return strings.TrimLeft(ch, "0")
}
return ch
}
// FindChapterPages will extract the images
func (p *Local) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
if p.dir == "" {
return make([]*hibikemanga.ChapterPage, 0), nil
}
// id = filepath
// e.g. "series/chapter_1.cbz"
fullpath := filepath.Join(p.dir, id) // e.g. "/collection/series/chapter_1.cbz"
// Prefix with {{manga-local-assets}} to signal the client that this is a local file
// e.g. "{{manga-local-assets}}/series/chapter_1.cbz/image_1.jpg"
formatUrl := func(fileName string) string {
return filepath.ToSlash(filepath.Join(LocalServePath, id, fileName))
}
ext := filepath.Ext(fullpath)
// Close the current pages
if p.currentZipCloser != nil {
_ = p.currentZipCloser.Close()
}
for _, loadedPage := range p.currentPages {
loadedPage.buf = nil
}
p.currentPages = make(map[string]*loadedPage)
p.currentZipCloser = nil
p.currentChapterPath = fullpath
switch ext {
case ".zip", ".cbz":
r, err := zip.OpenReader(fullpath)
if err != nil {
return nil, err
}
defer r.Close()
for _, f := range r.File {
if !isFileImage(f.Name) {
continue
}
page, err := f.Open()
if err != nil {
return nil, fmt.Errorf("failed to open page: %w", err)
}
buf, err := io.ReadAll(page)
if err != nil {
return nil, fmt.Errorf("failed to read page: %w", err)
}
p.currentPages[strings.ToLower(f.Name)] = &loadedPage{
buf: buf,
page: &hibikemanga.ChapterPage{
Provider: LocalProvider,
URL: formatUrl(f.Name),
Index: 0, // placeholder, will be set later
Buf: buf,
},
}
}
case ".pdf":
// doc, err := fitz.New(fullpath)
// if err != nil {
// return nil, fmt.Errorf("failed to open PDF file: %w", err)
// }
// defer doc.Close()
// // Load images into memory
// for n := 0; n < doc.NumPage(); n++ {
// img, err := doc.Image(n)
// if err != nil {
// panic(err)
// }
// var buf bytes.Buffer
// err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
// if err != nil {
// panic(err)
// }
// p.currentPages[fmt.Sprintf("page_%d.jpg", n)] = &loadedPage{
// buf: buf.Bytes(),
// page: &hibikemanga.ChapterPage{
// Provider: LocalProvider,
// URL: formatUrl(fmt.Sprintf("page_%d.jpg", n)),
// Index: n,
// },
// }
// }
default:
// If it's a directory of images
stat, err := os.Stat(fullpath)
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
if !stat.IsDir() {
return nil, fmt.Errorf("file is not a directory: %s", fullpath)
}
entries, err := os.ReadDir(fullpath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
for _, entry := range entries {
if !isFileImage(entry.Name()) {
continue
}
page, err := os.Open(filepath.Join(fullpath, entry.Name()))
if err != nil {
return nil, fmt.Errorf("failed to open page: %w", err)
}
buf, err := io.ReadAll(page)
if err != nil {
return nil, fmt.Errorf("failed to read page: %w", err)
}
p.currentPages[strings.ToLower(entry.Name())] = &loadedPage{
buf: buf,
page: &hibikemanga.ChapterPage{
Provider: LocalProvider,
URL: formatUrl(entry.Name()),
Index: 0, // placeholder, will be set later
Buf: buf,
},
}
}
}
type pageStruct struct {
Number float64
LoadedPage *loadedPage
}
pages := make([]*pageStruct, 0)
// Parse and order the pages
for _, loadedPage := range p.currentPages {
scannedPage, ok := parsePageFilename(filepath.Base(loadedPage.page.URL))
if !ok {
continue
}
pages = append(pages, &pageStruct{
Number: scannedPage.Number,
LoadedPage: loadedPage,
})
}
// Sort pages
slices.SortFunc(pages, func(a, b *pageStruct) int {
return strings.Compare(filepath.Base(a.LoadedPage.page.URL), filepath.Base(b.LoadedPage.page.URL))
})
ret = make([]*hibikemanga.ChapterPage, 0)
for idx, pageStruct := range pages {
pageStruct.LoadedPage.page.Index = idx
ret = append(ret, pageStruct.LoadedPage.page)
}
return ret, nil
}
func (p *Local) ReadPage(path string) (ret io.ReadCloser, err error) {
// e.g. path = "/series/chapter_1.cbz/image_1.jpg"
// If the pages are already in memory, return them
if len(p.currentPages) > 0 {
page, ok := p.currentPages[strings.ToLower(filepath.Base(path))]
if ok {
return io.NopCloser(bytes.NewReader(page.buf)), nil // Return the page
}
}
return nil, fmt.Errorf("page not found: %s", path)
}