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
@@ -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