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
+12 -14
View File
@@ -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
+102
View File
@@ -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
View File
@@ -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
}
-32
View File
@@ -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
}