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:
copilot-swe-agent[bot]
2026-07-04 22:18:22 +00:00
committed by GitHub
co-authored by Belphemur
parent e87e73eb28
commit 164dc577b9
21 changed files with 1735 additions and 1386 deletions
+485 -1
View File
@@ -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")
}
}