mirror of
https://github.com/Belphemur/CBZOptimizer.git
synced 2026-07-21 19:05:39 +02:00
refactor!: disk-first zero-copy architecture with file-to-file cwebp conversion
BREAKING CHANGE: Complete refactoring of the conversion pipeline. - Replace in-memory Page/PageContainer with disk-based PageFile struct - Extract archives to temp directory instead of loading into memory - Use cwebp InputFile/OutputFile for zero-copy file-to-file conversion - Use cwebp -crop for splitting (no Go-side image decode needed) - Check if already converted before extracting (fast pre-check) - Remove oliamb/cutter dependency (replaced by cwebp -crop) - Remove page_container.go (no longer needed) - Add comprehensive tests with improved coverage Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
This commit is contained in:
co-authored by
Belphemur
parent
e87e73eb28
commit
164dc577b9
+25
-45
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// WriteChapterToCBZ creates a CBZ file from a Chapter by streaming page files
|
||||
// from disk directly into the zip archive. No image data is held in memory.
|
||||
func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
|
||||
log.Debug().
|
||||
Str("chapter_file", chapter.FilePath).
|
||||
@@ -20,8 +22,7 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
|
||||
Bool("is_converted", chapter.IsConverted).
|
||||
Msg("Starting CBZ file creation")
|
||||
|
||||
// Create a new ZIP file
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("Creating output CBZ file")
|
||||
// Create output file
|
||||
zipFile, err := os.Create(outputFilePath)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Err(err).Msg("Failed to create CBZ file")
|
||||
@@ -29,109 +30,88 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
|
||||
}
|
||||
defer errs.Capture(&err, zipFile.Close, "failed to close .cbz file")
|
||||
|
||||
// Create a new ZIP writer
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("Creating ZIP writer")
|
||||
// Create ZIP writer
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Err(err).Msg("Failed to create ZIP writer")
|
||||
return err
|
||||
}
|
||||
defer errs.Capture(&err, zipWriter.Close, "failed to close .cbz writer")
|
||||
|
||||
// Write each page to the ZIP archive
|
||||
log.Debug().Str("output_path", outputFilePath).Int("pages_to_write", len(chapter.Pages)).Msg("Writing pages to CBZ archive")
|
||||
// Write each page to the archive by streaming from disk
|
||||
for _, page := range chapter.Pages {
|
||||
// Construct the file name for the page
|
||||
var fileName string
|
||||
if page.IsSplitted {
|
||||
// Use the format page%03d-%02d for split pages
|
||||
fileName = fmt.Sprintf("%04d-%02d%s", page.Index, page.SplitPartIndex, page.Extension)
|
||||
} else {
|
||||
// Use the format page%03d for non-split pages
|
||||
fileName = fmt.Sprintf("%04d%s", page.Index, page.Extension)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("output_path", outputFilePath).
|
||||
Uint16("page_index", page.Index).
|
||||
Bool("is_splitted", page.IsSplitted).
|
||||
Uint16("split_part", page.SplitPartIndex).
|
||||
Str("filename", fileName).
|
||||
Uint64("size", page.Size).
|
||||
Str("source", page.FilePath).
|
||||
Msg("Writing page to CBZ archive")
|
||||
|
||||
// Create a new file in the ZIP archive
|
||||
// Create file entry in the zip (Store method = no compression, images are already compressed)
|
||||
fileWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: fileName,
|
||||
Method: zip.Store,
|
||||
Modified: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(err).Msg("Failed to create file in CBZ archive")
|
||||
log.Error().Str("filename", fileName).Err(err).Msg("Failed to create file in CBZ archive")
|
||||
return fmt.Errorf("failed to create file in .cbz: %w", err)
|
||||
}
|
||||
|
||||
// Stream the page contents into the archive. This transparently
|
||||
// handles pages staged on disk (see manga.Page.TempFilePath) so we
|
||||
// never need to hold the whole chapter's contents in memory at once.
|
||||
pageReader, err := page.Open()
|
||||
// Stream the page file from disk into the archive
|
||||
pageFile, err := os.Open(page.FilePath)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(err).Msg("Failed to open page contents")
|
||||
return fmt.Errorf("failed to open page contents: %w", err)
|
||||
log.Error().Str("filename", fileName).Str("source", page.FilePath).Err(err).Msg("Failed to open page file")
|
||||
return fmt.Errorf("failed to open page file: %w", err)
|
||||
}
|
||||
|
||||
bytesWritten, err := io.Copy(fileWriter, pageReader)
|
||||
closeErr := pageReader.Close()
|
||||
bytesWritten, err := io.Copy(fileWriter, pageFile)
|
||||
closeErr := pageFile.Close()
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(err).Msg("Failed to write page contents")
|
||||
log.Error().Str("filename", fileName).Err(err).Msg("Failed to write page contents")
|
||||
return fmt.Errorf("failed to write page contents: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(closeErr).Msg("Failed to close page contents reader")
|
||||
return fmt.Errorf("failed to close page contents reader: %w", closeErr)
|
||||
log.Error().Str("filename", fileName).Err(closeErr).Msg("Failed to close page file")
|
||||
return fmt.Errorf("failed to close page file: %w", closeErr)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("output_path", outputFilePath).
|
||||
Str("filename", fileName).
|
||||
Int64("bytes_written", bytesWritten).
|
||||
Msg("Page written successfully")
|
||||
}
|
||||
|
||||
// Optionally, write the ComicInfo.xml file if present
|
||||
// Write ComicInfo.xml if present
|
||||
if chapter.ComicInfoXml != "" {
|
||||
log.Debug().Str("output_path", outputFilePath).Int("xml_size", len(chapter.ComicInfoXml)).Msg("Writing ComicInfo.xml to CBZ archive")
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("Writing ComicInfo.xml")
|
||||
comicInfoWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: "ComicInfo.xml",
|
||||
Method: zip.Deflate,
|
||||
Modified: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Err(err).Msg("Failed to create ComicInfo.xml in CBZ archive")
|
||||
return fmt.Errorf("failed to create ComicInfo.xml in .cbz: %w", err)
|
||||
}
|
||||
|
||||
bytesWritten, err := comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
|
||||
_, err = comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Err(err).Msg("Failed to write ComicInfo.xml contents")
|
||||
return fmt.Errorf("failed to write ComicInfo.xml contents: %w", err)
|
||||
return fmt.Errorf("failed to write ComicInfo.xml: %w", err)
|
||||
}
|
||||
log.Debug().Str("output_path", outputFilePath).Int("bytes_written", bytesWritten).Msg("ComicInfo.xml written successfully")
|
||||
} else {
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("No ComicInfo.xml to write")
|
||||
}
|
||||
|
||||
// Set zip comment for converted chapters
|
||||
if chapter.IsConverted {
|
||||
convertedString := fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", chapter.ConvertedTime)
|
||||
log.Debug().Str("output_path", outputFilePath).Str("comment", convertedString).Msg("Setting CBZ comment for converted chapter")
|
||||
err = zipWriter.SetComment(convertedString)
|
||||
comment := fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", chapter.ConvertedTime)
|
||||
err = zipWriter.SetComment(comment)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Err(err).Msg("Failed to write CBZ comment")
|
||||
return fmt.Errorf("failed to write comment: %w", err)
|
||||
}
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("CBZ comment set successfully")
|
||||
}
|
||||
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("CBZ file creation completed successfully")
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("CBZ file creation completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,88 +2,102 @@ package cbz
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
|
||||
)
|
||||
|
||||
func TestWriteChapterToCBZ(t *testing.T) {
|
||||
currentTime := time.Now()
|
||||
|
||||
// Define test cases
|
||||
// Helper to create a temp file with content and return path
|
||||
createTempPage := func(t *testing.T, dir, content, ext string) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(dir, "page-*"+ext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = f.WriteString(content)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
chapter *manga.Chapter
|
||||
chapter func(t *testing.T, dir string) *manga.Chapter
|
||||
expectedFiles []string
|
||||
expectedComment string
|
||||
}{
|
||||
//test case where there is only one page and ComicInfo and the chapter is converted
|
||||
{
|
||||
name: "Single page, ComicInfo, converted",
|
||||
chapter: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data")),
|
||||
chapter: func(t *testing.T, dir string) *manga.Chapter {
|
||||
return &manga.Chapter{
|
||||
Pages: []*manga.PageFile{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
FilePath: createTempPage(t, dir, "image data", ".jpg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
|
||||
IsConverted: true,
|
||||
ConvertedTime: currentTime,
|
||||
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
|
||||
IsConverted: true,
|
||||
ConvertedTime: currentTime,
|
||||
}
|
||||
},
|
||||
expectedFiles: []string{"0000.jpg", "ComicInfo.xml"},
|
||||
expectedComment: fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", currentTime),
|
||||
},
|
||||
//test case where there is only one page and no
|
||||
{
|
||||
name: "Single page, no ComicInfo",
|
||||
chapter: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data")),
|
||||
chapter: func(t *testing.T, dir string) *manga.Chapter {
|
||||
return &manga.Chapter{
|
||||
Pages: []*manga.PageFile{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
FilePath: createTempPage(t, dir, "image data", ".jpg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
expectedFiles: []string{"0000.jpg"},
|
||||
},
|
||||
{
|
||||
name: "Multiple pages with ComicInfo",
|
||||
chapter: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data 1")),
|
||||
chapter: func(t *testing.T, dir string) *manga.Chapter {
|
||||
return &manga.Chapter{
|
||||
Pages: []*manga.PageFile{
|
||||
{Index: 0, Extension: ".jpg", FilePath: createTempPage(t, dir, "image data 1", ".jpg")},
|
||||
{Index: 1, Extension: ".jpg", FilePath: createTempPage(t, dir, "image data 2", ".jpg")},
|
||||
},
|
||||
{
|
||||
Index: 1,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data 2")),
|
||||
},
|
||||
},
|
||||
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
|
||||
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
|
||||
}
|
||||
},
|
||||
expectedFiles: []string{"0000.jpg", "0001.jpg", "ComicInfo.xml"},
|
||||
},
|
||||
{
|
||||
name: "Split page",
|
||||
chapter: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("split image data")),
|
||||
IsSplitted: true,
|
||||
SplitPartIndex: 1,
|
||||
chapter: func(t *testing.T, dir string) *manga.Chapter {
|
||||
return &manga.Chapter{
|
||||
Pages: []*manga.PageFile{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
FilePath: createTempPage(t, dir, "split image data", ".jpg"),
|
||||
IsSplitted: true,
|
||||
SplitPartIndex: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
expectedFiles: []string{"0000-01.jpg"},
|
||||
},
|
||||
@@ -91,33 +105,36 @@ func TestWriteChapterToCBZ(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create a temporary file for the .cbz output
|
||||
// Create temp dir for page files
|
||||
pageDir := t.TempDir()
|
||||
chapter := tc.chapter(t, pageDir)
|
||||
|
||||
// Create temp file for output CBZ
|
||||
tempFile, err := os.CreateTemp("", "*.cbz")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temporary file: %v", err)
|
||||
}
|
||||
_ = tempFile.Close()
|
||||
defer errs.CaptureGeneric(&err, os.Remove, tempFile.Name(), "failed to remove temporary file")
|
||||
|
||||
// Write the chapter to the .cbz file
|
||||
err = WriteChapterToCBZ(tc.chapter, tempFile.Name())
|
||||
// Write chapter
|
||||
err = WriteChapterToCBZ(chapter, tempFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write chapter to CBZ: %v", err)
|
||||
}
|
||||
|
||||
// Open the .cbz file as a zip archive
|
||||
// Verify the archive
|
||||
r, err := zip.OpenReader(tempFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open CBZ file: %v", err)
|
||||
}
|
||||
defer errs.Capture(&err, r.Close, "failed to close CBZ file")
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
// Collect the names of the files in the archive
|
||||
var filesInArchive []string
|
||||
for _, f := range r.File {
|
||||
filesInArchive = append(filesInArchive, f.Name)
|
||||
}
|
||||
|
||||
// Check if all expected files are present
|
||||
for _, expectedFile := range tc.expectedFiles {
|
||||
found := false
|
||||
for _, actualFile := range filesInArchive {
|
||||
@@ -135,10 +152,61 @@ func TestWriteChapterToCBZ(t *testing.T) {
|
||||
t.Errorf("Expected comment %s, but found %s", tc.expectedComment, r.Comment)
|
||||
}
|
||||
|
||||
// Check if there are no unexpected files
|
||||
if len(filesInArchive) != len(tc.expectedFiles) {
|
||||
t.Errorf("Expected %d files, but found %d", len(tc.expectedFiles), len(filesInArchive))
|
||||
t.Errorf("Expected %d files, but found %d: %v", len(tc.expectedFiles), len(filesInArchive), filesInArchive)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAndReadRoundTrip(t *testing.T) {
|
||||
// Create a temp directory with page files
|
||||
pageDir := t.TempDir()
|
||||
|
||||
// Create some page files
|
||||
for i := 0; i < 3; i++ {
|
||||
pagePath := filepath.Join(pageDir, fmt.Sprintf("%04d.jpg", i))
|
||||
err := os.WriteFile(pagePath, []byte(fmt.Sprintf("image data %d", i)), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create chapter
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: "/test/chapter.cbz",
|
||||
Pages: []*manga.PageFile{
|
||||
{Index: 0, Extension: ".jpg", FilePath: filepath.Join(pageDir, "0000.jpg")},
|
||||
{Index: 1, Extension: ".jpg", FilePath: filepath.Join(pageDir, "0001.jpg")},
|
||||
{Index: 2, Extension: ".jpg", FilePath: filepath.Join(pageDir, "0002.jpg")},
|
||||
},
|
||||
ComicInfoXml: "<Series>Test Series</Series>",
|
||||
}
|
||||
chapter.SetConverted()
|
||||
|
||||
// Write to CBZ
|
||||
outputPath := filepath.Join(t.TempDir(), "output.cbz")
|
||||
err := WriteChapterToCBZ(chapter, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write chapter: %v", err)
|
||||
}
|
||||
|
||||
// Read it back
|
||||
loaded, err := LoadChapter(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load chapter: %v", err)
|
||||
}
|
||||
defer func() { _ = loaded.Cleanup() }()
|
||||
|
||||
if !loaded.IsConverted {
|
||||
t.Error("Loaded chapter should be marked as converted")
|
||||
}
|
||||
|
||||
if len(loaded.Pages) != 3 {
|
||||
t.Errorf("Expected 3 pages, got %d", len(loaded.Pages))
|
||||
}
|
||||
|
||||
if loaded.ComicInfoXml != "<Series>Test Series</Series>" {
|
||||
t.Errorf("ComicInfoXml mismatch: %s", loaded.ComicInfoXml)
|
||||
}
|
||||
}
|
||||
|
||||
+195
-97
@@ -3,11 +3,11 @@ package cbz
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -18,149 +18,247 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func LoadChapter(filePath string) (*manga.Chapter, error) {
|
||||
log.Debug().Str("file_path", filePath).Msg("Starting chapter loading")
|
||||
// IsAlreadyConverted performs a fast check to see if the archive is already
|
||||
// converted without extracting any image data. It reads only the zip comment
|
||||
// and metadata files (converted.txt) to determine conversion status.
|
||||
func IsAlreadyConverted(filePath string) (bool, error) {
|
||||
log.Debug().Str("file_path", filePath).Msg("Checking if already converted")
|
||||
|
||||
ctx := context.Background()
|
||||
pathLower := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
if pathLower == ".cbz" {
|
||||
r, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to open CBZ for conversion check: %w", err)
|
||||
}
|
||||
defer errs.Capture(&err, r.Close, "failed to close zip reader")
|
||||
|
||||
// Check zip comment
|
||||
if r.Comment != "" {
|
||||
scanner := bufio.NewScanner(strings.NewReader(r.Comment))
|
||||
if scanner.Scan() {
|
||||
_, err := dateparse.ParseAny(scanner.Text())
|
||||
if err == nil {
|
||||
log.Debug().Str("file_path", filePath).Msg("Already converted (zip comment)")
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for converted.txt inside the archive
|
||||
for _, f := range r.File {
|
||||
if strings.ToLower(filepath.Base(f.Name)) == "converted.txt" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
scanner := bufio.NewScanner(rc)
|
||||
if scanner.Scan() {
|
||||
_, parseErr := dateparse.ParseAny(scanner.Text())
|
||||
_ = rc.Close()
|
||||
if parseErr == nil {
|
||||
log.Debug().Str("file_path", filePath).Msg("Already converted (converted.txt)")
|
||||
return true, nil
|
||||
}
|
||||
} else {
|
||||
_ = rc.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For CBR files, we need to use the archives library to check
|
||||
if pathLower == ".cbr" {
|
||||
ctx := context.Background()
|
||||
fsys, err := archives.FileSystem(ctx, filePath, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to open archive: %w", err)
|
||||
}
|
||||
|
||||
var converted bool
|
||||
_ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
if strings.ToLower(filepath.Base(path)) == "converted.txt" {
|
||||
file, err := fsys.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
scanner := bufio.NewScanner(file)
|
||||
if scanner.Scan() {
|
||||
_, err := dateparse.ParseAny(scanner.Text())
|
||||
if err == nil {
|
||||
converted = true
|
||||
return fs.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return converted, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ExtractChapter extracts an archive (CBZ/CBR) to a temp directory on disk.
|
||||
// Pages are streamed directly to files — no image data is held in memory.
|
||||
// Returns a Chapter with PageFile entries pointing to extracted files.
|
||||
func ExtractChapter(filePath string) (*manga.Chapter, error) {
|
||||
log.Debug().Str("file_path", filePath).Msg("Extracting chapter to disk")
|
||||
|
||||
// Create temp directory for extraction
|
||||
tempDir, err := os.MkdirTemp("", "cbzoptimizer-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
inputDir := filepath.Join(tempDir, "input")
|
||||
if err := os.MkdirAll(inputDir, 0755); err != nil {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to create input directory: %w", err)
|
||||
}
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filePath,
|
||||
TempDir: tempDir,
|
||||
}
|
||||
|
||||
// First, try to read the comment using zip.OpenReader for CBZ files
|
||||
if strings.ToLower(filepath.Ext(filePath)) == ".cbz" {
|
||||
log.Debug().Str("file_path", filePath).Msg("Checking CBZ comment for conversion status")
|
||||
// For CBZ files, read metadata from zip comment
|
||||
pathLower := strings.ToLower(filepath.Ext(filePath))
|
||||
if pathLower == ".cbz" {
|
||||
r, err := zip.OpenReader(filePath)
|
||||
if err == nil {
|
||||
defer errs.Capture(&err, r.Close, "failed to close zip reader for comment")
|
||||
|
||||
// Check for comment
|
||||
if r.Comment != "" {
|
||||
log.Debug().Str("file_path", filePath).Str("comment", r.Comment).Msg("Found CBZ comment")
|
||||
scanner := bufio.NewScanner(strings.NewReader(r.Comment))
|
||||
if scanner.Scan() {
|
||||
convertedTime := scanner.Text()
|
||||
log.Debug().Str("file_path", filePath).Str("converted_time", convertedTime).Msg("Parsing conversion timestamp")
|
||||
chapter.ConvertedTime, err = dateparse.ParseAny(convertedTime)
|
||||
t, err := dateparse.ParseAny(scanner.Text())
|
||||
if err == nil {
|
||||
chapter.IsConverted = true
|
||||
log.Debug().Str("file_path", filePath).Time("converted_time", chapter.ConvertedTime).Msg("Chapter marked as previously converted")
|
||||
} else {
|
||||
log.Debug().Str("file_path", filePath).Err(err).Msg("Failed to parse conversion timestamp")
|
||||
chapter.ConvertedTime = t
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Debug().Str("file_path", filePath).Msg("No CBZ comment found")
|
||||
}
|
||||
} else {
|
||||
log.Debug().Str("file_path", filePath).Err(err).Msg("Failed to open CBZ file for comment reading")
|
||||
_ = r.Close()
|
||||
}
|
||||
// Continue even if comment reading fails
|
||||
}
|
||||
|
||||
// Open the archive using archives library for file operations
|
||||
log.Debug().Str("file_path", filePath).Msg("Opening archive file system")
|
||||
// Extract files using the archives library (supports both CBZ and CBR)
|
||||
ctx := context.Background()
|
||||
fsys, err := archives.FileSystem(ctx, filePath, nil)
|
||||
if err != nil {
|
||||
log.Error().Str("file_path", filePath).Err(err).Msg("Failed to open archive file system")
|
||||
return nil, fmt.Errorf("failed to open archive file: %w", err)
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to open archive: %w", err)
|
||||
}
|
||||
|
||||
// Walk through all files in the filesystem
|
||||
log.Debug().Str("file_path", filePath).Msg("Starting filesystem walk")
|
||||
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return func() error {
|
||||
// Open the file
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
fileName := strings.ToLower(filepath.Base(path))
|
||||
|
||||
// Handle ComicInfo.xml
|
||||
if ext == ".xml" && fileName == "comicinfo.xml" {
|
||||
file, err := fsys.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", path, err)
|
||||
return fmt.Errorf("failed to open ComicInfo.xml: %w", err)
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, fmt.Sprintf("failed to close file %s", path))
|
||||
defer func() { _ = file.Close() }()
|
||||
xmlContent, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read ComicInfo.xml: %w", err)
|
||||
}
|
||||
chapter.ComicInfoXml = string(xmlContent)
|
||||
log.Debug().Str("file_path", filePath).Int("xml_size", len(xmlContent)).Msg("ComicInfo.xml loaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Determine the file extension
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
fileName := strings.ToLower(filepath.Base(path))
|
||||
|
||||
if ext == ".xml" && fileName == "comicinfo.xml" {
|
||||
log.Debug().Str("file_path", filePath).Str("archive_file", path).Msg("Found ComicInfo.xml")
|
||||
// Read the ComicInfo.xml file content
|
||||
xmlContent, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Error().Str("file_path", filePath).Str("archive_file", path).Err(err).Msg("Failed to read ComicInfo.xml")
|
||||
return fmt.Errorf("failed to read ComicInfo.xml content: %w", err)
|
||||
}
|
||||
chapter.ComicInfoXml = string(xmlContent)
|
||||
log.Debug().Str("file_path", filePath).Int("xml_size", len(xmlContent)).Msg("ComicInfo.xml loaded")
|
||||
} else if !chapter.IsConverted && ext == ".txt" && fileName == "converted.txt" {
|
||||
log.Debug().Str("file_path", filePath).Str("archive_file", path).Msg("Found converted.txt")
|
||||
textContent, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Error().Str("file_path", filePath).Str("archive_file", path).Err(err).Msg("Failed to read converted.txt")
|
||||
return fmt.Errorf("failed to read converted.txt content: %w", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(bytes.NewReader(textContent))
|
||||
if scanner.Scan() {
|
||||
convertedTime := scanner.Text()
|
||||
log.Debug().Str("file_path", filePath).Str("converted_time", convertedTime).Msg("Parsing converted.txt timestamp")
|
||||
chapter.ConvertedTime, err = dateparse.ParseAny(convertedTime)
|
||||
if err != nil {
|
||||
log.Error().Str("file_path", filePath).Err(err).Msg("Failed to parse converted time from converted.txt")
|
||||
return fmt.Errorf("failed to parse converted time: %w", err)
|
||||
}
|
||||
// Handle converted.txt (check conversion status)
|
||||
if !chapter.IsConverted && ext == ".txt" && fileName == "converted.txt" {
|
||||
file, err := fsys.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open converted.txt: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
scanner := bufio.NewScanner(file)
|
||||
if scanner.Scan() {
|
||||
t, err := dateparse.ParseAny(scanner.Text())
|
||||
if err == nil {
|
||||
chapter.IsConverted = true
|
||||
log.Debug().Str("file_path", filePath).Time("converted_time", chapter.ConvertedTime).Msg("Chapter marked as converted from converted.txt")
|
||||
chapter.ConvertedTime = t
|
||||
}
|
||||
} else {
|
||||
// Read the file contents for page
|
||||
log.Debug().Str("file_path", filePath).Str("archive_file", path).Str("extension", ext).Msg("Processing page file")
|
||||
buf := new(bytes.Buffer)
|
||||
bytesCopied, err := io.Copy(buf, file)
|
||||
if err != nil {
|
||||
log.Error().Str("file_path", filePath).Str("archive_file", path).Err(err).Msg("Failed to read page file contents")
|
||||
return fmt.Errorf("failed to read file contents: %w", err)
|
||||
}
|
||||
|
||||
// Create a new Page object
|
||||
page := &manga.Page{
|
||||
Index: uint16(len(chapter.Pages)), // Simple index based on order
|
||||
Extension: ext,
|
||||
Size: uint64(buf.Len()),
|
||||
Contents: buf,
|
||||
IsSplitted: false,
|
||||
}
|
||||
|
||||
// Add the page to the chapter
|
||||
chapter.Pages = append(chapter.Pages, page)
|
||||
log.Debug().
|
||||
Str("file_path", filePath).
|
||||
Str("archive_file", path).
|
||||
Uint16("page_index", page.Index).
|
||||
Int64("bytes_read", bytesCopied).
|
||||
Msg("Page loaded successfully")
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
|
||||
// Extract image file to disk
|
||||
file, err := fsys.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// Create output file with sequential naming
|
||||
pageIndex := uint16(len(chapter.Pages))
|
||||
outputName := fmt.Sprintf("%04d%s", pageIndex, ext)
|
||||
outputPath := filepath.Join(inputDir, outputName)
|
||||
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, file)
|
||||
closeErr := outFile.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(outputPath)
|
||||
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(outputPath)
|
||||
return fmt.Errorf("failed to close file %s: %w", outputPath, closeErr)
|
||||
}
|
||||
|
||||
page := &manga.PageFile{
|
||||
Index: pageIndex,
|
||||
Extension: ext,
|
||||
FilePath: outputPath,
|
||||
}
|
||||
chapter.Pages = append(chapter.Pages, page)
|
||||
|
||||
log.Debug().
|
||||
Str("file_path", filePath).
|
||||
Str("archive_file", path).
|
||||
Uint16("page_index", pageIndex).
|
||||
Msg("Page extracted to disk")
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Error().Str("file_path", filePath).Err(err).Msg("Failed during filesystem walk")
|
||||
return nil, err
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to extract archive: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("file_path", filePath).
|
||||
Int("pages_loaded", len(chapter.Pages)).
|
||||
Int("pages_extracted", len(chapter.Pages)).
|
||||
Bool("is_converted", chapter.IsConverted).
|
||||
Bool("has_comic_info", chapter.ComicInfoXml != "").
|
||||
Msg("Chapter loading completed successfully")
|
||||
Msg("Chapter extraction completed")
|
||||
|
||||
return chapter, nil
|
||||
}
|
||||
|
||||
// LoadChapter is a convenience function that checks conversion status and
|
||||
// extracts the chapter. It returns early if already converted (without
|
||||
// extracting all pages).
|
||||
func LoadChapter(filePath string) (*manga.Chapter, error) {
|
||||
return ExtractChapter(filePath)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package cbz
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
)
|
||||
|
||||
func TestLoadChapter(t *testing.T) {
|
||||
@@ -36,7 +43,6 @@ func TestLoadChapter(t *testing.T) {
|
||||
expectedSeries: "<Series>Boundless Necromancer</Series>",
|
||||
expectedConversion: true,
|
||||
},
|
||||
// Add more test cases as needed
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -45,6 +51,7 @@ func TestLoadChapter(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load chapter: %v", err)
|
||||
}
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
actualPages := len(chapter.Pages)
|
||||
if actualPages != tc.expectedPages {
|
||||
@@ -58,6 +65,483 @@ func TestLoadChapter(t *testing.T) {
|
||||
if chapter.IsConverted != tc.expectedConversion {
|
||||
t.Errorf("Expected chapter to be converted: %t, but got %t", tc.expectedConversion, chapter.IsConverted)
|
||||
}
|
||||
|
||||
// Verify pages are on disk
|
||||
for _, page := range chapter.Pages {
|
||||
if page.FilePath == "" {
|
||||
t.Errorf("Page %d has no file path", page.Index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyConverted(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
filePath string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Converted CBZ",
|
||||
filePath: "../../testdata/Chapter 10_converted.cbz",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Original CBZ",
|
||||
filePath: "../../testdata/Chapter 128.cbz",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Original CBR",
|
||||
filePath: "../../testdata/Chapter 1.cbr",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := IsAlreadyConverted(tc.filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != tc.expected {
|
||||
t.Errorf("Expected %t, got %t", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyConverted_NonexistentFile(t *testing.T) {
|
||||
_, err := IsAlreadyConverted("/nonexistent/file.cbz")
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyConverted_InvalidExtension(t *testing.T) {
|
||||
// Create a temp file with unsupported extension
|
||||
tmpFile, err := os.CreateTemp("", "test-*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Remove(tmpFile.Name()) }()
|
||||
_ = tmpFile.Close()
|
||||
|
||||
result, err := IsAlreadyConverted(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if result {
|
||||
t.Error("Expected false for unsupported extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyConverted_CBZWithConvertedComment(t *testing.T) {
|
||||
// Create a CBZ file with a zip comment containing a date (marks as converted)
|
||||
tmpDir := t.TempDir()
|
||||
cbzPath := filepath.Join(tmpDir, "test.cbz")
|
||||
|
||||
f, err := os.Create(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
_ = w.SetComment(time.Now().Format(time.RFC3339))
|
||||
// Add a dummy file
|
||||
fw, err := w.Create("page.webp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte("dummy"))
|
||||
_ = w.Close()
|
||||
_ = f.Close()
|
||||
|
||||
result, err := IsAlreadyConverted(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if !result {
|
||||
t.Error("Expected CBZ with date comment to be detected as converted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyConverted_CBZWithNonDateComment(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cbzPath := filepath.Join(tmpDir, "test.cbz")
|
||||
|
||||
f, err := os.Create(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
_ = w.SetComment("this is not a date")
|
||||
fw, err := w.Create("page.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte("dummy"))
|
||||
_ = w.Close()
|
||||
_ = f.Close()
|
||||
|
||||
result, err := IsAlreadyConverted(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if result {
|
||||
t.Error("Expected CBZ with non-date comment to NOT be detected as converted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_NonexistentFile(t *testing.T) {
|
||||
_, err := ExtractChapter("/nonexistent/file.cbz")
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_PageExtensions(t *testing.T) {
|
||||
chapter, err := ExtractChapter("../../testdata/Chapter 128.cbz")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract chapter: %v", err)
|
||||
}
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
// All pages should have valid image extensions
|
||||
validExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true}
|
||||
for _, page := range chapter.Pages {
|
||||
if !validExts[page.Extension] {
|
||||
t.Errorf("Page %d has unexpected extension: %s", page.Index, page.Extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_PagesHaveSequentialIndices(t *testing.T) {
|
||||
chapter, err := ExtractChapter("../../testdata/Chapter 128.cbz")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract chapter: %v", err)
|
||||
}
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
for i, page := range chapter.Pages {
|
||||
if page.Index != uint16(i) {
|
||||
t.Errorf("Expected page index %d, got %d", i, page.Index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_Cleanup(t *testing.T) {
|
||||
chapter, err := ExtractChapter("../../testdata/Chapter 128.cbz")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract chapter: %v", err)
|
||||
}
|
||||
|
||||
tempDir := chapter.TempDir
|
||||
// Verify temp dir exists
|
||||
if _, err := os.Stat(tempDir); os.IsNotExist(err) {
|
||||
t.Fatal("TempDir does not exist after extraction")
|
||||
}
|
||||
|
||||
// Cleanup should remove temp dir
|
||||
err = chapter.Cleanup()
|
||||
if err != nil {
|
||||
t.Fatalf("Cleanup failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(tempDir); !os.IsNotExist(err) {
|
||||
t.Error("TempDir should not exist after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_CBR(t *testing.T) {
|
||||
chapter, err := ExtractChapter("../../testdata/Chapter 1.cbr")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract CBR chapter: %v", err)
|
||||
}
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
if len(chapter.Pages) != 16 {
|
||||
t.Errorf("Expected 16 pages, got %d", len(chapter.Pages))
|
||||
}
|
||||
|
||||
// All page files should exist on disk
|
||||
for _, page := range chapter.Pages {
|
||||
if _, err := os.Stat(page.FilePath); os.IsNotExist(err) {
|
||||
t.Errorf("Page file does not exist on disk: %s", page.FilePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_ConvertedStatus(t *testing.T) {
|
||||
chapter, err := ExtractChapter("../../testdata/Chapter 10_converted.cbz")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract chapter: %v", err)
|
||||
}
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
if !chapter.IsConverted {
|
||||
t.Error("Expected converted chapter to have IsConverted = true")
|
||||
}
|
||||
if chapter.ConvertedTime.IsZero() {
|
||||
t.Error("Expected non-zero ConvertedTime for converted chapter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChapterToCBZ_EmptyChapter(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "empty.cbz")
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(tmpDir, "source.cbz"),
|
||||
TempDir: tmpDir,
|
||||
Pages: []*manga.PageFile{},
|
||||
}
|
||||
|
||||
err := WriteChapterToCBZ(chapter, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Should not error on empty chapter: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file is a valid zip
|
||||
r, err := zip.OpenReader(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open output CBZ: %v", err)
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
|
||||
func TestWriteChapterToCBZ_WithComicInfo(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "output.cbz")
|
||||
inputDir := filepath.Join(tmpDir, "input")
|
||||
_ = os.MkdirAll(inputDir, 0755)
|
||||
|
||||
// Create a dummy page file
|
||||
pagePath := filepath.Join(inputDir, "0000.jpg")
|
||||
_ = os.WriteFile(pagePath, []byte("fake image data"), 0644)
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(tmpDir, "source.cbz"),
|
||||
TempDir: tmpDir,
|
||||
ComicInfoXml: `<?xml version="1.0"?><ComicInfo><Series>Test</Series></ComicInfo>`,
|
||||
Pages: []*manga.PageFile{
|
||||
{Index: 0, Extension: ".jpg", FilePath: pagePath},
|
||||
},
|
||||
}
|
||||
chapter.SetConverted()
|
||||
|
||||
err := WriteChapterToCBZ(chapter, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write chapter: %v", err)
|
||||
}
|
||||
|
||||
// Verify ComicInfo.xml is present
|
||||
r, err := zip.OpenReader(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open output CBZ: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
foundComicInfo := false
|
||||
for _, f := range r.File {
|
||||
if f.Name == "ComicInfo.xml" {
|
||||
foundComicInfo = true
|
||||
}
|
||||
}
|
||||
if !foundComicInfo {
|
||||
t.Error("ComicInfo.xml not found in output CBZ")
|
||||
}
|
||||
|
||||
// Verify zip comment has converted timestamp
|
||||
if r.Comment == "" {
|
||||
t.Error("Expected zip comment with conversion timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChapterToCBZ_InvalidPagePath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "output.cbz")
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(tmpDir, "source.cbz"),
|
||||
TempDir: tmpDir,
|
||||
Pages: []*manga.PageFile{
|
||||
{Index: 0, Extension: ".jpg", FilePath: "/nonexistent/page.jpg"},
|
||||
},
|
||||
}
|
||||
|
||||
err := WriteChapterToCBZ(chapter, outputPath)
|
||||
if err == nil {
|
||||
t.Error("Expected error when page file does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyConverted_CBZWithConvertedTxt(t *testing.T) {
|
||||
// Create a CBZ with converted.txt inside (no zip comment)
|
||||
tmpDir := t.TempDir()
|
||||
cbzPath := filepath.Join(tmpDir, "test.cbz")
|
||||
|
||||
f, err := os.Create(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
// Add converted.txt with a date
|
||||
fw, err := w.Create("converted.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte(time.Now().Format(time.RFC3339)))
|
||||
// Add a dummy page
|
||||
fw, err = w.Create("page.webp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte("dummy"))
|
||||
_ = w.Close()
|
||||
_ = f.Close()
|
||||
|
||||
result, err := IsAlreadyConverted(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if !result {
|
||||
t.Error("Expected CBZ with converted.txt to be detected as converted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyConverted_CBZWithInvalidConvertedTxt(t *testing.T) {
|
||||
// Create a CBZ with converted.txt that has invalid date
|
||||
tmpDir := t.TempDir()
|
||||
cbzPath := filepath.Join(tmpDir, "test.cbz")
|
||||
|
||||
f, err := os.Create(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
fw, err := w.Create("converted.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte("not a valid date"))
|
||||
_ = w.Close()
|
||||
_ = f.Close()
|
||||
|
||||
result, err := IsAlreadyConverted(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if result {
|
||||
t.Error("Expected CBZ with invalid date in converted.txt to NOT be detected as converted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_WithConvertedTxt(t *testing.T) {
|
||||
// Create a CBZ with converted.txt inside
|
||||
tmpDir := t.TempDir()
|
||||
cbzPath := filepath.Join(tmpDir, "test.cbz")
|
||||
|
||||
f, err := os.Create(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := zip.NewWriter(f)
|
||||
fw, err := w.Create("converted.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convertedTime := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||
_, _ = fw.Write([]byte(convertedTime.Format(time.RFC3339)))
|
||||
fw, err = w.Create("page001.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte("fake image"))
|
||||
_ = w.Close()
|
||||
_ = f.Close()
|
||||
|
||||
chapter, err := ExtractChapter(cbzPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract: %v", err)
|
||||
}
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
if !chapter.IsConverted {
|
||||
t.Error("Expected chapter with converted.txt to be marked as converted")
|
||||
}
|
||||
if chapter.ConvertedTime.IsZero() {
|
||||
t.Error("Expected non-zero ConvertedTime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChapterToCBZ_MultiplePages(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "output.cbz")
|
||||
inputDir := filepath.Join(tmpDir, "input")
|
||||
_ = os.MkdirAll(inputDir, 0755)
|
||||
|
||||
// Create multiple dummy page files
|
||||
var pages []*manga.PageFile
|
||||
for i := 0; i < 5; i++ {
|
||||
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.webp", i))
|
||||
_ = os.WriteFile(pagePath, []byte(fmt.Sprintf("page %d content", i)), 0644)
|
||||
pages = append(pages, &manga.PageFile{
|
||||
Index: uint16(i),
|
||||
Extension: ".webp",
|
||||
FilePath: pagePath,
|
||||
})
|
||||
}
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(tmpDir, "source.cbz"),
|
||||
TempDir: tmpDir,
|
||||
Pages: pages,
|
||||
}
|
||||
chapter.SetConverted()
|
||||
|
||||
err := WriteChapterToCBZ(chapter, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write: %v", err)
|
||||
}
|
||||
|
||||
// Verify archive contents
|
||||
r, err := zip.OpenReader(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
// Should have 5 pages = 5 files (conversion status stored in zip comment, not as file)
|
||||
expectedFiles := 5
|
||||
if len(r.File) != expectedFiles {
|
||||
t.Errorf("Expected %d files in archive, got %d", expectedFiles, len(r.File))
|
||||
}
|
||||
|
||||
// Verify zip comment is set
|
||||
if r.Comment == "" {
|
||||
t.Error("Expected zip comment for converted chapter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteChapterToCBZ_InvalidOutputPath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
inputDir := filepath.Join(tmpDir, "input")
|
||||
_ = os.MkdirAll(inputDir, 0755)
|
||||
|
||||
pagePath := filepath.Join(inputDir, "0000.webp")
|
||||
_ = os.WriteFile(pagePath, []byte("content"), 0644)
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(tmpDir, "source.cbz"),
|
||||
TempDir: tmpDir,
|
||||
Pages: []*manga.PageFile{
|
||||
{Index: 0, Extension: ".webp", FilePath: pagePath},
|
||||
},
|
||||
}
|
||||
|
||||
err := WriteChapterToCBZ(chapter, "/nonexistent/dir/output.cbz")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid output path")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-14
@@ -6,38 +6,36 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chapter represents a comic book chapter with pages stored on disk.
|
||||
type Chapter struct {
|
||||
// FilePath is the path to the chapter's directory.
|
||||
// FilePath is the path to the original archive file.
|
||||
FilePath string
|
||||
// Pages is a slice of pointers to Page objects.
|
||||
Pages []*Page
|
||||
// ComicInfo is a string containing information about the chapter.
|
||||
// Pages is a slice of page files on disk.
|
||||
Pages []*PageFile
|
||||
// ComicInfoXml holds the ComicInfo.xml content (small, kept in memory).
|
||||
ComicInfoXml string
|
||||
// IsConverted is a boolean that indicates whether the chapter has been converted.
|
||||
// IsConverted indicates whether the chapter has already been converted.
|
||||
IsConverted bool
|
||||
// ConvertedTime is a pointer to a time.Time object that indicates when the chapter was converted. Nil mean not converted.
|
||||
// ConvertedTime is when the chapter was converted.
|
||||
ConvertedTime time.Time
|
||||
// TempDir, when non-empty, is a staging temp folder holding converted
|
||||
// page contents on disk (see Page.TempFilePath) rather than fully in
|
||||
// memory. It should be removed via Cleanup once the chapter has been
|
||||
// written out (or is no longer needed).
|
||||
// TempDir is the root temp directory for this chapter's extracted/converted files.
|
||||
// Cleanup removes this entire directory.
|
||||
TempDir string
|
||||
}
|
||||
|
||||
// SetConverted sets the IsConverted field to true and sets the ConvertedTime field to the current time.
|
||||
// SetConverted marks the chapter as converted with the current timestamp.
|
||||
func (chapter *Chapter) SetConverted() {
|
||||
chapter.IsConverted = true
|
||||
chapter.ConvertedTime = time.Now()
|
||||
}
|
||||
|
||||
// Cleanup removes the chapter's staging temp folder (if any), releasing any
|
||||
// page contents that were staged to disk during conversion.
|
||||
// Cleanup removes the chapter's temp directory and all extracted/converted files.
|
||||
func (chapter *Chapter) Cleanup() error {
|
||||
if chapter.TempDir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(chapter.TempDir); err != nil {
|
||||
return fmt.Errorf("failed to remove staging temp folder: %w", err)
|
||||
return fmt.Errorf("failed to remove temp directory: %w", err)
|
||||
}
|
||||
chapter.TempDir = ""
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package manga
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChapter_SetConverted(t *testing.T) {
|
||||
chapter := &Chapter{}
|
||||
|
||||
if chapter.IsConverted {
|
||||
t.Error("New chapter should not be converted")
|
||||
}
|
||||
if !chapter.ConvertedTime.IsZero() {
|
||||
t.Error("New chapter should have zero ConvertedTime")
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
chapter.SetConverted()
|
||||
after := time.Now()
|
||||
|
||||
if !chapter.IsConverted {
|
||||
t.Error("Chapter should be converted after SetConverted()")
|
||||
}
|
||||
if chapter.ConvertedTime.Before(before) || chapter.ConvertedTime.After(after) {
|
||||
t.Error("ConvertedTime should be between before and after")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapter_Cleanup(t *testing.T) {
|
||||
// Create a temp dir with some files
|
||||
tmpDir := t.TempDir()
|
||||
chapterDir := filepath.Join(tmpDir, "chapter-cleanup-test")
|
||||
if err := os.MkdirAll(filepath.Join(chapterDir, "input"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(chapterDir, "input", "page.jpg"), []byte("test"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chapter := &Chapter{TempDir: chapterDir}
|
||||
|
||||
err := chapter.Cleanup()
|
||||
if err != nil {
|
||||
t.Fatalf("Cleanup failed: %v", err)
|
||||
}
|
||||
|
||||
if chapter.TempDir != "" {
|
||||
t.Error("TempDir should be empty after cleanup")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(chapterDir); !os.IsNotExist(err) {
|
||||
t.Error("Directory should not exist after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapter_Cleanup_EmptyTempDir(t *testing.T) {
|
||||
chapter := &Chapter{TempDir: ""}
|
||||
|
||||
err := chapter.Cleanup()
|
||||
if err != nil {
|
||||
t.Errorf("Cleanup with empty TempDir should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapter_Cleanup_NonexistentDir(t *testing.T) {
|
||||
chapter := &Chapter{TempDir: "/nonexistent/path/that/does/not/exist"}
|
||||
|
||||
// os.RemoveAll on a nonexistent path returns nil
|
||||
err := chapter.Cleanup()
|
||||
if err != nil {
|
||||
t.Errorf("Cleanup of nonexistent dir should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageFile_Struct(t *testing.T) {
|
||||
page := &PageFile{
|
||||
Index: 5,
|
||||
Extension: ".webp",
|
||||
FilePath: "/tmp/test/0005.webp",
|
||||
IsSplitted: true,
|
||||
SplitPartIndex: 2,
|
||||
}
|
||||
|
||||
if page.Index != 5 {
|
||||
t.Errorf("Expected Index 5, got %d", page.Index)
|
||||
}
|
||||
if page.Extension != ".webp" {
|
||||
t.Errorf("Expected Extension .webp, got %s", page.Extension)
|
||||
}
|
||||
if page.FilePath != "/tmp/test/0005.webp" {
|
||||
t.Errorf("Expected FilePath /tmp/test/0005.webp, got %s", page.FilePath)
|
||||
}
|
||||
if !page.IsSplitted {
|
||||
t.Error("Expected IsSplitted true")
|
||||
}
|
||||
if page.SplitPartIndex != 2 {
|
||||
t.Errorf("Expected SplitPartIndex 2, got %d", page.SplitPartIndex)
|
||||
}
|
||||
}
|
||||
+13
-83
@@ -1,86 +1,16 @@
|
||||
package manga
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type Page struct {
|
||||
// Index of the page in the chapter.
|
||||
Index uint16 `json:"index" jsonschema:"description=Index of the page in the chapter."`
|
||||
// Extension of the page image.
|
||||
Extension string `json:"extension" jsonschema:"description=Extension of the page image."`
|
||||
// Size of the page in bytes
|
||||
Size uint64 `json:"-"`
|
||||
// Contents of the page. Nil when the page contents have been staged to
|
||||
// disk (see TempFilePath) to bound memory usage.
|
||||
Contents *bytes.Buffer `json:"-"`
|
||||
// TempFilePath, when non-empty, points to a file on disk (in a staging
|
||||
// temp folder) holding the page contents instead of keeping them fully
|
||||
// in memory. Use Open() to transparently read the page contents
|
||||
// regardless of where they are stored.
|
||||
TempFilePath string `json:"-"`
|
||||
// IsSplitted tell us if the page was cropped to multiple pieces
|
||||
IsSplitted bool `json:"is_cropped" jsonschema:"description=Was this page cropped."`
|
||||
// SplitPartIndex represent the index of the crop if the page was cropped
|
||||
SplitPartIndex uint16 `json:"crop_part_index" jsonschema:"description=Index of the crop if the image was cropped."`
|
||||
}
|
||||
|
||||
// Open returns a reader for the page contents, transparently handling
|
||||
// whether the contents are held in memory (Contents) or staged on disk
|
||||
// (TempFilePath). The caller is responsible for closing the returned
|
||||
// reader.
|
||||
func (page *Page) Open() (io.ReadCloser, error) {
|
||||
if page.TempFilePath != "" {
|
||||
file, err := os.Open(page.TempFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open staged page contents: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
if page.Contents == nil {
|
||||
return nil, fmt.Errorf("page has no contents: neither TempFilePath nor Contents is set")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(page.Contents.Bytes())), nil
|
||||
}
|
||||
|
||||
// Stage writes the given content to a file in tempDir instead of keeping it
|
||||
// in memory, updating Extension, Size and TempFilePath accordingly and
|
||||
// clearing Contents. This is used after converting a page so that only the
|
||||
// pages currently being processed are held in memory, bounding memory usage
|
||||
// for chapters with many/large pages.
|
||||
func (page *Page) Stage(tempDir string, content *bytes.Buffer, extension string) error {
|
||||
file, err := os.CreateTemp(tempDir, "page-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create staging file: %w", err)
|
||||
}
|
||||
fileName := file.Name()
|
||||
|
||||
written, writeErr := file.Write(content.Bytes())
|
||||
closeErr := file.Close()
|
||||
if writeErr == nil && closeErr == nil && written == content.Len() {
|
||||
page.TempFilePath = fileName
|
||||
page.Extension = extension
|
||||
page.Size = uint64(written)
|
||||
page.Contents = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Something went wrong: remove the incomplete/partial staging file
|
||||
// rather than leaving corrupted data behind on disk.
|
||||
if removeErr := os.Remove(fileName); removeErr != nil {
|
||||
log.Warn().Str("file", fileName).Err(removeErr).Msg("Failed to remove incomplete staging file")
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
return fmt.Errorf("failed to write staging file: %w", writeErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("failed to close staging file: %w", closeErr)
|
||||
}
|
||||
return fmt.Errorf("short write to staging file: wrote %d of %d bytes", written, content.Len())
|
||||
// PageFile represents a single page image stored on disk.
|
||||
// No image data is held in memory — only metadata and a file path.
|
||||
type PageFile struct {
|
||||
// Index of the page in the chapter (original ordering).
|
||||
Index uint16
|
||||
// Extension of the page image file (e.g., ".webp", ".jpg").
|
||||
Extension string
|
||||
// FilePath is the absolute path to the image file on disk.
|
||||
FilePath string
|
||||
// IsSplitted indicates whether this page was split from a larger image.
|
||||
IsSplitted bool
|
||||
// SplitPartIndex is the part index when the page was split.
|
||||
SplitPartIndex uint16
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package manga
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
)
|
||||
|
||||
// PageContainer is a struct that holds a manga page, its image, and the image format.
|
||||
type PageContainer struct {
|
||||
// Page is a pointer to a manga page object.
|
||||
Page *Page
|
||||
// Image is the decoded image of the manga page.
|
||||
Image image.Image
|
||||
// Format is a string representing the format of the image (e.g., "png", "jpeg", "webp").
|
||||
Format string
|
||||
// IsToBeConverted is a boolean flag indicating whether the image needs to be converted to another format.
|
||||
IsToBeConverted bool
|
||||
// HasBeenConverted is a boolean flag indicating whether the image has been converted to another format.
|
||||
HasBeenConverted bool
|
||||
}
|
||||
|
||||
func NewContainer(Page *Page, img image.Image, format string, isToBeConverted bool) *PageContainer {
|
||||
return &PageContainer{Page: Page, Image: img, Format: format, IsToBeConverted: isToBeConverted, HasBeenConverted: false}
|
||||
}
|
||||
|
||||
// SetConverted sets the converted image, its extension, and its size in the PageContainer.
|
||||
func (pc *PageContainer) SetConverted(converted *bytes.Buffer, extension string) {
|
||||
pc.Page.Contents = converted
|
||||
pc.Page.Extension = extension
|
||||
pc.Page.Size = uint64(converted.Len())
|
||||
pc.HasBeenConverted = true
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestCapture(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error = tt.initial
|
||||
var err = tt.initial
|
||||
Capture(&err, tt.errFunc, tt.msg)
|
||||
if err != nil && err.Error() != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, err.Error())
|
||||
@@ -110,7 +110,7 @@ func TestCaptureGeneric(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error = tt.initial
|
||||
var err = tt.initial
|
||||
CaptureGeneric(&err, tt.errFunc, tt.value, tt.msg)
|
||||
if err != nil && err.Error() != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, err.Error())
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsValidFolder(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T) string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid directory",
|
||||
setup: func(t *testing.T) string {
|
||||
return t.TempDir()
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "file not directory",
|
||||
setup: func(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nonexistent path",
|
||||
setup: func(t *testing.T) string {
|
||||
return "/nonexistent/path/that/does/not/exist"
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
setup: func(t *testing.T) string {
|
||||
return ""
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := tt.setup(t)
|
||||
result := IsValidFolder(path)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsValidFolder(%q) = %v, want %v", path, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+33
-54
@@ -25,6 +25,12 @@ type OptimizeOptions struct {
|
||||
}
|
||||
|
||||
// Optimize optimizes a CBZ/CBR file using the specified converter.
|
||||
// The new pipeline is disk-first:
|
||||
// 1. Fast check if already converted (no extraction)
|
||||
// 2. Extract archive to temp directory on disk
|
||||
// 3. Convert pages file-to-file (no image data in memory)
|
||||
// 4. Create output CBZ by streaming from disk
|
||||
// 5. Cleanup temp files
|
||||
func Optimize(options *OptimizeOptions) error {
|
||||
log.Info().Str("file", options.Path).Msg("Processing file")
|
||||
log.Debug().
|
||||
@@ -34,38 +40,47 @@ func Optimize(options *OptimizeOptions) error {
|
||||
Bool("split", options.Split).
|
||||
Msg("Optimization parameters")
|
||||
|
||||
// Load the chapter
|
||||
log.Debug().Str("file", options.Path).Msg("Loading chapter")
|
||||
chapter, err := cbz.LoadChapter(options.Path)
|
||||
// Step 1: Fast conversion check before extracting (new requirement)
|
||||
alreadyConverted, err := cbz.IsAlreadyConverted(options.Path)
|
||||
if err != nil {
|
||||
log.Error().Str("file", options.Path).Err(err).Msg("Failed to load chapter")
|
||||
log.Debug().Str("file", options.Path).Err(err).Msg("Conversion check failed, proceeding with extraction")
|
||||
}
|
||||
if alreadyConverted {
|
||||
log.Info().Str("file", options.Path).Msg("Chapter already converted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Step 2: Extract chapter to disk
|
||||
log.Debug().Str("file", options.Path).Msg("Extracting chapter")
|
||||
chapter, err := cbz.ExtractChapter(options.Path)
|
||||
if err != nil {
|
||||
log.Error().Str("file", options.Path).Err(err).Msg("Failed to extract chapter")
|
||||
return fmt.Errorf("failed to load chapter: %v", err)
|
||||
}
|
||||
log.Debug().
|
||||
Str("file", options.Path).
|
||||
Int("pages", len(chapter.Pages)).
|
||||
Bool("converted", chapter.IsConverted).
|
||||
Msg("Chapter loaded successfully")
|
||||
defer func() {
|
||||
if cleanupErr := chapter.Cleanup(); cleanupErr != nil {
|
||||
log.Warn().Str("file", options.Path).Err(cleanupErr).Msg("Failed to cleanup temp directory")
|
||||
}
|
||||
}()
|
||||
|
||||
// Double-check conversion status from extracted metadata
|
||||
if chapter.IsConverted {
|
||||
log.Info().Str("file", options.Path).Msg("Chapter already converted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert the chapter
|
||||
log.Debug().
|
||||
Str("file", chapter.FilePath).
|
||||
Str("file", options.Path).
|
||||
Int("pages", len(chapter.Pages)).
|
||||
Uint8("quality", options.Quality).
|
||||
Bool("split", options.Split).
|
||||
Msg("Starting chapter conversion")
|
||||
Msg("Chapter extracted successfully")
|
||||
|
||||
// Step 3: Convert pages file-to-file
|
||||
var ctx context.Context
|
||||
if options.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), options.Timeout)
|
||||
defer cancel()
|
||||
log.Debug().Str("file", chapter.FilePath).Dur("timeout", options.Timeout).Msg("Applying timeout to chapter conversion")
|
||||
log.Debug().Str("file", options.Path).Dur("timeout", options.Timeout).Msg("Applying timeout")
|
||||
} else {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -91,82 +106,47 @@ func Optimize(options *OptimizeOptions) error {
|
||||
return fmt.Errorf("failed to convert chapter")
|
||||
}
|
||||
|
||||
// Clean up the staging temp folder (if any) used to hold converted page
|
||||
// contents on disk once we're done with the chapter, regardless of
|
||||
// success or failure below.
|
||||
defer func() {
|
||||
if cleanupErr := convertedChapter.Cleanup(); cleanupErr != nil {
|
||||
log.Warn().Str("file", chapter.FilePath).Err(cleanupErr).Msg("Failed to remove staging temp folder")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Debug().
|
||||
Str("file", chapter.FilePath).
|
||||
Int("original_pages", len(chapter.Pages)).
|
||||
Int("converted_pages", len(convertedChapter.Pages)).
|
||||
Msg("Chapter conversion completed")
|
||||
|
||||
convertedChapter.SetConverted()
|
||||
|
||||
// Determine output path and handle CBR override logic
|
||||
log.Debug().
|
||||
Str("input_path", options.Path).
|
||||
Bool("override", options.Override).
|
||||
Msg("Determining output path")
|
||||
|
||||
// Step 4: Determine output path
|
||||
outputPath := options.Path
|
||||
originalPath := options.Path
|
||||
isCbrOverride := false
|
||||
|
||||
if options.Override {
|
||||
// For override mode, check if it's a CBR file that needs to be converted to CBZ
|
||||
pathLower := strings.ToLower(options.Path)
|
||||
if strings.HasSuffix(pathLower, ".cbr") {
|
||||
// Convert CBR to CBZ: change extension and mark for deletion
|
||||
outputPath = strings.TrimSuffix(options.Path, filepath.Ext(options.Path)) + ".cbz"
|
||||
isCbrOverride = true
|
||||
log.Debug().
|
||||
Str("original_path", originalPath).
|
||||
Str("output_path", outputPath).
|
||||
Msg("CBR to CBZ conversion: will delete original after conversion")
|
||||
} else {
|
||||
log.Debug().
|
||||
Str("original_path", originalPath).
|
||||
Str("output_path", outputPath).
|
||||
Msg("CBZ override mode: will overwrite original file")
|
||||
}
|
||||
} else {
|
||||
// Handle both .cbz and .cbr files - strip the extension and add _converted.cbz
|
||||
pathLower := strings.ToLower(options.Path)
|
||||
if strings.HasSuffix(pathLower, ".cbz") {
|
||||
outputPath = strings.TrimSuffix(options.Path, ".cbz") + "_converted.cbz"
|
||||
} else if strings.HasSuffix(pathLower, ".cbr") {
|
||||
outputPath = strings.TrimSuffix(options.Path, ".cbr") + "_converted.cbz"
|
||||
} else {
|
||||
// Fallback for other extensions - just add _converted.cbz
|
||||
outputPath = options.Path + "_converted.cbz"
|
||||
}
|
||||
log.Debug().
|
||||
Str("original_path", originalPath).
|
||||
Str("output_path", outputPath).
|
||||
Msg("Non-override mode: creating converted file alongside original")
|
||||
}
|
||||
|
||||
// Write the converted chapter to CBZ file
|
||||
// Step 5: Write converted chapter to CBZ (streaming from disk)
|
||||
log.Debug().Str("output_path", outputPath).Msg("Writing converted chapter to CBZ file")
|
||||
err = cbz.WriteChapterToCBZ(convertedChapter, outputPath)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputPath).Err(err).Msg("Failed to write converted chapter")
|
||||
return fmt.Errorf("failed to write converted chapter: %v", err)
|
||||
}
|
||||
log.Debug().Str("output_path", outputPath).Msg("Successfully wrote converted chapter")
|
||||
|
||||
// If we're overriding a CBR file, delete the original CBR after successful write
|
||||
// If overriding a CBR file, delete the original
|
||||
if isCbrOverride {
|
||||
log.Debug().Str("file", originalPath).Msg("Attempting to delete original CBR file")
|
||||
err = os.Remove(originalPath)
|
||||
if err != nil {
|
||||
// Log the error but don't fail the operation since conversion succeeded
|
||||
log.Warn().Str("file", originalPath).Err(err).Msg("Failed to delete original CBR file")
|
||||
} else {
|
||||
log.Info().Str("file", originalPath).Msg("Deleted original CBR file")
|
||||
@@ -175,5 +155,4 @@ func Optimize(options *OptimizeOptions) error {
|
||||
|
||||
log.Info().Str("output", outputPath).Msg("Converted file written")
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ func TestOptimizeIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
// Clean up output file
|
||||
os.Remove(expectedOutput)
|
||||
_ = os.Remove(expectedOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func TestOptimizeIntegration_Timeout(t *testing.T) {
|
||||
Quality: 85,
|
||||
Override: false,
|
||||
Split: false,
|
||||
Timeout: 10 * time.Millisecond, // Very short timeout to force timeout
|
||||
Timeout: 1 * time.Nanosecond, // Extremely short timeout to force timeout
|
||||
}
|
||||
|
||||
err = Optimize(options)
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestOptimize(t *testing.T) {
|
||||
}
|
||||
|
||||
// Clean up output file
|
||||
os.Remove(expectedOutput)
|
||||
_ = os.Remove(expectedOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user