node build fixed
This commit is contained in:
556
seanime-2.9.10/internal/manga/providers/local.go
Normal file
556
seanime-2.9.10/internal/manga/providers/local.go
Normal file
@@ -0,0 +1,556 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user