fix(memory): use staging folder to avoid keeping everything in memory

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

Fixes #203
This commit is contained in:
Copilot
2026-07-03 09:15:56 -04:00
committed by GitHub
parent f2a65425cf
commit c2f809ed2c
13 changed files with 352 additions and 33 deletions
+1
View File
@@ -0,0 +1 @@
testdata/large/*.cbz filter=lfs diff=lfs merge=lfs -text
+12 -2
View File
@@ -95,6 +95,15 @@ go test -v ./pkg/converter/...
go test -v ./internal/utils/... go test -v ./internal/utils/...
``` ```
A large-file integration test (`TestOptimizeIntegration_LargeFile`) exercises the
optimize pipeline against a ~1GB CBZ fixture stored via Git LFS
(`testdata/large/large_chapter.cbz`, tracked in `.gitattributes`). It is skipped
by default; fetch the fixture with `git lfs pull` and opt in with:
```bash
CBZ_RUN_LARGE_FILE_TEST=1 go test -v ./internal/utils/... -run TestOptimizeIntegration_LargeFile
```
### Linting ### Linting
```bash ```bash
@@ -281,8 +290,9 @@ Releases are automated via goreleaser:
## Performance Considerations ## Performance Considerations
- **Parallelism:** Use `--parallelism` flag to control concurrent chapter processing - **Parallelism:** Use `--parallelism` flag to control concurrent chapter processing. Regardless of this value, the total number of pages converted at the same time (i.e. concurrent `cwebp` processes) is capped process-wide to the number of CPU cores, so raising parallelism spreads that budget across more chapters instead of multiplying resource usage.
- **Memory:** Large images are processed in-memory; consider system RAM when setting parallelism - **Memory:** Original page images are loaded in memory, but converted pages are staged to a temporary folder on disk as soon as they're produced (see `manga.Page.TempFilePath` / `manga.Chapter.TempDir`) instead of being held in memory until the whole chapter is written out. The staging folder is cleaned up once the chapter has been written to its final CBZ file.
- **Disk:** Since converted pages are staged on disk, ensure enough free space (roughly the size of the converted chapter) is available in the OS temp directory.
- **Timeouts:** Use `--timeout` flag to prevent hanging on problematic files - **Timeouts:** Use `--timeout` flag to prevent hanging on problematic files
- **WebP Quality:** Balance quality (0-100) vs file size; default is 85 - **WebP Quality:** Balance quality (0-100) vs file size; default is 85
+1 -1
View File
@@ -87,7 +87,7 @@ docker run -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest watc
### Flags ### Flags
- `--quality`, `-q`: Quality for conversion (0-100). Default is 85. - `--quality`, `-q`: Quality for conversion (0-100). Default is 85.
- `--parallelism`, `-n`: Number of chapters to convert in parallel. Default is 2. - `--parallelism`, `-n`: Number of chapters to convert in parallel. Default is 2. Regardless of this value, the total number of pages converted at the same time (i.e. concurrent `cwebp` processes) is capped to the number of CPU cores, so increasing parallelism spreads that budget across more chapters rather than multiplying resource usage.
- `--override`, `-o`: Override the original files. For CBZ files, overwrites the original. For CBR files, deletes the original CBR and creates a new CBZ. Default is false. - `--override`, `-o`: Override the original files. For CBZ files, overwrites the original. For CBR files, deletes the original CBR and creates a new CBZ. Default is false.
- `--split`, `-s`: Split long pages into smaller chunks. Default is false. - `--split`, `-s`: Split long pages into smaller chunks. Default is false.
- `--format`, `-f`: Format to convert the images to (currently supports: webp). Default is webp. - `--format`, `-f`: Format to convert the images to (currently supports: webp). Default is webp.
-2
View File
@@ -52,8 +52,6 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+18 -4
View File
@@ -3,6 +3,7 @@ package cbz
import ( import (
"archive/zip" "archive/zip"
"fmt" "fmt"
"io"
"os" "os"
"time" "time"
@@ -56,7 +57,7 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
Bool("is_splitted", page.IsSplitted). Bool("is_splitted", page.IsSplitted).
Uint16("split_part", page.SplitPartIndex). Uint16("split_part", page.SplitPartIndex).
Str("filename", fileName). Str("filename", fileName).
Int("size", len(page.Contents.Bytes())). Uint64("size", page.Size).
Msg("Writing page to CBZ archive") Msg("Writing page to CBZ archive")
// Create a new file in the ZIP archive // Create a new file in the ZIP archive
@@ -70,17 +71,30 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
return fmt.Errorf("failed to create file in .cbz: %w", err) return fmt.Errorf("failed to create file in .cbz: %w", err)
} }
// Write the page contents to the file // Stream the page contents into the archive. This transparently
bytesWritten, err := fileWriter.Write(page.Contents.Bytes()) // 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()
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)
}
bytesWritten, err := io.Copy(fileWriter, pageReader)
closeErr := pageReader.Close()
if err != nil { if err != nil {
log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(err).Msg("Failed to write page contents") log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(err).Msg("Failed to write page contents")
return fmt.Errorf("failed to write page contents: %w", err) 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.Debug(). log.Debug().
Str("output_path", outputFilePath). Str("output_path", outputFilePath).
Str("filename", fileName). Str("filename", fileName).
Int("bytes_written", bytesWritten). Int64("bytes_written", bytesWritten).
Msg("Page written successfully") Msg("Page written successfully")
} }
+23 -1
View File
@@ -1,6 +1,10 @@
package manga package manga
import "time" import (
"fmt"
"os"
"time"
)
type Chapter struct { type Chapter struct {
// FilePath is the path to the chapter's directory. // FilePath is the path to the chapter's directory.
@@ -13,6 +17,11 @@ type Chapter struct {
IsConverted bool IsConverted bool
// ConvertedTime is a pointer to a time.Time object that indicates when the chapter was converted. Nil mean not converted. // ConvertedTime is a pointer to a time.Time object that indicates when the chapter was converted. Nil mean not converted.
ConvertedTime time.Time 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 string
} }
// SetConverted sets the IsConverted field to true and sets the ConvertedTime field to the current time. // SetConverted sets the IsConverted field to true and sets the ConvertedTime field to the current time.
@@ -20,3 +29,16 @@ func (chapter *Chapter) SetConverted() {
chapter.IsConverted = true chapter.IsConverted = true
chapter.ConvertedTime = time.Now() 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.
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)
}
chapter.TempDir = ""
return nil
}
+70 -2
View File
@@ -1,6 +1,13 @@
package manga package manga
import "bytes" import (
"bytes"
"fmt"
"io"
"os"
"github.com/rs/zerolog/log"
)
type Page struct { type Page struct {
// Index of the page in the chapter. // Index of the page in the chapter.
@@ -9,10 +16,71 @@ type Page struct {
Extension string `json:"extension" jsonschema:"description=Extension of the page image."` Extension string `json:"extension" jsonschema:"description=Extension of the page image."`
// Size of the page in bytes // Size of the page in bytes
Size uint64 `json:"-"` Size uint64 `json:"-"`
// Contents of the page // Contents of the page. Nil when the page contents have been staged to
// disk (see TempFilePath) to bound memory usage.
Contents *bytes.Buffer `json:"-"` 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 tell us if the page was cropped to multiple pieces
IsSplitted bool `json:"is_cropped" jsonschema:"description=Was this page cropped."` IsSplitted bool `json:"is_cropped" jsonschema:"description=Was this page cropped."`
// SplitPartIndex represent the index of the crop if the page was 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."` 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())
}
+9
View File
@@ -91,6 +91,15 @@ func Optimize(options *OptimizeOptions) error {
return fmt.Errorf("failed to convert chapter") 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(). log.Debug().
Str("file", chapter.FilePath). Str("file", chapter.FilePath).
Int("original_pages", len(chapter.Pages)). Int("original_pages", len(chapter.Pages)).
@@ -51,6 +51,13 @@ func TestOptimizeIntegration(t *testing.T) {
if err != nil { if err != nil {
return err return err
} }
// Skip the "large" fixtures directory: it holds the Git LFS-tracked
// fixture used exclusively by TestOptimizeIntegration_LargeFile,
// which may only be a small LFS pointer file if the content wasn't
// fetched, and shouldn't be exercised by this generic test.
if info.IsDir() && filepath.Base(path) == "large" {
return filepath.SkipDir
}
if !info.IsDir() { if !info.IsDir() {
fileName := strings.ToLower(info.Name()) fileName := strings.ToLower(info.Name())
if (strings.HasSuffix(fileName, ".cbz") || strings.HasSuffix(fileName, ".cbr")) && !strings.Contains(fileName, "converted") { if (strings.HasSuffix(fileName, ".cbz") || strings.HasSuffix(fileName, ".cbr")) && !strings.Contains(fileName, "converted") {
@@ -0,0 +1,121 @@
package utils
import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/rs/zerolog/log"
)
// largeTestFile is a ~1GB synthetic CBZ fixture stored via Git LFS (see
// .gitattributes). It is used to exercise the optimize pipeline with a
// chapter large enough to make in-memory-only handling impractical, and to
// validate that converted pages are streamed to/from a staging temp folder
// (see manga.Page.TempFilePath / manga.Chapter.TempDir) instead of blowing
// up memory usage.
const largeTestFile = "../../testdata/large/large_chapter.cbz"
// TestOptimizeIntegration_LargeFile is opt-in (set CBZ_RUN_LARGE_FILE_TEST=1)
// since it processes a ~1GB fixture and can take a while to run. It is
// automatically skipped if the fixture is unavailable (e.g. Git LFS content
// wasn't fetched, leaving only a pointer file) or in short mode.
func TestOptimizeIntegration_LargeFile(t *testing.T) {
if testing.Short() {
t.Skip("Skipping large file integration test in short mode")
}
if os.Getenv("CBZ_RUN_LARGE_FILE_TEST") == "" {
t.Skip("Skipping large file integration test; set CBZ_RUN_LARGE_FILE_TEST=1 to run it")
}
info, err := os.Stat(largeTestFile)
if err != nil {
t.Skipf("large test fixture not found: %v", err)
}
// If Git LFS content wasn't fetched (e.g. `actions/checkout` without
// `lfs: true`), the file on disk is just a small pointer text file
// rather than the real ~1GB fixture. Detect and skip gracefully instead
// of failing the whole suite.
const minExpectedSize = 500 * 1024 * 1024 // 500MB
if info.Size() < minExpectedSize {
t.Skipf("large test fixture looks like a Git LFS pointer (size=%d), skipping; run `git lfs pull`", info.Size())
}
tempDir, err := os.MkdirTemp("", "test_optimize_large_file")
if err != nil {
t.Fatal(err)
}
defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory")
converterInstance, err := converter.Get(constant.WebP)
if err != nil {
t.Skip("WebP converter not available, skipping large file integration test")
}
if err := converterInstance.PrepareConverter(); err != nil {
t.Skip("Failed to prepare WebP converter, skipping large file integration test")
}
cbzFile := filepath.Join(tempDir, "large_chapter.cbz")
if err := copyFile(largeTestFile, cbzFile); err != nil {
t.Fatal(err)
}
var memBefore, memAfter runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&memBefore)
options := &OptimizeOptions{
ChapterConverter: converterInstance,
Path: cbzFile,
Quality: 85,
Override: false,
Split: true,
}
err = Optimize(options)
if err != nil {
t.Fatalf("failed to optimize large chapter: %v", err)
}
runtime.GC()
runtime.ReadMemStats(&memAfter)
log.Info().
Uint64("heap_alloc_before", memBefore.HeapAlloc).
Uint64("heap_alloc_after", memAfter.HeapAlloc).
Int64("input_size", info.Size()).
Msg("Large file integration test memory usage")
outputFile := strings.TrimSuffix(cbzFile, ".cbz") + "_converted.cbz"
if _, err := os.Stat(outputFile); err != nil {
t.Fatalf("expected converted output file %s to exist: %v", outputFile, err)
}
}
// copyFile copies src to dst using streaming file I/O so that the whole
// file content is never held in memory at once, which matters for the
// large fixture used by this test.
func copyFile(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer errs.Capture(&err, in.Close, "failed to close source file")
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("failed to create destination file: %w", err)
}
defer errs.Capture(&err, out.Close, "failed to close destination file")
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("failed to copy file contents: %w", err)
}
return nil
}
+73 -16
View File
@@ -9,6 +9,7 @@ import (
_ "image/gif" _ "image/gif"
_ "image/jpeg" _ "image/jpeg"
"image/png" "image/png"
"os"
"runtime" "runtime"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -28,6 +29,15 @@ type Converter struct {
maxHeight int maxHeight int
cropHeight int cropHeight int
isPrepared bool isPrepared bool
// pageWorkerGuard limits how many pages are converted (i.e. how many
// cwebp binary processes are spawned) concurrently across the whole
// application, regardless of how many chapters are being processed in
// parallel (see the --parallelism flag). Without this shared limit, the
// per-chapter worker pool (sized to runtime.NumCPU()) would be
// multiplied by the number of chapters processed concurrently, leading
// to CPU/memory oversubscription since every page conversion spawns an
// external cwebp process.
pageWorkerGuard chan struct{}
} }
func (converter *Converter) Format() (format constant.ConversionFormat) { func (converter *Converter) Format() (format constant.ConversionFormat) {
@@ -37,9 +47,10 @@ func (converter *Converter) Format() (format constant.ConversionFormat) {
func New() *Converter { func New() *Converter {
return &Converter{ return &Converter{
//maxHeight: 16383 / 2, //maxHeight: 16383 / 2,
maxHeight: 4000, maxHeight: 4000,
cropHeight: 2000, cropHeight: 2000,
isPrepared: false, isPrepared: false,
pageWorkerGuard: make(chan struct{}, runtime.NumCPU()),
} }
} }
@@ -70,6 +81,18 @@ func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.C
return nil, err return nil, err
} }
// Stage converted pages on disk in a temp folder instead of keeping them
// all in memory until the whole chapter is written out. This bounds
// memory usage for chapters with many/large pages; the caller is
// responsible for calling chapter.Cleanup() once the chapter has been
// written to its final CBZ file.
tempDir, err := os.MkdirTemp("", "cbzoptimizer-*")
if err != nil {
log.Error().Str("chapter", chapter.FilePath).Err(err).Msg("Failed to create staging temp folder")
return nil, fmt.Errorf("failed to create staging temp folder: %w", err)
}
chapter.TempDir = tempDir
var wgConvertedPages sync.WaitGroup var wgConvertedPages sync.WaitGroup
maxGoroutines := runtime.NumCPU() maxGoroutines := runtime.NumCPU()
@@ -80,7 +103,12 @@ func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.C
var wgPages sync.WaitGroup var wgPages sync.WaitGroup
wgPages.Add(len(chapter.Pages)) wgPages.Add(len(chapter.Pages))
guard := make(chan struct{}, maxGoroutines) // guard is shared across all chapters processed concurrently by this
// converter instance (see pageWorkerGuard doc comment) so that the total
// number of concurrently running cwebp processes stays bounded to
// runtime.NumCPU(), no matter how many chapters are being converted in
// parallel at the same time.
guard := converter.pageWorkerGuard
pagesMutex := sync.Mutex{} pagesMutex := sync.Mutex{}
var pages []*manga.Page var pages []*manga.Page
var totalPages = uint32(len(chapter.Pages)) var totalPages = uint32(len(chapter.Pages))
@@ -89,13 +117,25 @@ func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.C
Str("chapter", chapter.FilePath). Str("chapter", chapter.FilePath).
Int("total_pages", len(chapter.Pages)). Int("total_pages", len(chapter.Pages)).
Int("worker_count", maxGoroutines). Int("worker_count", maxGoroutines).
Str("staging_dir", tempDir).
Msg("Initialized conversion worker pool") Msg("Initialized conversion worker pool")
// failWithCleanup removes the staging temp folder before returning a
// fatal error for which no chapter is returned to the caller (and thus
// nobody else will clean it up).
failWithCleanup := func(err error) (*manga.Chapter, error) {
if removeErr := os.RemoveAll(tempDir); removeErr != nil {
log.Warn().Str("chapter", chapter.FilePath).Err(removeErr).Msg("Failed to remove staging temp folder after error")
}
chapter.TempDir = ""
return nil, err
}
// Check if context is already cancelled // Check if context is already cancelled
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout") log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
return nil, ctx.Err() return failWithCleanup(ctx.Err())
default: default:
} }
@@ -122,7 +162,7 @@ func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.C
default: default:
} }
convertedPage, err := converter.convertPage(pageToConvert, quality) convertedPage, err := converter.convertPage(pageToConvert, quality, tempDir)
if err != nil { if err != nil {
if convertedPage == nil { if convertedPage == nil {
select { select {
@@ -142,9 +182,14 @@ func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.C
} }
return return
} }
convertedPage.Page.Contents = buffer if err := convertedPage.Page.Stage(tempDir, buffer, ".png"); err != nil {
convertedPage.Page.Extension = ".png" select {
convertedPage.Page.Size = uint64(buffer.Len()) case errChan <- err:
case <-ctx.Done():
return
}
return
}
} }
pagesMutex.Lock() pagesMutex.Lock()
defer pagesMutex.Unlock() defer pagesMutex.Unlock()
@@ -160,7 +205,7 @@ func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.C
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout") log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
return nil, ctx.Err() return failWithCleanup(ctx.Err())
default: default:
} }
@@ -251,11 +296,13 @@ func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.C
// Conversion completed successfully // Conversion completed successfully
case <-ctx.Done(): case <-ctx.Done():
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout") log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
return nil, ctx.Err() return failWithCleanup(ctx.Err())
} }
close(errChan) close(errChan)
close(guard) // Note: guard is the converter's shared pageWorkerGuard and must not be
// closed here since it is reused by subsequent (and possibly concurrent)
// ConvertChapter calls.
var errList []error var errList []error
for err := range errChan { for err := range errChan {
@@ -402,7 +449,7 @@ func (converter *Converter) checkPageNeedsSplit(page *manga.Page, splitRequested
return needsSplit, img, format, nil return needsSplit, img, format, nil
} }
func (converter *Converter) convertPage(container *manga.PageContainer, quality uint8) (*manga.PageContainer, error) { func (converter *Converter) convertPage(container *manga.PageContainer, quality uint8, tempDir string) (*manga.PageContainer, error) {
log.Debug(). log.Debug().
Uint16("page_index", container.Page.Index). Uint16("page_index", container.Page.Index).
Str("format", container.Format). Str("format", container.Format).
@@ -439,12 +486,22 @@ func (converter *Converter) convertPage(container *manga.PageContainer, quality
return nil, err return nil, err
} }
container.SetConverted(converted, ".webp") originalSize := container.Page.Size
convertedSize := converted.Len()
if err := container.Page.Stage(tempDir, converted, ".webp"); err != nil {
log.Error().
Uint16("page_index", container.Page.Index).
Err(err).
Msg("Failed to stage converted page to disk")
return nil, err
}
container.HasBeenConverted = true
log.Debug(). log.Debug().
Uint16("page_index", container.Page.Index). Uint16("page_index", container.Page.Index).
Int("original_size", len(container.Page.Contents.Bytes())). Uint64("original_size", originalSize).
Int("converted_size", len(converted.Bytes())). Int("converted_size", convertedSize).
Msg("Page conversion completed") Msg("Page conversion completed")
return container, nil return container, nil
+14 -5
View File
@@ -9,6 +9,7 @@ import (
"image/gif" "image/gif"
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -86,11 +87,19 @@ func createTestPage(t *testing.T, index int, width, height int, format string) *
} }
func validateConvertedImage(t *testing.T, page *manga.Page) { func validateConvertedImage(t *testing.T, page *manga.Page) {
require.NotNil(t, page.Contents) require.True(t, page.Contents != nil || page.TempFilePath != "", "page should have contents in memory or staged on disk")
require.Greater(t, page.Contents.Len(), 0) require.Greater(t, page.Size, uint64(0))
reader, err := page.Open()
require.NoError(t, err)
defer reader.Close()
data, err := io.ReadAll(reader)
require.NoError(t, err)
require.Greater(t, len(data), 0)
// Try to decode the image // Try to decode the image
img, format, err := image.Decode(bytes.NewReader(page.Contents.Bytes())) img, format, err := image.Decode(bytes.NewReader(data))
require.NoError(t, err, "Failed to decode converted image") require.NoError(t, err, "Failed to decode converted image")
if page.Extension == ".webp" { if page.Extension == ".webp" {
@@ -291,7 +300,7 @@ func TestConverter_convertPage(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
container := manga.NewContainer(page, img, tt.format, tt.isToBeConverted) container := manga.NewContainer(page, img, tt.format, tt.isToBeConverted)
converted, err := converter.convertPage(container, 80) converted, err := converter.convertPage(container, 80, t.TempDir())
if tt.expectError { if tt.expectError {
assert.Error(t, err) assert.Error(t, err)
@@ -327,7 +336,7 @@ func TestConverter_convertPage_EncodingError(t *testing.T) {
container := manga.NewContainer(corruptedPage, nil, "png", true) container := manga.NewContainer(corruptedPage, nil, "png", true)
converted, err := converter.convertPage(container, 80) converted, err := converter.convertPage(container, 80, t.TempDir())
// This should return nil container and error because encoding will fail with nil image // This should return nil container and error because encoding will fail with nil image
assert.Error(t, err) assert.Error(t, err)
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8880c8909521c3716a339a39a29a4bf1114921a8b4651ff8ca04da7ce3a54b6a
size 1001668690