Compare commits

..
1 Commits
Author SHA1 Message Date
renovate[bot]andGitHub f4e2606ace chore(deps): update jetbrains/qodana-action action to v2026 2026-05-22 19:11:50 +00:00
37 changed files with 1518 additions and 3060 deletions
+3 -13
View File
@@ -114,15 +114,6 @@ golangci-lint run
- **Error handling:** Always check errors explicitly; use structured error wrapping with `fmt.Errorf("context: %w", err)`
- **Context usage:** Pass `context.Context` as first parameter for operations that may be cancelled
### Commit Messages
This project follows [Conventional Commits](https://www.conventionalcommits.org/). Prefix commit messages with a type (and optional scope), for example:
- `fix(watch): debounce fsnotify events before optimizing`
- `feat(converter): add avif support`
- `docs: clarify commit convention`
- `chore: update dependency`
### Logging
Use **zerolog** for all logging:
@@ -290,9 +281,8 @@ Releases are automated via goreleaser:
## Performance Considerations
- **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:** 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.
- **Parallelism:** Use `--parallelism` flag to control concurrent chapter processing
- **Memory:** Large images are processed in-memory; consider system RAM when setting parallelism
- **Timeouts:** Use `--timeout` flag to prevent hanging on problematic files
- **WebP Quality:** Balance quality (0-100) vs file size; default is 85
@@ -308,7 +298,7 @@ Releases are automated via goreleaser:
- CBR files are always converted to CBZ format (RAR is read-only)
- The `--override` flag deletes the original file after successful conversion
- Page splitting is useful for double-page spreads or very tall images
- Watch mode uses fsnotify-backed recursive directory monitoring on Linux
- Watch mode uses inotify on Linux for efficient file monitoring
- Bash completion is available via `cbzconverter completion bash`
## Getting Help
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
+23
View File
@@ -0,0 +1,23 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
push:
branches:
jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
fetch-depth: 0 # a full history is required for pull request analysis
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2026.1.0
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
+2 -2
View File
@@ -20,11 +20,11 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
with:
fetch-depth: 0 # this is important, otherwise it won't checkout the full tree (i.e. no previous tags)
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
+3 -3
View File
@@ -9,10 +9,10 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -46,6 +46,6 @@ jobs:
files: test-results/junit.xml
- name: Upload coverage reports to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v7
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
-4
View File
@@ -11,10 +11,6 @@
# Test binary, built with `go test -c`
*.test
# Build artifacts
/cbzoptimizer
/encoder-setup
test/
# Output of the go coverage tool, specifically when used with LiteIDE
-23
View File
@@ -1,23 +0,0 @@
# Agent Instructions
This repository contains AI-agent oriented project context.
## Read first
- `docs/project-overview.md` for architecture and execution flow.
- `docs/development.md` for build/test/lint commands and runtime prerequisites.
## Repository conventions
- Language: Go
- CLI framework: Cobra + Viper
- Logging: zerolog
- Error wrapping: `fmt.Errorf("context: %w", err)`
- Prefer small, focused changes.
- Commit messages: follow [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix(watch): debounce fsnotify events`, `docs: clarify commit convention`).
## Areas to know
- Watch command: `cmd/cbzoptimizer/commands/watch_command.go`
- Optimization orchestration: `internal/utils/optimize.go`
- Converter interface/impl: `pkg/converter/`
+22 -18
View File
@@ -1,34 +1,38 @@
FROM alpine:latest
FROM debian:trixie-slim
LABEL authors="Belphemur"
ARG TARGETPLATFORM
ARG APP_PATH=/usr/local/bin/CBZOptimizer
ENV USER=abc
ENV CONFIG_FOLDER=/config
ENV PUID=99
# libwebp-tools (cwebp) is installed via apk below into /usr/bin; point go-webpbin
# at it directly so it doesn't try to download a glibc prebuilt binary.
ENV VENDOR_PATH=/usr/bin
ENV DEBIAN_FRONTEND=noninteractive
RUN adduser \
-S \
-D \
-h "${CONFIG_FOLDER}" \
-u "${PUID}" \
-G users \
-s /bin/bash \
"${USER}"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends adduser && \
addgroup --system users && \
adduser \
--system \
--home "${CONFIG_FOLDER}" \
--uid "${PUID}" \
--ingroup users \
--disabled-password \
"${USER}" && \
apt-get purge -y --auto-remove adduser
COPY ${TARGETPLATFORM}/CBZOptimizer ${APP_PATH}
RUN apk add --no-cache \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
apt-get full-upgrade -y && \
apt-get install -y --no-install-recommends \
inotify-tools \
bash \
ca-certificates \
bash-completion \
libwebp-tools && \
bash-completion && \
chmod +x ${APP_PATH} && \
${APP_PATH} completion bash > /etc/bash_completion.d/CBZOptimizer.bash && \
mkdir -p "${CONFIG_FOLDER}" && \
chown -R "${PUID}":users "${CONFIG_FOLDER}"
${APP_PATH} completion bash > /etc/bash_completion.d/CBZOptimizer.bash
USER ${USER}
+1 -19
View File
@@ -58,12 +58,6 @@ cbzconverter optimize [folder] --format=webp
cbzconverter optimize [folder] --format WEBP
```
Preserve original page filenames in the output CBZ instead of sequential renumbering:
```sh
cbzconverter optimize [folder] --keep-filenames --quality 85 --format webp
```
With timeout to avoid hanging on problematic chapters:
```sh
@@ -84,16 +78,6 @@ Watch a folder for new CBZ/CBR files and optimize them automatically:
cbzconverter watch [folder] --quality 85 --override --format webp --split
```
Watch mode only reacts to filesystem events that occur *after* it starts; by default it does
not scan and optimize files that already exist in the folder when it starts. Run the
`optimize` command first if you need to process an existing library, then use `watch`
to keep it up to date going forward, or pass `--backfill` to have `watch` optimize the
existing files at startup before it begins watching for new changes. The only exception
is a directory that gets created/moved into the watched tree while watch mode is
running: since no per-file event is emitted for files already inside it, its existing
archives are always processed once when the directory is first detected, regardless of
`--backfill`.
Or with Docker:
```sh
@@ -103,15 +87,13 @@ docker run -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest watc
### Flags
- `--quality`, `-q`: Quality for conversion (0-100). Default is 85.
- `--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.
- `--parallelism`, `-n`: Number of chapters to convert in parallel. Default is 2.
- `--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.
- `--format`, `-f`: Format to convert the images to (currently supports: webp). Default is webp.
- Can be specified as: `--format webp`, `-f webp`, or `--format=webp`
- Case-insensitive: `webp`, `WEBP`, and `WebP` are all valid
- `--keep-filenames`: Preserve each page's original base filename in the output CBZ (with the extension swapped for format conversion) instead of renumbering pages to `0000`, `0001`, ... Default is false. When two source pages share the same base filename, the second one falls back to the indexed form so the output stays a valid zip.
- `--timeout`, `-t`: Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout. Default is 0.
- `--backfill`: *(`watch` only)* Optimize CBZ/CBR files that already exist in the watched folder at startup, before watching for new changes. Default is false.
- `--log`, `-l`: Set log level; can be 'panic', 'fatal', 'error', 'warn', 'info', 'debug', or 'trace'. Default is info.
## Logging
-14
View File
@@ -70,19 +70,6 @@ func setupSplitFlag(cmd *cobra.Command, defaultValue bool, bindViper bool) {
}
}
// setupKeepFilenamesFlag sets up the keep-filenames flag for a command.
//
// Parameters:
// - cmd: The Cobra command to add the keep-filenames flag to
// - defaultValue: The default keep-filenames value
// - bindViper: If true, binds the flag to viper for configuration file support
func setupKeepFilenamesFlag(cmd *cobra.Command, defaultValue bool, bindViper bool) {
cmd.Flags().Bool("keep-filenames", defaultValue, "Preserve original page filenames instead of renumbering to sequential indices")
if bindViper {
_ = viper.BindPFlag("keep-filenames", cmd.Flags().Lookup("keep-filenames"))
}
}
// setupTimeoutFlag sets up the timeout flag for a command.
//
// Parameters:
@@ -109,6 +96,5 @@ func setupCommonFlags(cmd *cobra.Command, converterType *constant.ConversionForm
setupQualityFlag(cmd, qualityDefault, bindViper)
setupOverrideFlag(cmd, overrideDefault, bindViper)
setupSplitFlag(cmd, splitDefault, bindViper)
setupKeepFilenamesFlag(cmd, false, bindViper)
setupTimeoutFlag(cmd, bindViper)
}
@@ -73,13 +73,6 @@ func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
}
log.Debug().Bool("split", split).Msg("Split parameter parsed")
keepFilenames, err := cmd.Flags().GetBool("keep-filenames")
if err != nil {
log.Error().Err(err).Msg("Failed to parse keep-filenames flag")
return fmt.Errorf("invalid keep-filenames value")
}
log.Debug().Bool("keep-filenames", keepFilenames).Msg("Keep-filenames parameter parsed")
timeout, err := cmd.Flags().GetDuration("timeout")
if err != nil {
log.Error().Err(err).Msg("Failed to parse timeout flag")
@@ -134,7 +127,6 @@ func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
Quality: quality,
Override: override,
Split: split,
KeepFilenames: keepFilenames,
Timeout: timeout,
})
if err != nil {
@@ -86,7 +86,6 @@ func TestConvertCbzCommand(t *testing.T) {
cmd.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel")
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout")
// Execute the command
@@ -198,7 +197,6 @@ func setupTestCommand(t *testing.T) (*cobra.Command, func()) {
cmd.Flags().IntP("parallelism", "n", 1, "Number of chapters to convert in parallel")
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
// Reset converterType to default before test for consistency
@@ -215,15 +213,13 @@ func TestFormatFlagWithSpace(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
defer os.RemoveAll(tempDir)
cmd, cleanup := setupTestCommand(t)
defer cleanup()
// Test with space-separated format flag (--format webp)
if err := cmd.ParseFlags([]string{"--format", "webp"}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
cmd.ParseFlags([]string{"--format", "webp"})
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
@@ -244,15 +240,13 @@ func TestFormatFlagWithShortForm(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
defer os.RemoveAll(tempDir)
cmd, cleanup := setupTestCommand(t)
defer cleanup()
// Test with short form and space (-f webp)
if err := cmd.ParseFlags([]string{"-f", "webp"}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
cmd.ParseFlags([]string{"-f", "webp"})
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
@@ -273,15 +267,13 @@ func TestFormatFlagWithEquals(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
defer os.RemoveAll(tempDir)
cmd, cleanup := setupTestCommand(t)
defer cleanup()
// Test with equals syntax (--format=webp)
if err := cmd.ParseFlags([]string{"--format=webp"}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
cmd.ParseFlags([]string{"--format=webp"})
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
@@ -302,15 +294,13 @@ func TestFormatFlagDefaultValue(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
defer os.RemoveAll(tempDir)
cmd, cleanup := setupTestCommand(t)
defer cleanup()
// Don't set format flag - should use default
if err := cmd.ParseFlags([]string{}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
cmd.ParseFlags([]string{})
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
@@ -331,7 +321,7 @@ func TestFormatFlagCaseInsensitive(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
defer os.RemoveAll(tempDir)
testCases := []string{"webp", "WEBP", "WebP", "WeBp"}
@@ -341,9 +331,7 @@ func TestFormatFlagCaseInsensitive(t *testing.T) {
defer cleanup()
// Test with different case variations
if err := cmd.ParseFlags([]string{"--format", formatValue}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
cmd.ParseFlags([]string{"--format", formatValue})
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
@@ -435,7 +423,6 @@ func TestConvertCbzCommand_ManyFiles_NoDeadlock(t *testing.T) {
cmd.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel")
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
converterType = constant.DefaultConversion
@@ -538,7 +525,6 @@ func TestConvertCbzCommand_HighParallelism_NoDeadlock(t *testing.T) {
cmd.Flags().IntP("parallelism", "n", 8, "Number of chapters to convert in parallel")
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
converterType = constant.DefaultConversion
+4 -10
View File
@@ -59,7 +59,7 @@ func init() {
ef,
"log", "l",
"Set log level; can be 'panic', 'fatal', 'error', 'warn', 'info', 'debug', or 'trace'")
if err := ef.RegisterCompletion(rootCmd, "log", enumflag.Help[zerolog.Level]{
ef.RegisterCompletion(rootCmd, "log", enumflag.Help[zerolog.Level]{
zerolog.PanicLevel: "Only log panic messages",
zerolog.FatalLevel: "Log fatal and panic messages",
zerolog.ErrorLevel: "Log error, fatal, and panic messages",
@@ -67,17 +67,11 @@ func init() {
zerolog.InfoLevel: "Log info, warn, error, fatal, and panic messages",
zerolog.DebugLevel: "Log debug, info, warn, error, fatal, and panic messages",
zerolog.TraceLevel: "Log all messages including trace",
}); err != nil {
panic(fmt.Errorf("failed to register log completion: %w", err))
}
})
// Add log level environment variable support
if err := viper.BindEnv("log", "LOG_LEVEL"); err != nil {
panic(fmt.Errorf("failed to bind LOG_LEVEL env: %w", err))
}
if err := viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log")); err != nil {
panic(fmt.Errorf("failed to bind log flag: %w", err))
}
viper.BindEnv("log", "LOG_LEVEL")
viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log"))
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
ConfigureLogging()
+56 -309
View File
@@ -1,31 +1,20 @@
package commands
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
utils2 "github.com/belphemur/CBZOptimizer/v2/internal/utils"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/fsnotify/fsnotify"
"github.com/pablodz/inotifywaitgo/inotifywaitgo"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// debounceDelay is the quiet period we wait, per path, after the last fsnotify
// event before triggering an optimization. This coalesces the bursts of
// Write/Create/Rename events fsnotify emits while a file is still being
// written (e.g. copied or downloaded into the watched folder).
const debounceDelay = 2 * time.Second
func init() {
if runtime.GOOS != "linux" {
return
@@ -41,9 +30,6 @@ func init() {
// Setup common flags (format, quality, override, split, timeout) with viper binding
setupCommonFlags(command, &converterType, 85, true, false, true)
command.Flags().Bool("backfill", false, "Optimize CBZ/CBR files that already exist in the watched folder at startup, before watching for new changes")
_ = viper.BindPFlag("backfill", command.Flags().Lookup("backfill"))
AddCommand(command)
}
func WatchCommand(_ *cobra.Command, args []string) error {
@@ -65,322 +51,83 @@ func WatchCommand(_ *cobra.Command, args []string) error {
split := viper.GetBool("split")
keepFilenames := viper.GetBool("keep-filenames")
timeout := viper.GetDuration("timeout")
backfill := viper.GetBool("backfill")
converterType := constant.FindConversionFormat(viper.GetString("format"))
chapterConverter, err := converter.Get(converterType)
if err != nil {
return fmt.Errorf("failed to get chapterConverter: %w", err)
return fmt.Errorf("failed to get chapterConverter: %v", err)
}
err = chapterConverter.PrepareConverter()
if err != nil {
return fmt.Errorf("failed to prepare converter: %w", err)
return fmt.Errorf("failed to prepare converter: %v", err)
}
log.Info().Str("path", path).Bool("override", override).Uint8("quality", quality).Str("format", converterType.String()).Bool("split", split).Bool("keep_filenames", keepFilenames).Bool("backfill", backfill).Msg("Watching directory")
log.Info().Str("path", path).Bool("override", override).Uint8("quality", quality).Str("format", converterType.String()).Bool("split", split).Msg("Watching directory")
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create file watcher: %w", err)
}
defer func() {
if closeErr := watcher.Close(); closeErr != nil {
log.Error().Err(closeErr).Msg("Failed to close file watcher")
}
events := make(chan inotifywaitgo.FileEvent)
errors := make(chan error)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
inotifywaitgo.WatchPath(&inotifywaitgo.Settings{
Dir: path,
FileEvents: events,
ErrorChan: errors,
Options: &inotifywaitgo.Options{
Recursive: true,
Events: []inotifywaitgo.EVENT{
inotifywaitgo.MOVE,
inotifywaitgo.CLOSE_WRITE,
},
Monitor: true,
},
Verbose: true,
})
}()
// Optimization jobs run in a small worker pool so the fsnotify event loop
// is never blocked waiting on a conversion.
queue := newOptimizeQueue(runtime.NumCPU(), &utils2.OptimizeOptions{
wg.Add(1)
go func() {
defer wg.Done()
for event := range events {
log.Debug().Str("file", event.Filename).Interface("events", event.Events).Msg("File event")
filename := strings.ToLower(event.Filename)
if !strings.HasSuffix(filename, ".cbz") && !strings.HasSuffix(filename, ".cbr") {
continue
}
for _, e := range event.Events {
switch e {
case inotifywaitgo.CLOSE_WRITE, inotifywaitgo.MOVE:
err := utils2.Optimize(&utils2.OptimizeOptions{
ChapterConverter: chapterConverter,
Path: event.Filename,
Quality: quality,
Override: override,
Split: split,
KeepFilenames: keepFilenames,
Timeout: timeout,
})
defer queue.Stop()
if err != nil {
errors <- fmt.Errorf("error processing file %s: %w", event.Filename, err)
}
default:
// ignored
}
}
}
}()
debouncer := newEventDebouncer(debounceDelay, queue.Enqueue)
defer debouncer.Stop()
// Note: existing archives already present under path when the watch
// starts are left untouched unless --backfill is set. Watch mode only
// reacts to filesystem events going forward by default; use the
// `optimize` command (or pass --backfill) to process a library's
// existing contents. Archives inside a directory that is created/moved
// into the watched tree *after* startup are always back-filled below,
// since only the directory itself generates an fsnotify event.
if err := addRecursiveWatch(watcher, path); err != nil {
return fmt.Errorf("failed to watch path %s: %w", path, err)
}
maybeBackfillExistingArchives(backfill, path, debouncer.Trigger)
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return nil
}
log.Debug().Str("file", event.Name).Str("event", event.Op.String()).Msg("File event")
if event.Has(fsnotify.Create) {
fileInfo, err := os.Stat(event.Name)
if err == nil && fileInfo.IsDir() {
if err := addRecursiveWatch(watcher, event.Name); err != nil {
log.Error().Err(err).Str("path", event.Name).Msg("Failed to watch created directory")
}
// The newly discovered directory may already contain
// archives (e.g. a folder moved/copied in); back-fill them
// since no further fsnotify event will target them.
backfillExistingArchives(event.Name, debouncer.Trigger)
continue
}
}
if !shouldProcessWatchEvent(event) {
continue
}
if !isComicArchive(event.Name) {
continue
}
debouncer.Trigger(event.Name)
case err, ok := <-watcher.Errors:
if !ok {
return nil
}
wg.Add(1)
go func() {
defer wg.Done()
for err := range errors {
log.Error().Err(err).Msg("Watch error")
}
}
}
}()
// addRecursiveWatch registers a watch on rootPath and every subdirectory
// beneath it. Directories that can't be read (e.g. permission errors) are
// logged and skipped instead of aborting the whole walk, so a single bad
// subtree doesn't prevent the rest of the folder from being watched.
func addRecursiveWatch(watcher *fsnotify.Watcher, rootPath string) error {
return filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("Skipping path while setting up watch")
wg.Wait()
return nil
}
if !entry.IsDir() {
return nil
}
if err := watcher.Add(path); err != nil {
if errors.Is(err, fs.ErrPermission) {
log.Warn().Err(err).Str("path", path).Msg("Skipping unreadable directory while setting up watch")
return nil
}
return fmt.Errorf("failed to watch directory %s: %w", path, err)
}
return nil
})
}
// backfillExistingArchives walks rootPath for comic archives that already
// exist on disk and hands them to process. This covers the case where a
// directory (potentially already containing archives) is created/moved into
// the watched tree: only the directory itself generates an fsnotify event,
// so the files inside it would otherwise never be picked up.
func backfillExistingArchives(rootPath string, process func(path string)) {
err := filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("Skipping path while scanning for existing archives")
return nil
}
if entry.IsDir() {
return nil
}
if !isComicArchive(path) {
return nil
}
process(path)
return nil
})
if err != nil {
log.Error().Err(err).Str("path", rootPath).Msg("Failed to scan directory for existing archives")
}
}
// maybeBackfillExistingArchives runs backfillExistingArchives against
// rootPath only when enabled is true. It exists as its own function (rather
// than inlining the `if` check at the call site) so the gating decision used
// by WatchCommand can be exercised directly in tests.
func maybeBackfillExistingArchives(enabled bool, rootPath string, process func(path string)) {
if !enabled {
return
}
backfillExistingArchives(rootPath, process)
}
func shouldProcessWatchEvent(event fsnotify.Event) bool {
return event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Rename)
}
func isComicArchive(path string) bool {
filename := strings.ToLower(path)
return strings.HasSuffix(filename, ".cbz") || strings.HasSuffix(filename, ".cbr")
}
// eventDebouncer coalesces bursts of fsnotify events targeting the same path
// into a single call to onQuiet, fired after the path has been quiet for
// delay. This prevents repeated conversions being triggered while a file is
// still being written, and avoids running Optimize against a Rename event
// that reports the *old* (already gone) file name once the quiet period lets
// us re-check the path.
type eventDebouncer struct {
delay time.Duration
onQuiet func(path string)
mu sync.Mutex
timers map[string]*debounceTimer
inFlight sync.WaitGroup
stopping bool
}
type debounceTimer struct {
timer *time.Timer
}
func newEventDebouncer(delay time.Duration, onQuiet func(path string)) *eventDebouncer {
return &eventDebouncer{
delay: delay,
onQuiet: onQuiet,
timers: make(map[string]*debounceTimer),
}
}
// Trigger (re)schedules onQuiet to run for path after the debounce delay,
// resetting any previously pending timer for the same path.
func (d *eventDebouncer) Trigger(path string) {
d.mu.Lock()
defer d.mu.Unlock()
if d.stopping {
return
}
if existing, exists := d.timers[path]; exists {
if existing.timer.Stop() {
d.inFlight.Done()
}
}
entry := &debounceTimer{}
d.inFlight.Add(1)
entry.timer = time.AfterFunc(d.delay, func() {
defer d.inFlight.Done()
d.mu.Lock()
// Only the still-current timer for this path is allowed to clear the
// entry and fire onQuiet. If Trigger raced with this callback and
// already installed a newer timer, this stale invocation must not
// delete that newer entry or fire early.
owns := d.timers[path] == entry && !d.stopping
if owns {
delete(d.timers, path)
}
d.mu.Unlock()
if owns {
d.onQuiet(path)
}
})
d.timers[path] = entry
}
// Stop cancels all pending timers.
func (d *eventDebouncer) Stop() {
d.mu.Lock()
d.stopping = true
for path, timer := range d.timers {
if timer.timer.Stop() {
d.inFlight.Done()
}
delete(d.timers, path)
}
d.mu.Unlock()
d.inFlight.Wait()
}
// optimizeQueue is a small worker pool that runs Optimize jobs off of the
// fsnotify event loop, so a slow conversion never blocks event draining.
type optimizeQueue struct {
jobs chan string
wg sync.WaitGroup
mu sync.RWMutex
stopped bool
options *utils2.OptimizeOptions
optimize func(options *utils2.OptimizeOptions) error
}
func newOptimizeQueue(workerCount int, options *utils2.OptimizeOptions) *optimizeQueue {
if workerCount < 1 {
workerCount = 1
}
q := &optimizeQueue{
jobs: make(chan string, 64),
options: options,
optimize: utils2.Optimize,
}
q.wg.Add(workerCount)
for i := 0; i < workerCount; i++ {
go q.worker()
}
return q
}
func (q *optimizeQueue) worker() {
defer q.wg.Done()
for path := range q.jobs {
q.process(path)
}
}
// process re-checks the path right before optimizing: fsnotify Rename events
// commonly report the *old* file name (already gone by the time we act on
// it), and Write can fire while a file is still being written elsewhere. In
// override mode, Optimize may overwrite/delete the source file, so skipping
// paths that no longer exist (or that are no longer regular files) avoids
// noisy failures.
func (q *optimizeQueue) process(path string) {
info, err := os.Stat(path)
if err != nil {
log.Debug().Err(err).Str("file", path).Msg("Skipping watch event: path no longer accessible")
return
}
if info.IsDir() {
return
}
options := *q.options
options.Path = path
if err := q.optimize(&options); err != nil {
log.Error().Err(err).Str("file", path).Msg("Error processing file")
}
}
// Enqueue submits path for processing by the worker pool.
func (q *optimizeQueue) Enqueue(path string) {
q.mu.RLock()
defer q.mu.RUnlock()
if q.stopped {
return
}
q.jobs <- path
}
// Stop closes the job queue and waits for in-flight jobs to finish.
func (q *optimizeQueue) Stop() {
q.mu.Lock()
q.stopped = true
close(q.jobs)
q.mu.Unlock()
q.wg.Wait()
}
@@ -1,204 +0,0 @@
package commands
import (
"os"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
utils2 "github.com/belphemur/CBZOptimizer/v2/internal/utils"
"github.com/fsnotify/fsnotify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsComicArchive(t *testing.T) {
testCases := []struct {
name string
path string
expected bool
}{
{"cbz lowercase", "/a/b/chapter.cbz", true},
{"cbr lowercase", "/a/b/chapter.cbr", true},
{"cbz uppercase", "/a/b/chapter.CBZ", true},
{"other extension", "/a/b/chapter.zip", false},
{"no extension", "/a/b/chapter", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, isComicArchive(tc.path))
})
}
}
func TestShouldProcessWatchEvent(t *testing.T) {
testCases := []struct {
name string
op fsnotify.Op
expected bool
}{
{"create", fsnotify.Create, true},
{"write", fsnotify.Write, true},
{"rename", fsnotify.Rename, true},
{"remove", fsnotify.Remove, false},
{"chmod", fsnotify.Chmod, false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
event := fsnotify.Event{Name: "file.cbz", Op: tc.op}
assert.Equal(t, tc.expected, shouldProcessWatchEvent(event))
})
}
}
func TestAddRecursiveWatchSkipsUnreadableSubdirectory(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission bits are not reliably enforced on Windows")
}
root := t.TempDir()
nested := filepath.Join(root, "nested")
assert.NoError(t, os.MkdirAll(nested, 0o755))
assert.NoError(t, os.Chmod(nested, 0o000))
defer func() {
assert.NoError(t, os.Chmod(nested, 0o755))
}()
if _, err := os.ReadDir(nested); err == nil {
t.Skip("cannot make nested directory unreadable in this environment")
}
watcher, err := fsnotify.NewWatcher()
assert.NoError(t, err)
defer func() {
assert.NoError(t, watcher.Close())
}()
// A regular, fully accessible tree should be watched without error.
assert.NoError(t, addRecursiveWatch(watcher, root))
}
func TestBackfillExistingArchivesFindsPreExistingArchives(t *testing.T) {
root := t.TempDir()
assert.NoError(t, os.WriteFile(filepath.Join(root, "chapter1.cbz"), []byte("data"), 0o644))
assert.NoError(t, os.WriteFile(filepath.Join(root, "notes.txt"), []byte("data"), 0o644))
sub := filepath.Join(root, "sub")
assert.NoError(t, os.MkdirAll(sub, 0o755))
assert.NoError(t, os.WriteFile(filepath.Join(sub, "chapter2.cbr"), []byte("data"), 0o644))
var mu sync.Mutex
var found []string
backfillExistingArchives(root, func(path string) {
mu.Lock()
defer mu.Unlock()
found = append(found, path)
})
assert.Len(t, found, 2)
}
func TestEventDebouncerCoalescesBurstsIntoSingleCall(t *testing.T) {
var calls int32
debouncer := newEventDebouncer(20*time.Millisecond, func(path string) {
atomic.AddInt32(&calls, 1)
})
defer debouncer.Stop()
// Simulate a burst of events for the same path.
for i := 0; i < 5; i++ {
debouncer.Trigger("/tmp/chapter.cbz")
}
require.Eventually(t, func() bool {
return atomic.LoadInt32(&calls) == 1
}, 2*time.Second, 10*time.Millisecond)
}
func TestEventDebouncerHandlesMultiplePaths(t *testing.T) {
var mu sync.Mutex
seen := make(map[string]int)
debouncer := newEventDebouncer(10*time.Millisecond, func(path string) {
mu.Lock()
defer mu.Unlock()
seen[path]++
})
defer debouncer.Stop()
debouncer.Trigger("/tmp/a.cbz")
debouncer.Trigger("/tmp/b.cbz")
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return seen["/tmp/a.cbz"] == 1 && seen["/tmp/b.cbz"] == 1
}, 2*time.Second, 10*time.Millisecond)
}
func TestOptimizeQueueSkipsMissingPath(t *testing.T) {
q := newOptimizeQueue(1, &utils2.OptimizeOptions{})
defer q.Stop()
var calls int32
done := make(chan struct{}, 1)
existing := filepath.Join(t.TempDir(), "existing.cbz")
require.NoError(t, os.WriteFile(existing, []byte("data"), 0o644))
q.optimize = func(options *utils2.OptimizeOptions) error {
if options.Path == existing {
atomic.AddInt32(&calls, 1)
select {
case done <- struct{}{}:
default:
}
}
return nil
}
// Enqueue a path that doesn't exist; process should skip it without
// panicking or blocking since it never reaches utils2.Optimize.
q.Enqueue(filepath.Join(t.TempDir(), "missing.cbz"))
q.Enqueue(existing)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for existing path to be processed")
}
assert.EqualValues(t, 1, atomic.LoadInt32(&calls))
}
func TestWatchCommandBackfillFlagDefaultsToFalse(t *testing.T) {
watchCmd, _, err := rootCmd.Find([]string{"watch"})
require.NoError(t, err)
flag := watchCmd.Flags().Lookup("backfill")
require.NotNil(t, flag, "watch command should register a --backfill flag")
assert.Equal(t, "false", flag.DefValue)
assert.Equal(t, "bool", flag.Value.Type())
}
func TestMaybeBackfillExistingArchivesOnlyInvokedWhenRequested(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "chapter1.cbz"), []byte("data"), 0o644))
runBackfill := func(enabled bool) []string {
var found []string
var mu sync.Mutex
process := func(path string) {
mu.Lock()
defer mu.Unlock()
found = append(found, path)
}
// This is the exact same gating call WatchCommand makes based on the
// --backfill flag value.
maybeBackfillExistingArchives(enabled, root, process)
return found
}
assert.Empty(t, runBackfill(false), "no pre-existing archive should be processed when backfill is disabled")
assert.Len(t, runBackfill(true), 1, "pre-existing archives should be processed when backfill is enabled")
}
-33
View File
@@ -1,33 +0,0 @@
# Development Guide
## Build
```bash
go build -o cbzconverter ./cmd/cbzoptimizer
```
## Encoder setup (required for WebP conversion)
```bash
go build -tags encoder_setup -o encoder-setup ./cmd/encoder-setup
./encoder-setup
```
## Test
```bash
go test ./...
```
## Lint
```bash
golangci-lint run
```
## Important behavior
- Input supports CBZ and CBR.
- Output is always CBZ.
- `--override` replaces source CBZ files and removes source CBR files after successful conversion.
- Watch mode performs recursive directory monitoring.
-27
View File
@@ -1,27 +0,0 @@
# Project Overview
CBZOptimizer is a Go CLI that optimizes comic archives (`.cbz` and `.cbr`) by converting page images to modern formats (currently WebP).
## High-level flow
1. Load chapters from archive files.
2. Decode pages and optionally split oversized pages.
3. Convert images with the selected converter.
4. Write optimized output as CBZ.
## Main components
- `cmd/cbzoptimizer`: CLI commands and flag wiring (`optimize`, `watch`).
- `internal/cbz`: archive loading and writing.
- `internal/manga`: chapter and page domain models.
- `internal/utils`: orchestration utilities (`optimize` flow and file helpers).
- `pkg/converter`: converter abstraction and format implementations.
## Watch mode
`watch` monitors a directory tree for archive file changes and runs optimization automatically. By default it only reacts to changes going forward; pass `--backfill` to also optimize archives that already exist in the folder at startup.
## Key runtime requirements
- Go 1.25+
- WebP encoder setup via `cmd/encoder-setup` for WebP conversion tests and runtime support.
+9 -8
View File
@@ -1,19 +1,21 @@
module github.com/belphemur/CBZOptimizer/v2
go 1.26
go 1.25.0
require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/belphemur/go-webpbin/v2 v2.1.0
github.com/fsnotify/fsnotify v1.10.1
github.com/mholt/archives v0.1.5
github.com/oliamb/cutter v0.2.2
github.com/pablodz/inotifywaitgo v0.0.9
github.com/rs/zerolog v1.35.1
github.com/samber/lo v1.53.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/thediveo/enumflag/v2 v2.2.1
golang.org/x/image v0.44.0
github.com/thediveo/enumflag/v2 v2.2.0
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a
golang.org/x/image v0.41.0
)
require (
@@ -25,6 +27,7 @@ require (
github.com/bodgit/windows v1.0.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -48,9 +51,7 @@ require (
github.com/ulikunitz/xz v0.5.15 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+96 -35
View File
@@ -17,8 +17,6 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@@ -40,6 +38,7 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -52,16 +51,18 @@ 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -87,8 +88,8 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
@@ -115,8 +116,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -128,14 +132,19 @@ github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
github.com/oliamb/cutter v0.2.2 h1:Lfwkya0HHNU1YLnGv2hTkzHfasrSMkgv4Dn+5rmlk3k=
github.com/oliamb/cutter v0.2.2/go.mod h1:4BenG2/4GuRBDbVm/OPahDVqbrOemzpPiG5mi1iryBU=
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/pablodz/inotifywaitgo v0.0.9 h1:njquRbBU7fuwIe5rEvtaniVBjwWzcpdUVptSgzFqZsw=
github.com/pablodz/inotifywaitgo v0.0.9/go.mod h1:hAfx2oN+WKg8miwUKPs52trySpPignlRBRxWcXVHku0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -144,15 +153,24 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@@ -182,10 +200,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/thediveo/enumflag/v2 v2.2.1 h1:sB6zJBA7G5Qk5tcCK5f25H8Rfern7idwTASdBxY4inQ=
github.com/thediveo/enumflag/v2 v2.2.1/go.mod h1:Fa35DiSMi7oIXNc1VJPktJVlsk4NPW8dYY3Zjvhx+S4=
github.com/thediveo/success v1.3.1 h1:SQ/ICN55yYxyEpgh0XGQwG+A4mMLGa2MYp+fxnIKoYs=
github.com/thediveo/success v1.3.1/go.mod h1:Wlj+S4i3x4pLZEO/OY/cEDfpChUoxw02BRYjcGjH+Zw=
github.com/thediveo/enumflag/v2 v2.0.7 h1:uxXDU+rTel7Hg4X0xdqICpG9rzuI/mzLAEYXWLflOfs=
github.com/thediveo/enumflag/v2 v2.0.7/go.mod h1:bWlnNvTJuUK+huyzf3WECFLy557Ttlc+yk3o+BPs0EA=
github.com/thediveo/enumflag/v2 v2.1.0 h1:F80w/h1U4B3/sBpFVUewzMVTfLk2m0D60+61UCuXSf8=
github.com/thediveo/enumflag/v2 v2.1.0/go.mod h1:wj2B0dHqqFOqIgnJ7mD8s97wK7/46oOZvDg93muD68g=
github.com/thediveo/enumflag/v2 v2.2.0 h1:T8ty4R+Pv+pYiK+lSVSk45MuyM39T/29ZZTH/6QH3Ls=
github.com/thediveo/enumflag/v2 v2.2.0/go.mod h1:3ax7ccNoUb+rnHhNepTx1vdhrlKyMUefD+Z8kvxiVg4=
github.com/thediveo/success v1.0.2 h1:w+r3RbSjLmd7oiNnlCblfGqItcsaShcuAorRVh/+0xk=
github.com/thediveo/success v1.0.2/go.mod h1:hdPJB77k70w764lh8uLUZgNhgeTl3DYeZ4d4bwMO2CU=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
@@ -213,12 +235,42 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
golang.org/x/exp v0.0.0-20260211191109-2735e65f0518 h1:2E1CW7v5QB+Wi3N+MXllOtVR6SFmI8iJM8EdzgxrgrU=
golang.org/x/exp v0.0.0-20260211191109-2735e65f0518/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -235,8 +287,6 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -253,8 +303,8 @@ golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -267,8 +317,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -285,10 +335,14 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -299,10 +353,18 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -330,9 +392,8 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+45 -91
View File
@@ -3,10 +3,7 @@ package cbz
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
@@ -14,59 +11,7 @@ import (
"github.com/rs/zerolog/log"
)
// resolvePageName picks the final archive entry name for a page.
//
// Order of resolution:
// 1. When page.OriginalName is set (keep-filenames mode), use its stem with
// the current page.Extension. Split pages append the -NN suffix so all
// parts of the same source still land in the archive.
// 2. If that final name has already been used in this archive, fall back to
// the indexed form (stem + "_%04d" + extension) so the zip stays valid.
// 3. Otherwise (OriginalName is empty), use the historical %04d / %04d-NN
// sequential naming.
//
// usedNames tracks every name already chosen for this archive and is mutated
// in place so the caller can keep a single map across the whole chapter.
func resolvePageName(page *manga.PageFile, usedNames map[string]struct{}) string {
if page.OriginalName != "" {
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
var candidate string
if page.IsSplitted {
candidate = fmt.Sprintf("%s-%02d%s", stem, page.SplitPartIndex, page.Extension)
} else {
candidate = stem + page.Extension
}
if _, taken := usedNames[candidate]; !taken {
usedNames[candidate] = struct{}{}
return candidate
}
// Collision: fall back to the indexed form so the entry still gets
// written, but make it visibly distinct from the preserved one.
// Loop in case the fallback name is itself already taken (e.g. a
// source file literally named "cover_0001.png" colliding with a
// page-index 1 fallback "cover_0001.webp").
for suffix := page.Index; ; suffix++ {
fallback := fmt.Sprintf("%s_%04d%s", stem, suffix, page.Extension)
if _, taken := usedNames[fallback]; !taken {
usedNames[fallback] = struct{}{}
return fallback
}
}
}
if page.IsSplitted {
name := fmt.Sprintf("%04d-%02d%s", page.Index, page.SplitPartIndex, page.Extension)
usedNames[name] = struct{}{}
return name
}
name := fmt.Sprintf("%04d%s", page.Index, page.Extension)
usedNames[name] = struct{}{}
return name
}
// 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) (err error) {
func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
log.Debug().
Str("chapter_file", chapter.FilePath).
Str("output_path", outputFilePath).
@@ -74,7 +19,8 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) (err error
Bool("is_converted", chapter.IsConverted).
Msg("Starting CBZ file creation")
// Create output file
// Create a new ZIP file
log.Debug().Str("output_path", outputFilePath).Msg("Creating output CBZ file")
zipFile, err := os.Create(outputFilePath)
if err != nil {
log.Error().Str("output_path", outputFilePath).Err(err).Msg("Failed to create CBZ file")
@@ -82,88 +28,96 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) (err error
}
defer errs.Capture(&err, zipFile.Close, "failed to close .cbz file")
// Create ZIP writer
// Create a new ZIP writer
log.Debug().Str("output_path", outputFilePath).Msg("Creating 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 archive by streaming from disk.
// Final name resolution: when a page carries an OriginalName (recorded by
// ExtractChapter when --keep-filenames is on), preserve its stem and only
// swap the extension to the current page.Extension. Duplicates in the
// archive fall back to the indexed naming so the output stays a valid zip.
usedNames := make(map[string]struct{}, len(chapter.Pages))
// 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")
for _, page := range chapter.Pages {
fileName := resolvePageName(page, usedNames)
// 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).
Str("source", page.FilePath).
Int("size", len(page.Contents.Bytes())).
Msg("Writing page to CBZ archive")
// Create file entry in the zip (Store method = no compression, images are already compressed)
// Create a new file in the ZIP archive
fileWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: fileName,
Method: zip.Store,
Modified: time.Now(),
})
if err != nil {
log.Error().Str("filename", fileName).Err(err).Msg("Failed to create file in CBZ archive")
log.Error().Str("output_path", outputFilePath).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 file from disk into the archive
pageFile, err := os.Open(page.FilePath)
// Write the page contents to the file
bytesWritten, err := fileWriter.Write(page.Contents.Bytes())
if err != nil {
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, pageFile)
closeErr := pageFile.Close()
if err != nil {
log.Error().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)
}
if closeErr != nil {
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).
Int("bytes_written", bytesWritten).
Msg("Page written successfully")
}
// Write ComicInfo.xml if present
// Optionally, write the ComicInfo.xml file if present
if chapter.ComicInfoXml != "" {
log.Debug().Str("output_path", outputFilePath).Msg("Writing ComicInfo.xml")
log.Debug().Str("output_path", outputFilePath).Int("xml_size", len(chapter.ComicInfoXml)).Msg("Writing ComicInfo.xml to CBZ archive")
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)
}
_, err = comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
bytesWritten, err := comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
if err != nil {
return fmt.Errorf("failed to write ComicInfo.xml: %w", err)
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)
}
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 {
comment := fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", chapter.ConvertedTime)
err = zipWriter.SetComment(comment)
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)
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")
log.Debug().Str("output_path", outputFilePath).Msg("CBZ file creation completed successfully")
return nil
}
+38 -170
View File
@@ -2,203 +2,122 @@ package cbz
import (
"archive/zip"
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
"os"
"testing"
"time"
)
func TestWriteChapterToCBZ(t *testing.T) {
currentTime := time.Now()
// 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()
}
// Define test cases
testCases := []struct {
name string
chapter func(t *testing.T, dir string) *manga.Chapter
chapter *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: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
chapter: &manga.Chapter{
Pages: []*manga.Page{
{
Index: 0,
Extension: ".jpg",
FilePath: createTempPage(t, dir, "image data", ".jpg"),
Contents: bytes.NewBuffer([]byte("image data")),
},
},
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: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
chapter: &manga.Chapter{
Pages: []*manga.Page{
{
Index: 0,
Extension: ".jpg",
FilePath: createTempPage(t, dir, "image data", ".jpg"),
Contents: bytes.NewBuffer([]byte("image data")),
},
},
}
},
expectedFiles: []string{"0000.jpg"},
},
{
name: "Multiple pages with ComicInfo",
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")},
chapter: &manga.Chapter{
Pages: []*manga.Page{
{
Index: 0,
Extension: ".jpg",
Contents: bytes.NewBuffer([]byte("image data 1")),
},
{
Index: 1,
Extension: ".jpg",
Contents: bytes.NewBuffer([]byte("image data 2")),
},
},
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
}
},
expectedFiles: []string{"0000.jpg", "0001.jpg", "ComicInfo.xml"},
},
{
name: "Split page",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
chapter: &manga.Chapter{
Pages: []*manga.Page{
{
Index: 0,
Extension: ".jpg",
FilePath: createTempPage(t, dir, "split image data", ".jpg"),
Contents: bytes.NewBuffer([]byte("split image data")),
IsSplitted: true,
SplitPartIndex: 1,
},
},
}
},
expectedFiles: []string{"0000-01.jpg"},
},
{
// --keep-filenames: the page stem is reused as-is with the
// current Extension, regardless of the source extension.
name: "Preserve original filename, extension swap",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".webp",
FilePath: createTempPage(t, dir, "image data", ".webp"),
OriginalName: "page01.png",
},
},
}
},
expectedFiles: []string{"page01.webp"},
},
{
// --keep-filenames for a split page: stem is preserved, the
// split part index still gets appended to keep parts distinct.
name: "Split page with OriginalName",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".webp",
FilePath: createTempPage(t, dir, "split data", ".webp"),
IsSplitted: true,
SplitPartIndex: 2,
OriginalName: "tall.png",
},
},
}
},
expectedFiles: []string{"tall-02.webp"},
},
{
// Two pages share the same OriginalName (rare but possible
// with subdirectories): the first keeps the stem, the second
// falls back to the indexed form to avoid duplicate zip
// entries.
name: "Duplicate OriginalName falls back to indexed form",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".webp",
FilePath: createTempPage(t, dir, "first", ".webp"),
OriginalName: "cover.png",
},
{
Index: 1,
Extension: ".webp",
FilePath: createTempPage(t, dir, "second", ".webp"),
OriginalName: "cover.png",
},
},
}
},
expectedFiles: []string{"cover.webp", "cover_0001.webp"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create temp dir for page files
pageDir := t.TempDir()
chapter := tc.chapter(t, pageDir)
// Create temp file for output CBZ
// Create a temporary file for the .cbz output
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 chapter
err = WriteChapterToCBZ(chapter, tempFile.Name())
// Write the chapter to the .cbz file
err = WriteChapterToCBZ(tc.chapter, tempFile.Name())
if err != nil {
t.Fatalf("Failed to write chapter to CBZ: %v", err)
}
// Verify the archive
// Open the .cbz file as a zip archive
r, err := zip.OpenReader(tempFile.Name())
if err != nil {
t.Fatalf("Failed to open CBZ file: %v", err)
}
defer func() { _ = r.Close() }()
defer errs.Capture(&err, r.Close, "failed to close CBZ file")
// 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 {
@@ -216,61 +135,10 @@ 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: %v", len(tc.expectedFiles), len(filesInArchive), filesInArchive)
t.Errorf("Expected %d files, but found %d", len(tc.expectedFiles), len(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)
}
}
+95 -348
View File
@@ -3,14 +3,13 @@ package cbz
import (
"archive/zip"
"bufio"
"bytes"
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/araddon/dateparse"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
@@ -19,401 +18,149 @@ import (
"github.com/rs/zerolog/log"
)
// supportedImageExtensions contains file extensions considered valid image pages.
var supportedImageExtensions = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
".bmp": true,
".tiff": true,
".tif": true,
}
func LoadChapter(filePath string) (*manga.Chapter, error) {
log.Debug().Str("file_path", filePath).Msg("Starting chapter loading")
// parseConvertedComment checks if a zip comment's first line is a parseable date,
// indicating the archive was already converted. Returns true and the parsed time if so.
func parseConvertedComment(comment string) bool {
if comment == "" {
return false
}
scanner := bufio.NewScanner(strings.NewReader(comment))
if scanner.Scan() {
_, err := dateparse.ParseAny(scanner.Text())
return err == nil
}
return false
}
// parseConvertedCommentTime parses the converted timestamp from a zip comment.
// Returns the time and true if the comment indicates conversion, otherwise zero time and false.
func parseConvertedCommentTime(comment string) (time.Time, bool) {
if comment == "" {
return time.Time{}, false
}
scanner := bufio.NewScanner(strings.NewReader(comment))
if scanner.Scan() {
t, err := dateparse.ParseAny(scanner.Text())
if err == nil {
return t, true
}
}
return time.Time{}, false
}
// 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(ctx context.Context, filePath string) (converted bool, err error) {
log.Debug().Str("file_path", filePath).Msg("Checking if already converted")
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 parseConvertedComment(r.Comment) {
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" {
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.
//
// When keepFilenames is true, each PageFile has its OriginalName set to the
// base filename of the entry inside the archive. Downstream code uses that
// name to preserve the original page identity in the output CBZ (with the
// extension swapped for format conversion). When false, OriginalName stays
// empty and the sequential %04d naming convention is used instead.
func ExtractChapter(ctx context.Context, filePath string, keepFilenames bool) (*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)
}
ctx := context.Background()
chapter := &manga.Chapter{
FilePath: filePath,
TempDir: tempDir,
}
// usedOriginalStems tracks the STEM (filename without extension) of every
// OriginalName handed out in this chapter so stems stay unique across
// pages. Tracking stems (not full names) prevents a downstream race in
// pkg/converter/webp: the converter strips the OriginalName extension
// and appends a target-format suffix (e.g. ".webp"), so two pages that
// share a stem but differ in extension — e.g. a/page.png and b/page.jpg
// (legal in zip) — would otherwise race on a single shared intermediate
// output path. The map covers both same-stem-same-ext collisions (e.g.
// a/page.png + b/page.png) and same-stem-different-ext collisions (e.g.
// a/page.png + b/page.jpg). Allocated lazily so the keepFilenames=false
// path stays allocation-free.
var usedOriginalStems map[string]struct{}
if keepFilenames {
usedOriginalStems = make(map[string]struct{})
}
// For CBZ files, read metadata from zip comment
pathLower := strings.ToLower(filepath.Ext(filePath))
if pathLower == ".cbz" {
// 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")
r, err := zip.OpenReader(filePath)
if err == nil {
if t, ok := parseConvertedCommentTime(r.Comment); ok {
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)
if err == nil {
chapter.IsConverted = true
chapter.ConvertedTime = t
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")
}
_ = r.Close()
}
} 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")
}
// Continue even if comment reading fails
}
// Extract files using the archives library (supports both CBZ and CBR)
// Open the archive using archives library for file operations
log.Debug().Str("file_path", filePath).Msg("Opening archive file system")
fsys, err := archives.FileSystem(ctx, filePath, nil)
if err != nil {
_ = os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to open archive: %w", err)
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)
}
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
// 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
}
// Check for context cancellation during extraction
select {
case <-ctx.Done():
return ctx.Err()
default:
}
ext := strings.ToLower(filepath.Ext(path))
fileName := strings.ToLower(filepath.Base(path))
// Skip OS-specific metadata files and junk
if isJunkFile(path) {
log.Debug().Str("file_path", filePath).Str("skipped", path).Msg("Skipping junk file")
return nil
}
// Handle ComicInfo.xml
if ext == ".xml" && fileName == "comicinfo.xml" {
file, err := fsys.Open(path)
if err != nil {
return fmt.Errorf("failed to open ComicInfo.xml: %w", err)
}
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
}
// 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
chapter.ConvertedTime = t
}
}
return nil
}
// Only extract supported image files
if !supportedImageExtensions[ext] {
log.Debug().Str("file_path", filePath).Str("skipped", path).Str("ext", ext).Msg("Skipping non-image file")
return nil
}
// Extract image file to disk
return func() error {
// Open the file
file, err := fsys.Open(path)
if err != nil {
return fmt.Errorf("failed to open file %s: %w", path, err)
}
defer func() { _ = file.Close() }()
defer errs.Capture(&err, file.Close, fmt.Sprintf("failed to close file %s", path))
// Create output file with sequential naming
pageIndex := uint16(len(chapter.Pages))
outputName := fmt.Sprintf("%04d%s", pageIndex, ext)
outputPath := filepath.Join(inputDir, outputName)
// Determine the file extension
ext := strings.ToLower(filepath.Ext(path))
fileName := strings.ToLower(filepath.Base(path))
outFile, err := os.Create(outputPath)
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 {
return fmt.Errorf("failed to create output file %s: %w", outputPath, err)
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)
}
_, err = io.Copy(outFile, file)
closeErr := outFile.Close()
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 {
_ = os.Remove(outputPath)
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
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)
}
if closeErr != nil {
_ = os.Remove(outputPath)
return fmt.Errorf("failed to close file %s: %w", outputPath, closeErr)
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)
}
chapter.IsConverted = true
log.Debug().Str("file_path", filePath).Time("converted_time", chapter.ConvertedTime).Msg("Chapter marked as converted from converted.txt")
}
} 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)
}
page := &manga.PageFile{
Index: pageIndex,
// Create a new Page object
page := &manga.Page{
Index: uint16(len(chapter.Pages)), // Simple index based on order
Extension: ext,
FilePath: outputPath,
Size: uint64(buf.Len()),
Contents: buf,
IsSplitted: false,
}
if keepFilenames {
page.OriginalName = allocateUniqueBaseName(archiveBaseName(path), pageIndex, usedOriginalStems)
}
chapter.Pages = append(chapter.Pages, page)
// 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", pageIndex).
Msg("Page extracted to disk")
Uint16("page_index", page.Index).
Int64("bytes_read", bytesCopied).
Msg("Page loaded successfully")
}
return nil
}()
})
if err != nil {
_ = os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to extract archive: %w", err)
log.Error().Str("file_path", filePath).Err(err).Msg("Failed during filesystem walk")
return nil, err
}
log.Debug().
Str("file_path", filePath).
Int("pages_extracted", len(chapter.Pages)).
Int("pages_loaded", len(chapter.Pages)).
Bool("is_converted", chapter.IsConverted).
Msg("Chapter extraction completed")
Bool("has_comic_info", chapter.ComicInfoXml != "").
Msg("Chapter loading completed successfully")
return chapter, nil
}
// isJunkFile returns true for known OS/tool metadata files that should not be
// treated as pages (e.g., __MACOSX/, Thumbs.db, .DS_Store).
func isJunkFile(path string) bool {
// __MACOSX resource fork directories
if strings.Contains(path, "__MACOSX") {
return true
}
baseLower := strings.ToLower(filepath.Base(path))
switch baseLower {
case "thumbs.db", ".ds_store", "desktop.ini":
return true
}
return false
}
// archiveBaseName returns the bare base name of an archive entry, with
// Windows-style backslash separators normalized to forward slashes before
// the last-segment split. The archives library surfaces zip entry names
// verbatim, so a name like "..\evil.png" (common from Windows-created
// archives) would otherwise pass through filepath.Base unchanged on non-
// Windows hosts and be written into the output CBZ as a path-traversal
// shape. Normalizing the separators first and then taking the segment
// after the final one guarantees the returned name is a bare base name
// with no directory components, no backslashes, and no forward slashes —
// which is what the downstream writer expects as a safe ZIP entry name.
//
// This intentionally does NOT touch filepath.Base calls in isJunkFile or
// the ComicInfo.xml / converted.txt detection: those comparisons just
// fail to match, and the entry then falls through to the non-image
// extension filter, so no traversal-shaped data escapes the loader.
func archiveBaseName(path string) string {
normalized := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(normalized, "/"); idx >= 0 {
return normalized[idx+1:]
}
return normalized
}
// allocateUniqueBaseName returns baseName when its stem is not already taken
// by an earlier page in this chapter, or a collision-resolved variant
// otherwise.
//
// Stem-uniqueness contract: the function guarantees that the STEM
// (filename without extension) of the returned OriginalName is unique
// among all names previously handed out from this chapter. That contract
// matches what downstream code relies on —
// pkg/converter/webp.intermediatePageName strips the OriginalName's
// extension and appends a target-format suffix (e.g. ".webp"), so two
// OriginalNames that share a stem would otherwise race on a single
// intermediate output path. The stem is computed the same way downstream
// does it (strings.TrimSuffix(name, filepath.Ext(name))) to keep both
// definitions in lock-step.
//
// On collision the resolved name matches the existing fallback style used
// by cbz_creator's resolvePageName: stem + "_%04d" + extension. The loop
// checks the CANDIDATE's stem (not the full generated name) so two files
// that share a stem but differ in extension — e.g. "page.png" and
// "page.jpg" — resolve to, e.g., "page.png" and "page_0001.jpg", each
// preserving its original extension. The starting suffix is pageIndex so
// the resolved name stays in the same neighborhood as the page's archive
// position. If the candidate's stem is itself already taken
// (astronomically rare: the source archive would need both the original
// name and a matching indexed name), the suffix is incremented until a
// free stem is found. The chosen name's stem is recorded in usedStems so
// subsequent calls cannot pick it again.
func allocateUniqueBaseName(baseName string, pageIndex uint16, usedStems map[string]struct{}) string {
ext := filepath.Ext(baseName)
stem := strings.TrimSuffix(baseName, ext)
if _, taken := usedStems[stem]; !taken {
usedStems[stem] = struct{}{}
return baseName
}
for suffix := int(pageIndex); ; suffix++ {
candidateStem := fmt.Sprintf("%s_%04d", stem, suffix)
if _, taken := usedStems[candidateStem]; !taken {
usedStems[candidateStem] = struct{}{}
return candidateStem + ext
}
}
}
// LoadChapter extracts the chapter from a CBZ/CBR file to disk.
// It delegates to ExtractChapter with keepFilenames=false and always
// extracts all pages. Use IsAlreadyConverted for a fast conversion status
// check without extraction.
func LoadChapter(filePath string) (*manga.Chapter, error) {
return ExtractChapter(context.Background(), filePath, false)
}
+13 -621
View File
@@ -1,19 +1,8 @@
package cbz
import (
"archive/zip"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadChapter(t *testing.T) {
@@ -47,625 +36,28 @@ func TestLoadChapter(t *testing.T) {
expectedSeries: "<Series>Boundless Necromancer</Series>",
expectedConversion: true,
},
// Add more test cases as needed
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
chapter, err := LoadChapter(tc.filePath)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
if err != nil {
t.Fatalf("Failed to load chapter: %v", err)
}
assert.Equal(t, tc.expectedPages, len(chapter.Pages))
actualPages := len(chapter.Pages)
if actualPages != tc.expectedPages {
t.Errorf("Expected %d pages, but got %d", tc.expectedPages, actualPages)
}
assert.Contains(t, chapter.ComicInfoXml, tc.expectedSeries)
if !strings.Contains(chapter.ComicInfoXml, tc.expectedSeries) {
t.Errorf("ComicInfoXml does not contain the expected series: %s", tc.expectedSeries)
}
assert.Equal(t, tc.expectedConversion, chapter.IsConverted)
// Verify pages are on disk
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.FilePath, "Page %d has no file path", page.Index)
if chapter.IsConverted != tc.expectedConversion {
t.Errorf("Expected chapter to be converted: %t, but got %t", tc.expectedConversion, chapter.IsConverted)
}
})
}
}
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(context.Background(), tc.filePath)
require.NoError(t, err)
assert.Equal(t, tc.expected, result)
})
}
}
func TestIsAlreadyConverted_NonexistentFile(t *testing.T) {
_, err := IsAlreadyConverted(context.Background(), "/nonexistent/file.cbz")
require.Error(t, err)
}
func TestIsAlreadyConverted_InvalidExtension(t *testing.T) {
// Create a temp file with unsupported extension
tmpFile, err := os.CreateTemp("", "test-*.txt")
require.NoError(t, err)
defer func() { _ = os.Remove(tmpFile.Name()) }()
_ = tmpFile.Close()
result, err := IsAlreadyConverted(context.Background(), tmpFile.Name())
require.NoError(t, err)
assert.False(t, result, "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)
require.NoError(t, err)
w := zip.NewWriter(f)
_ = w.SetComment(time.Now().Format(time.RFC3339))
// Add a dummy file
fw, err := w.Create("page.webp")
require.NoError(t, err)
_, _ = fw.Write([]byte("dummy"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.True(t, result, "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)
require.NoError(t, err)
w := zip.NewWriter(f)
_ = w.SetComment("this is not a date")
fw, err := w.Create("page.jpg")
require.NoError(t, err)
_, _ = fw.Write([]byte("dummy"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.False(t, result, "Expected CBZ with non-date comment to NOT be detected as converted")
}
func TestExtractChapter_NonexistentFile(t *testing.T) {
_, err := ExtractChapter(context.Background(), "/nonexistent/file.cbz", false)
require.Error(t, err)
}
func TestExtractChapter_PageExtensions(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
require.NoError(t, 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 {
assert.True(t, validExts[page.Extension], "Page %d has unexpected extension: %s", page.Index, page.Extension)
}
}
func TestExtractChapter_PagesHaveSequentialIndices(t *testing.T) {
t.Run("default sequential naming", func(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
for i, page := range chapter.Pages {
assert.Equal(t, uint16(i), page.Index)
assert.Empty(t, page.OriginalName, "keep-filenames disabled: OriginalName must stay empty")
}
})
t.Run("keep-filenames records OriginalName", func(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.NotEmpty(t, chapter.Pages)
for i, page := range chapter.Pages {
assert.Equal(t, uint16(i), page.Index)
assert.NotEmpty(t, page.OriginalName, "keep-filenames enabled: OriginalName must be set for every page")
// OriginalName must be a bare base name (no directory prefix), even
// when the source archive nests pages in subdirectories.
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"OriginalName should be a base filename with no path components")
}
})
}
// writeCollisionCBZ builds a CBZ containing the same image base name in two
// different subdirectories plus a normal unique page. The test fixture is
// specifically crafted to reproduce the race fixed in ExtractChapter: when
// keep-filenames naively copied filepath.Base(path) into OriginalName, both
// a/page.png and b/page.png would have ended up with the same stem and the
// WebP converter would have raced on a single shared intermediate path.
func writeCollisionCBZ(t *testing.T, dir string) string {
t.Helper()
cbzPath := filepath.Join(dir, "collisions.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
for _, entry := range []string{"a/page.png", "b/page.png", "cover.png"} {
fw, err := w.Create(entry)
require.NoError(t, err)
_, err = fw.Write([]byte("dummy image bytes for " + entry))
require.NoError(t, err)
}
require.NoError(t, w.Close())
require.NoError(t, f.Close())
return cbzPath
}
func TestExtractChapter_KeepFilenames_DeduplicatesCollidingNames(t *testing.T) {
tmpDir := t.TempDir()
cbzPath := writeCollisionCBZ(t, tmpDir)
t.Run("keep-filenames resolves colliding base names", func(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), cbzPath, true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3, "expected 3 pages: two colliding + one unique")
// All OriginalNames must be non-empty bare base names and unique.
seen := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.OriginalName, "page %d must have OriginalName set", page.Index)
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"page %d OriginalName should be a bare base filename", page.Index)
if _, dup := seen[page.OriginalName]; dup {
t.Errorf("duplicate OriginalName across pages: %q", page.OriginalName)
}
seen[page.OriginalName] = struct{}{}
}
// The unique page keeps its original name; the two colliding pages
// must come out as one bare "page.png" and one indexed variant.
assert.Contains(t, seen, "page.png", "one colliding page should keep the bare page.png name")
assert.Contains(t, seen, "cover.png", "the unique page should keep its original name")
indexedPattern := regexp.MustCompile(`^page_\d{4}\.png$`)
indexedCount := 0
for name := range seen {
if indexedPattern.MatchString(name) {
indexedCount++
}
}
assert.Equal(t, 1, indexedCount,
"exactly one colliding page should be resolved to the page_NNNN.png pattern, got %v", seen)
})
t.Run("keep-filenames off leaves OriginalName empty (regression guard)", func(t *testing.T) {
// Same collision-prone archive, but with keep-filenames off: the
// dedup logic must not run, so OriginalName stays empty for every
// page. This is the historical default and must not regress.
chapter, err := ExtractChapter(context.Background(), cbzPath, false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3)
for _, page := range chapter.Pages {
assert.Empty(t, page.OriginalName, "page %d must have empty OriginalName when keep-filenames is off", page.Index)
}
})
}
// writeBackslashCBZ builds a CBZ whose entry names mix Windows-style
// backslash separators with a normal forward-slash page. The fixture
// reproduces the CodeRabbit finding for PR #217: archive entries like
// "subdir\page.png" and "..\evil.png" must not surface verbatim as the
// OriginalName written into the output CBZ, since Windows ZIP consumers
// can interpret backslashes as path traversal.
func writeBackslashCBZ(t *testing.T, dir string) string {
t.Helper()
cbzPath := filepath.Join(dir, "backslash.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
// archive/zip stores entry names verbatim, so the bytes on the wire
// carry the backslashes through to fs.WalkDir in ExtractChapter.
for _, entry := range []string{`subdir\page.png`, `..\evil.png`, "cover.png"} {
fw, err := w.Create(entry)
require.NoError(t, err)
_, err = fw.Write([]byte("dummy image bytes for " + entry))
require.NoError(t, err)
}
require.NoError(t, w.Close())
require.NoError(t, f.Close())
return cbzPath
}
func TestExtractChapter_KeepFilenames_NormalizesWindowsSeparators(t *testing.T) {
tmpDir := t.TempDir()
cbzPath := writeBackslashCBZ(t, tmpDir)
chapter, err := ExtractChapter(context.Background(), cbzPath, true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3, "expected 3 pages: two backslash entries + one normal")
// Every OriginalName must be a bare base name: no backslashes, no
// forward slashes, and unique across the chapter.
seen := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.OriginalName, "page %d must have OriginalName set", page.Index)
assert.NotContains(t, page.OriginalName, `\`, "page %d OriginalName must not contain backslash, got %q", page.Index, page.OriginalName)
assert.NotContains(t, page.OriginalName, "/", "page %d OriginalName must not contain forward slash, got %q", page.Index, page.OriginalName)
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"page %d OriginalName should be a bare base filename", page.Index)
if _, dup := seen[page.OriginalName]; dup {
t.Errorf("duplicate OriginalName across pages: %q", page.OriginalName)
}
seen[page.OriginalName] = struct{}{}
}
// The two backslash entries must collapse to their bare bases; the
// "..\evil.png" entry's leading "..\evil" must not be carried through.
assert.Contains(t, seen, "page.png", "subdir\\page.png should normalize to bare page.png")
assert.Contains(t, seen, "evil.png", "..\\evil.png should normalize to bare evil.png")
assert.Contains(t, seen, "cover.png", "cover.png should pass through unchanged")
}
// writeSameStemDifferentExtCBZ builds a CBZ containing two same-stem pages
// with different extensions plus a distinct unique page. The fixture
// reproduces the CodeRabbit finding for PR #217: a/page.png and b/page.jpg
// share a stem but do NOT share a full filename, so naive full-name
// deduplication lets both OriginalNames through and the WebP converter then
// races on a single shared intermediate output path (outputDir/page.webp).
// The fix flips the dedup tracking to STEM-uniqueness, so one of the
// colliding pair must come out as a bare "page.<ext>" and the other as
// "page_NNNN.<ext>" — each preserving its original extension.
func writeSameStemDifferentExtCBZ(t *testing.T, dir string) string {
t.Helper()
cbzPath := filepath.Join(dir, "same_stem.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
for _, entry := range []string{"a/page.png", "b/page.jpg", "cover.png"} {
fw, err := w.Create(entry)
require.NoError(t, err)
_, err = fw.Write([]byte("dummy image bytes for " + entry))
require.NoError(t, err)
}
require.NoError(t, w.Close())
require.NoError(t, f.Close())
return cbzPath
}
func TestExtractChapter_KeepFilenames_DeduplicatesSameStemDifferentExtensions(t *testing.T) {
tmpDir := t.TempDir()
cbzPath := writeSameStemDifferentExtCBZ(t, tmpDir)
chapter, err := ExtractChapter(context.Background(), cbzPath, true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3, "expected 3 pages: two same-stem + one unique")
// Every OriginalName must be a bare base name; full names AND stems
// must each be unique across the chapter. The stem-uniqueness check is
// the contract the WebP converter relies on (it strips the extension
// and appends ".webp" when computing intermediatePageName).
seenNames := make(map[string]struct{}, len(chapter.Pages))
seenStems := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.OriginalName, "page %d must have OriginalName set", page.Index)
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"page %d OriginalName should be a bare base filename", page.Index)
if _, dup := seenNames[page.OriginalName]; dup {
t.Errorf("duplicate OriginalName full name across pages: %q", page.OriginalName)
}
seenNames[page.OriginalName] = struct{}{}
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
if _, dup := seenStems[stem]; dup {
t.Errorf("duplicate OriginalName stem across pages: %q (from %q)", stem, page.OriginalName)
}
seenStems[stem] = struct{}{}
}
// The unique page keeps its original name untouched.
assert.Contains(t, seenNames, "cover.png", "the unique page should keep its original name")
// The colliding pair must split into one bare "page.<ext>" and one
// "page_NNNN.<ext>". The extensions must differ — each must keep the
// extension of the archive entry it came from. Walk order is not
// guaranteed, so the test checks both shapes regardless of which
// entry comes first.
barePattern := regexp.MustCompile(`^page\.(png|jpg)$`)
indexedPattern := regexp.MustCompile(`^page_\d{4}\.(png|jpg)$`)
var bareExt, indexedExt string
for name := range seenNames {
if barePattern.MatchString(name) {
bareExt = filepath.Ext(name)
}
if indexedPattern.MatchString(name) {
indexedExt = filepath.Ext(name)
}
}
assert.NotEmpty(t, bareExt,
"expected one bare page.<ext> OriginalName from the colliding pair, got %v", seenNames)
assert.NotEmpty(t, indexedExt,
"expected one page_NNNN.<ext> OriginalName from the colliding pair, got %v", seenNames)
assert.NotEqual(t, bareExt, indexedExt,
"the bare and indexed variants must keep their original different extensions, got bare=%q indexed=%q",
bareExt, indexedExt)
}
func TestExtractChapter_Cleanup(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
require.NoError(t, err)
tempDir := chapter.TempDir
// Verify temp dir exists
assert.DirExists(t, tempDir)
// Cleanup should remove temp dir
err = chapter.Cleanup()
require.NoError(t, err)
assert.NoDirExists(t, tempDir)
}
func TestExtractChapter_CBR(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 1.cbr", false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
assert.Len(t, chapter.Pages, 16)
// All page files should exist on disk
for _, page := range chapter.Pages {
assert.FileExists(t, page.FilePath)
}
}
func TestExtractChapter_ConvertedStatus(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 10_converted.cbz", false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
assert.True(t, chapter.IsConverted, "Expected converted chapter to have IsConverted = true")
assert.False(t, chapter.ConvertedTime.IsZero(), "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)
require.NoError(t, err)
// Verify the file is a valid zip
r, err := zip.OpenReader(outputPath)
require.NoError(t, 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)
require.NoError(t, err)
// Verify ComicInfo.xml is present
r, err := zip.OpenReader(outputPath)
require.NoError(t, err)
defer func() { _ = r.Close() }()
foundComicInfo := false
for _, f := range r.File {
if f.Name == "ComicInfo.xml" {
foundComicInfo = true
}
}
assert.True(t, foundComicInfo, "ComicInfo.xml not found in output CBZ")
// Verify zip comment has converted timestamp
assert.NotEmpty(t, r.Comment, "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)
require.Error(t, err)
}
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)
require.NoError(t, err)
w := zip.NewWriter(f)
// Add converted.txt with a date
fw, err := w.Create("converted.txt")
require.NoError(t, err)
_, _ = fw.Write([]byte(time.Now().Format(time.RFC3339)))
// Add a dummy page
fw, err = w.Create("page.webp")
require.NoError(t, err)
_, _ = fw.Write([]byte("dummy"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.True(t, result, "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)
require.NoError(t, err)
w := zip.NewWriter(f)
fw, err := w.Create("converted.txt")
require.NoError(t, err)
_, _ = fw.Write([]byte("not a valid date"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.False(t, result, "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)
require.NoError(t, err)
w := zip.NewWriter(f)
fw, err := w.Create("converted.txt")
require.NoError(t, 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")
require.NoError(t, err)
_, _ = fw.Write([]byte("fake image"))
_ = w.Close()
_ = f.Close()
chapter, err := ExtractChapter(context.Background(), cbzPath, false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
assert.True(t, chapter.IsConverted, "Expected chapter with converted.txt to be marked as converted")
assert.False(t, chapter.ConvertedTime.IsZero(), "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)
require.NoError(t, err)
// Verify archive contents
r, err := zip.OpenReader(outputPath)
require.NoError(t, err)
defer func() { _ = r.Close() }()
// Should have 5 pages = 5 files (conversion status stored in zip comment, not as file)
expectedFiles := 5
assert.Len(t, r.File, expectedFiles)
// Verify zip comment is set
assert.NotEmpty(t, r.Comment, "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")
require.Error(t, err)
}
+8 -28
View File
@@ -1,42 +1,22 @@
package manga
import (
"fmt"
"os"
"time"
)
import "time"
// Chapter represents a comic book chapter with pages stored on disk.
type Chapter struct {
// FilePath is the path to the original archive file.
// FilePath is the path to the chapter's directory.
FilePath string
// Pages is a slice of page files on disk.
Pages []*PageFile
// ComicInfoXml holds the ComicInfo.xml content (small, kept in memory).
// Pages is a slice of pointers to Page objects.
Pages []*Page
// ComicInfo is a string containing information about the chapter.
ComicInfoXml string
// IsConverted indicates whether the chapter has already been converted.
// IsConverted is a boolean that indicates whether the chapter has been converted.
IsConverted bool
// ConvertedTime is when the chapter was converted.
// ConvertedTime is a pointer to a time.Time object that indicates when the chapter was converted. Nil mean not converted.
ConvertedTime time.Time
// TempDir is the root temp directory for this chapter's extracted/converted files.
// Cleanup removes this entire directory.
TempDir string
}
// SetConverted marks the chapter as converted with the current timestamp.
// SetConverted sets the IsConverted field to true and sets the ConvertedTime field to the current time.
func (chapter *Chapter) SetConverted() {
chapter.IsConverted = true
chapter.ConvertedTime = time.Now()
}
// 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 temp directory: %w", err)
}
chapter.TempDir = ""
return nil
}
-102
View File
@@ -1,102 +0,0 @@
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)
}
}
+15 -19
View File
@@ -1,22 +1,18 @@
package manga
// 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
// OriginalName is the base filename (e.g. "page01.png") of the page as
// it appeared in the source archive, recorded when the --keep-filenames
// flag is enabled. Empty when the flag is off or the source name is
// unknown. When set, downstream code uses this stem (with the final
// Extension swapped in) instead of the default %04d sequential name.
OriginalName string
import "bytes"
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
Contents *bytes.Buffer `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."`
}
+32
View File
@@ -0,0 +1,32 @@
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
}
+2 -2
View File
@@ -46,7 +46,7 @@ func TestCapture(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var err = tt.initial
var err error = 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 = tt.initial
var err error = 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())
-59
View File
@@ -1,59 +0,0 @@
package utils
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
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)
assert.Equal(t, tt.expected, result)
})
}
}
+58 -54
View File
@@ -21,21 +21,10 @@ type OptimizeOptions struct {
Quality uint8
Override bool
Split bool
// KeepFilenames preserves the original base filename of each page inside
// the output CBZ (with the extension swapped for format conversion)
// instead of the historical %04d sequential naming. Off by default so
// existing behavior is unchanged.
KeepFilenames bool
Timeout time.Duration
}
// 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().
@@ -43,57 +32,45 @@ func Optimize(options *OptimizeOptions) error {
Uint8("quality", options.Quality).
Bool("override", options.Override).
Bool("split", options.Split).
Bool("keep_filenames", options.KeepFilenames).
Msg("Optimization parameters")
// Step 1: Fast conversion check before extracting (new requirement)
alreadyConverted, err := cbz.IsAlreadyConverted(context.Background(), options.Path)
// Load the chapter
log.Debug().Str("file", options.Path).Msg("Loading chapter")
chapter, err := cbz.LoadChapter(options.Path)
if err != nil {
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
log.Error().Str("file", options.Path).Err(err).Msg("Failed to load 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")
// Step 2: Extract chapter to disk
log.Debug().Str("file", options.Path).Msg("Extracting chapter")
// Create context for extraction (use timeout if configured)
var extractCtx context.Context
if options.Timeout > 0 {
var cancel context.CancelFunc
extractCtx, cancel = context.WithTimeout(context.Background(), options.Timeout)
defer cancel()
log.Debug().Str("file", options.Path).Dur("timeout", options.Timeout).Msg("Applying timeout")
} else {
extractCtx = context.Background()
}
chapter, err := cbz.ExtractChapter(extractCtx, options.Path, options.KeepFilenames)
if err != nil {
log.Error().Str("file", options.Path).Err(err).Msg("Failed to extract chapter")
return fmt.Errorf("failed to extract chapter: %w", err)
}
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", options.Path).
Str("file", chapter.FilePath).
Int("pages", len(chapter.Pages)).
Msg("Chapter extracted successfully")
Uint8("quality", options.Quality).
Bool("split", options.Split).
Msg("Starting chapter conversion")
// Step 3: Convert pages file-to-file
convertedChapter, err := options.ChapterConverter.ConvertChapter(extractCtx, chapter, options.Quality, options.Split, func(msg string, current uint32, total uint32) {
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")
} else {
ctx = context.Background()
}
convertedChapter, err := options.ChapterConverter.ConvertChapter(ctx, chapter, options.Quality, options.Split, func(msg string, current uint32, total uint32) {
if current%10 == 0 || current == total {
log.Info().Str("file", chapter.FilePath).Uint32("current", current).Uint32("total", total).Msg("Converting")
} else {
@@ -106,7 +83,7 @@ func Optimize(options *OptimizeOptions) error {
log.Debug().Str("file", chapter.FilePath).Err(err).Msg("Page conversion error (non-fatal)")
} else {
log.Error().Str("file", chapter.FilePath).Err(err).Msg("Chapter conversion failed")
return fmt.Errorf("failed to convert chapter: %w", err)
return fmt.Errorf("failed to convert chapter: %v", err)
}
}
if convertedChapter == nil {
@@ -116,45 +93,71 @@ func Optimize(options *OptimizeOptions) error {
log.Debug().
Str("file", chapter.FilePath).
Int("original_pages", len(chapter.Pages)).
Int("converted_pages", len(convertedChapter.Pages)).
Msg("Chapter conversion completed")
convertedChapter.SetConverted()
// Step 4: Determine output path
// Determine output path and handle CBR override logic
log.Debug().
Str("input_path", options.Path).
Bool("override", options.Override).
Msg("Determining 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")
}
// Step 5: Write converted chapter to CBZ (streaming from disk)
// Write the converted chapter to CBZ file
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: %w", err)
return fmt.Errorf("failed to write converted chapter: %v", err)
}
log.Debug().Str("output_path", outputPath).Msg("Successfully wrote converted chapter")
// If overriding a CBR file, delete the original
// If we're overriding a CBR file, delete the original CBR after successful write
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")
@@ -163,4 +166,5 @@ func Optimize(options *OptimizeOptions) error {
log.Info().Str("output", outputPath).Msg("Converted file written")
return nil
}
+2 -2
View File
@@ -206,7 +206,7 @@ func TestOptimizeIntegration(t *testing.T) {
}
// Clean up output file
_ = os.Remove(expectedOutput)
os.Remove(expectedOutput)
})
}
}
@@ -387,7 +387,7 @@ func TestOptimizeIntegration_Timeout(t *testing.T) {
Quality: 85,
Override: false,
Split: false,
Timeout: 1 * time.Nanosecond, // Extremely short timeout to force timeout
Timeout: 10 * time.Millisecond, // Very short timeout to force timeout
}
err = Optimize(options)
+1 -1
View File
@@ -276,7 +276,7 @@ func TestOptimize(t *testing.T) {
}
// Clean up output file
_ = os.Remove(expectedOutput)
os.Remove(expectedOutput)
})
}
}
+8 -14
View File
@@ -11,19 +11,13 @@ import (
"github.com/samber/lo"
)
// Converter defines the interface for image format converters.
// All operations are file-to-file: no image data is held in memory in the happy path.
type Converter interface {
// Format returns the output format of this converter.
Format() constant.ConversionFormat
// ConvertChapter converts all pages in a chapter from their source files to
// the target format. Pages are processed in parallel (bounded by CPU count).
// On success, chapter.Pages is updated with converted PageFile entries.
// Returns partial success (non-fatal errors) via errors.PageIgnoredError.
// Format of the converter
Format() (format constant.ConversionFormat)
// ConvertChapter converts a manga chapter to the specified format.
//
// Returns partial success where some pages are converted and some are not.
ConvertChapter(ctx context.Context, chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error)
// PrepareConverter ensures the external encoder binary is available.
PrepareConverter() error
}
@@ -36,8 +30,8 @@ func Available() []constant.ConversionFormat {
return lo.Keys(converters)
}
// Get returns a converter by format name.
// If the converter is not available, an error is returned.
// Get returns a packer by name.
// If the packer is not available, an error is returned.
var Get = getConverter
func getConverter(name constant.ConversionFormat) (Converter, error) {
@@ -45,7 +39,7 @@ func getConverter(name constant.ConversionFormat) (Converter, error) {
return converter, nil
}
return nil, fmt.Errorf("unknown converter \"%s\", available options are %s", name, strings.Join(lo.Map(Available(), func(item constant.ConversionFormat, index int) string {
return nil, fmt.Errorf("unkown converter \"%s\", available options are %s", name, strings.Join(lo.Map(Available(), func(item constant.ConversionFormat, index int) string {
return item.String()
}), ", "))
}
+175 -138
View File
@@ -1,21 +1,22 @@
package converter
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
"os"
"path/filepath"
"testing"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
)
func TestConvertChapter(t *testing.T) {
testCases := []struct {
name string
genTestChapter func(t *testing.T, dir string) (*manga.Chapter, []string)
genTestChapter func(path string, isSplit bool) (*manga.Chapter, []string, error)
split bool
expectError bool
}{
@@ -26,7 +27,7 @@ func TestConvertChapter(t *testing.T) {
},
{
name: "Big Pages, no split",
genTestChapter: genHugePageNoSplit,
genTestChapter: genHugePage,
split: false,
expectError: true,
},
@@ -46,41 +47,53 @@ func TestConvertChapter(t *testing.T) {
split: false,
expectError: true,
},
{
name: "Two corrupted pages",
genTestChapter: genTwoCorrupted,
split: false,
expectError: true,
},
}
// Load test genTestChapter from testdata
temp, err := os.CreateTemp("", "test_chapter_*.cbz")
if err != nil {
t.Fatalf("failed to create temporary file: %v", err)
for _, converterFormat := range Available() {
conv, err := Get(converterFormat)
}
defer errs.CaptureGeneric(&err, os.Remove, temp.Name(), "failed to remove temporary file")
for _, converter := range Available() {
converter, err := Get(converter)
if err != nil {
t.Fatalf("failed to get converter: %v", err)
}
t.Run(conv.Format().String(), func(t *testing.T) {
t.Run(converter.Format().String(), func(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tempDir := t.TempDir()
chapter, expectedExtensions := tc.genTestChapter(t, tempDir)
chapter, expectedExtensions, err := tc.genTestChapter(temp.Name(), tc.split)
if err != nil {
t.Fatalf("failed to load test genTestChapter: %v", err)
}
quality := uint8(80)
progress := func(msg string, current uint32, total uint32) {
t.Log(msg)
}
convertedChapter, err := conv.ConvertChapter(context.Background(), chapter, quality, tc.split, progress)
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, quality, tc.split, progress)
if err != nil && !tc.expectError {
t.Fatalf("failed to convert chapter: %v", err)
}
if convertedChapter == nil {
t.Fatal("convertedChapter is nil")
t.Fatalf("failed to convert genTestChapter: %v", err)
}
if len(convertedChapter.Pages) == 0 {
t.Fatal("no pages were converted")
t.Fatalf("no pages were converted")
}
if len(convertedChapter.Pages) != len(expectedExtensions) {
t.Fatalf("converted chapter has %d pages but expected %d", len(convertedChapter.Pages), len(expectedExtensions))
}
// Check each page's extension against the expected array
for i, page := range convertedChapter.Pages {
expectedExt := expectedExtensions[i]
if page.Extension != expectedExt {
@@ -93,152 +106,176 @@ func TestConvertChapter(t *testing.T) {
}
}
// createTestImageFile creates a JPEG image file at the given path with the specified dimensions.
func createTestImageFile(t *testing.T, path string, width, height int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
f, err := os.Create(path)
func genHugePage(path string, isSplit bool) (*manga.Chapter, []string, error) {
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
return nil, nil, err
}
err = jpeg.Encode(f, img, nil)
_ = f.Close()
defer errs.Capture(&err, file.Close, "failed to close file")
var pages []*manga.Page
expectedExtensions := []string{".jpg"} // One image that's generated as JPEG
if isSplit {
expectedExtensions = []string{".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp"}
}
// Create one tall page
img := image.NewRGBA(image.Rect(0, 0, 1, 17000))
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, img, nil)
if err != nil {
t.Fatal(err)
return nil, nil, err
}
}
func genHugePage(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
if err := os.MkdirAll(inputDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "output"), 0755); err != nil {
t.Fatal(err)
}
pagePath := filepath.Join(inputDir, "0000.jpg")
createTestImageFile(t, pagePath, 1, 17000)
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: pagePath},
},
}
// With split: 17000/2000 = 9 parts (8*2000 + 1*1000)
// Without split: page > webpMaxHeight → kept as .jpg with error
// The caller decides split=true or split=false and we return expectations
// for the split=true case (this test case is always called with split=true)
expectedExtensions := []string{".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp"}
return chapter, expectedExtensions
}
func genHugePageNoSplit(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
if err := os.MkdirAll(inputDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "output"), 0755); err != nil {
t.Fatal(err)
}
pagePath := filepath.Join(inputDir, "0000.jpg")
createTestImageFile(t, pagePath, 1, 17000)
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: pagePath},
},
}
// Without split: page > webpMaxHeight → kept as .jpg with error
return chapter, []string{".jpg"}
}
func genSmallPages(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
_ = os.MkdirAll(inputDir, 0755)
_ = os.MkdirAll(filepath.Join(dir, "output"), 0755)
var pages []*manga.PageFile
for i := 0; i < 5; i++ {
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.jpg", i))
createTestImageFile(t, pagePath, 300, 1000)
pages = append(pages, &manga.PageFile{
Index: uint16(i),
page := &manga.Page{
Index: 0,
Contents: buf,
Extension: ".jpg",
FilePath: pagePath,
})
}
pages = append(pages, page)
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
return &manga.Chapter{
FilePath: path,
Pages: pages,
}
return chapter, []string{".webp", ".webp", ".webp", ".webp", ".webp"}
}, expectedExtensions, nil
}
func genMixSmallBig(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
_ = os.MkdirAll(inputDir, 0755)
_ = os.MkdirAll(filepath.Join(dir, "output"), 0755)
func genSmallPages(path string, isSplit bool) (*manga.Chapter, []string, error) {
file, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer errs.Capture(&err, file.Close, "failed to close file")
var pages []*manga.PageFile
for i := 0; i < 5; i++ {
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.jpg", i))
createTestImageFile(t, pagePath, 300, 1000*(i+1))
pages = append(pages, &manga.PageFile{
var pages []*manga.Page
for i := 0; i < 5; i++ { // Assuming there are 5 pages for the test
img := image.NewRGBA(image.Rect(0, 0, 300, 1000))
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, img, nil)
if err != nil {
return nil, nil, err
}
page := &manga.Page{
Index: uint16(i),
Contents: buf,
Extension: ".jpg",
FilePath: pagePath,
})
}
pages = append(pages, page)
}
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
return &manga.Chapter{
FilePath: path,
Pages: pages,
}
// Pages heights: 1000, 2000, 3000, 4000, 5000
// With new disk-first architecture: cwebp handles all these directly
// (all < 16383 webp max), no splitting needed
return chapter, []string{".webp", ".webp", ".webp", ".webp", ".webp"}
}, []string{".webp", ".webp", ".webp", ".webp", ".webp"}, nil
}
func genMixSmallHuge(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
_ = os.MkdirAll(inputDir, 0755)
_ = os.MkdirAll(filepath.Join(dir, "output"), 0755)
func genMixSmallBig(path string, isSplit bool) (*manga.Chapter, []string, error) {
file, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer errs.Capture(&err, file.Close, "failed to close file")
var pages []*manga.PageFile
for i := 0; i < 10; i++ {
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.jpg", i))
createTestImageFile(t, pagePath, 1, 2000*(i+1))
pages = append(pages, &manga.PageFile{
var pages []*manga.Page
for i := 0; i < 5; i++ { // Assuming there are 5 pages for the test
img := image.NewRGBA(image.Rect(0, 0, 300, 1000*(i+1)))
buf := new(bytes.Buffer)
err := jpeg.Encode(buf, img, nil)
if err != nil {
return nil, nil, err
}
page := &manga.Page{
Index: uint16(i),
Contents: buf,
Extension: ".jpg",
FilePath: pagePath,
})
}
pages = append(pages, page)
}
expectedExtensions := []string{".webp", ".webp", ".webp", ".webp", ".webp"}
if isSplit {
expectedExtensions = []string{".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp"}
}
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
return &manga.Chapter{
FilePath: path,
Pages: pages,
}, expectedExtensions, nil
}
func genMixSmallHuge(path string, isSplit bool) (*manga.Chapter, []string, error) {
file, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer errs.Capture(&err, file.Close, "failed to close file")
var pages []*manga.Page
for i := 0; i < 10; i++ { // Assuming there are 5 pages for the test
img := image.NewRGBA(image.Rect(0, 0, 1, 2000*(i+1)))
buf := new(bytes.Buffer)
err := jpeg.Encode(buf, img, nil)
if err != nil {
return nil, nil, err
}
page := &manga.Page{
Index: uint16(i),
Contents: buf,
Extension: ".jpg",
}
pages = append(pages, page)
}
// Heights: 2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000
// Without split, pages > webpMaxHeight (16383) are kept as .jpg
// Pages 0-7 (2000-16000): should convert to .webp
// Pages 8-9 (18000, 20000): > 16383, no split → kept as .jpg
return chapter, []string{".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".jpg", ".jpg"}
return &manga.Chapter{
FilePath: path,
Pages: pages,
}, []string{".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".jpg", ".jpg"}, nil
}
func genTwoCorrupted(path string, isSplit bool) (*manga.Chapter, []string, error) {
file, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer errs.Capture(&err, file.Close, "failed to close file")
var pages []*manga.Page
numPages := 5
corruptedIndices := []int{2, 4} // Pages 2 and 4 are too tall to convert without splitting
for i := 0; i < numPages; i++ {
var buf *bytes.Buffer
var ext string
isCorrupted := false
for _, ci := range corruptedIndices {
if i == ci {
isCorrupted = true
break
}
}
if isCorrupted {
buf = bytes.NewBufferString("corrupted data") // Invalid data, can't decode as image
ext = ".jpg"
} else {
img := image.NewRGBA(image.Rect(0, 0, 300, 1000))
buf = new(bytes.Buffer)
err = jpeg.Encode(buf, img, nil)
if err != nil {
return nil, nil, err
}
ext = ".jpg"
}
page := &manga.Page{
Index: uint16(i),
Contents: buf,
Extension: ext,
}
pages = append(pages, page)
}
// Expected: small pages to .webp, corrupted pages to .jpg (kept as is)
expectedExtensions := []string{".webp", ".webp", ".jpg", ".webp", ".jpg"}
// Even with split, corrupted pages can't be decoded so stay as is
return &manga.Chapter{
FilePath: path,
Pages: pages,
}, expectedExtensions, nil
}
+335 -259
View File
@@ -1,68 +1,45 @@
package webp
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"image/png"
"runtime"
"strings"
"sort"
"sync"
"sync/atomic"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
converterrors "github.com/belphemur/CBZOptimizer/v2/pkg/converter/errors"
"github.com/oliamb/cutter"
"github.com/rs/zerolog/log"
"golang.org/x/exp/slices"
_ "golang.org/x/image/webp"
)
const webpMaxHeight = 16383
// intermediatePageName returns the on-disk filename used for a page's WebP
// intermediate during conversion. When the page carries an OriginalName
// (recorded by --keep-filenames), its stem is reused so the temp file line
// up with the name the archive writer will pick. Otherwise the historical
// %04d indexed naming is kept. The splitSuffix argument is appended verbatim
// after the stem (e.g. "-00", "-01") for split parts and is empty for the
// happy-path single output. A leading dash is only added when both the
// split suffix and the original-name stem are present, so the indexed form
// stays as %04d-%02d.
func intermediatePageName(page *manga.PageFile, splitSuffix string) string {
if page.OriginalName != "" {
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
return stem + splitSuffix + ".webp"
}
if splitSuffix == "" {
return fmt.Sprintf("%04d.webp", page.Index)
}
return fmt.Sprintf("%04d%s.webp", page.Index, splitSuffix)
}
type Converter struct {
maxHeight int
cropHeight int
isPrepared bool
// pageWorkerGuard limits concurrent cwebp processes across all chapters.
pageWorkerGuard chan struct{}
}
func (converter *Converter) Format() constant.ConversionFormat {
func (converter *Converter) Format() (format constant.ConversionFormat) {
return constant.WebP
}
func New() *Converter {
return &Converter{
//maxHeight: 16383 / 2,
maxHeight: 4000,
cropHeight: 2000,
isPrepared: false,
pageWorkerGuard: make(chan struct{}, runtime.NumCPU()),
}
}
@@ -78,311 +55,410 @@ func (converter *Converter) PrepareConverter() error {
return nil
}
// ConvertChapter converts all pages in a chapter using file-to-file cwebp operations.
// In the happy path, no image data is loaded into Go memory.
// Splitting is attempted only if direct conversion fails due to dimension limits.
func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
log.Debug().
Str("chapter", chapter.FilePath).
Int("pages", len(chapter.Pages)).
Uint8("quality", quality).
Bool("split", split).
Msg("Starting file-to-file chapter conversion")
Int("max_goroutines", runtime.NumCPU()).
Msg("Starting chapter conversion")
err := converter.PrepareConverter()
if err != nil {
log.Error().Str("chapter", chapter.FilePath).Err(err).Msg("Failed to prepare converter")
return nil, err
}
// Validate TempDir is set to prevent writing to cwd
if chapter.TempDir == "" {
return nil, fmt.Errorf("chapter TempDir is empty, cannot create output directory")
}
var wgConvertedPages sync.WaitGroup
maxGoroutines := runtime.NumCPU()
// Create output directory for converted files
outputDir := filepath.Join(chapter.TempDir, "output")
if err := os.MkdirAll(outputDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
pagesChan := make(chan *manga.PageContainer, maxGoroutines)
errChan := make(chan error, maxGoroutines)
doneChan := make(chan struct{})
// Check for early context cancellation
var wgPages sync.WaitGroup
wgPages.Add(len(chapter.Pages))
guard := make(chan struct{}, maxGoroutines)
pagesMutex := sync.Mutex{}
var pages []*manga.Page
var totalPages = uint32(len(chapter.Pages))
log.Debug().
Str("chapter", chapter.FilePath).
Int("total_pages", len(chapter.Pages)).
Int("worker_count", maxGoroutines).
Msg("Initialized conversion worker pool")
// Check if context is already cancelled
select {
case <-ctx.Done():
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
return nil, ctx.Err()
default:
}
guard := converter.pageWorkerGuard
var totalPages atomic.Uint32
totalPages.Store(uint32(len(chapter.Pages)))
type pageResult struct {
pages []*manga.PageFile
err error
}
results := make([]pageResult, len(chapter.Pages))
var wg sync.WaitGroup
var convertedCount atomic.Uint32
for i, page := range chapter.Pages {
wg.Add(1)
go func(idx int, p *manga.PageFile) {
defer wg.Done()
// Check context before acquiring worker slot
// Start the worker pool
go func() {
defer close(doneChan)
for page := range pagesChan {
select {
case <-ctx.Done():
results[idx] = pageResult{err: ctx.Err()}
return
case guard <- struct{}{}:
case guard <- struct{}{}: // would block if guard channel is already filled
}
defer func() { <-guard }()
// Check context after acquiring worker slot
go func(pageToConvert *manga.PageContainer) {
defer func() {
wgConvertedPages.Done()
<-guard
}()
// Check context cancellation before processing
select {
case <-ctx.Done():
results[idx] = pageResult{err: ctx.Err()}
return
default:
}
pages, err := converter.convertPageFile(ctx, p, outputDir, quality, split)
results[idx] = pageResult{pages: pages, err: err}
convertedPage, err := converter.convertPage(pageToConvert, quality)
if err != nil {
if convertedPage == nil {
select {
case errChan <- err:
case <-ctx.Done():
return
}
return
}
buffer := new(bytes.Buffer)
err := png.Encode(buffer, convertedPage.Image)
if err != nil {
select {
case errChan <- err:
case <-ctx.Done():
return
}
return
}
convertedPage.Page.Contents = buffer
convertedPage.Page.Extension = ".png"
convertedPage.Page.Size = uint64(buffer.Len())
}
pagesMutex.Lock()
defer pagesMutex.Unlock()
pages = append(pages, convertedPage.Page)
currentTotalPages := atomic.LoadUint32(&totalPages)
progress(fmt.Sprintf("Converted %d/%d pages to %s format", len(pages), currentTotalPages, converter.Format()), uint32(len(pages)), currentTotalPages)
}(page)
}
}()
current := convertedCount.Add(1)
total := totalPages.Load()
progress(fmt.Sprintf("Converted %d/%d pages to %s format", current, total, converter.Format()), current, total)
}(i, page)
// Process pages
for _, page := range chapter.Pages {
select {
case <-ctx.Done():
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
return nil, ctx.Err()
default:
}
// Wait for completion or context cancellation
go func(page *manga.Page) {
defer wgPages.Done()
splitNeeded, img, format, err := converter.checkPageNeedsSplit(page, split)
if err != nil {
var pageIgnoredError *converterrors.PageIgnoredError
if errors.As(err, &pageIgnoredError) {
log.Info().Err(err).Msg("Page ignored due to image decode error")
}
select {
case errChan <- err:
case <-ctx.Done():
return
}
wgConvertedPages.Add(1)
select {
case pagesChan <- manga.NewContainer(page, img, format, false):
case <-ctx.Done():
wgConvertedPages.Done()
return
}
return
}
if !splitNeeded {
wgConvertedPages.Add(1)
select {
case pagesChan <- manga.NewContainer(page, img, format, true):
case <-ctx.Done():
wgConvertedPages.Done()
return
}
return
}
images, err := converter.cropImage(img)
if err != nil {
select {
case errChan <- err:
case <-ctx.Done():
return
}
return
}
atomic.AddUint32(&totalPages, uint32(len(images)-1))
for i, img := range images {
select {
case <-ctx.Done():
return
default:
}
newPage := &manga.Page{
Index: page.Index,
IsSplitted: true,
SplitPartIndex: uint16(i),
}
wgConvertedPages.Add(1)
select {
case pagesChan <- manga.NewContainer(newPage, img, "N/A", true):
case <-ctx.Done():
wgConvertedPages.Done()
return
}
}
}(page)
}
wgPages.Wait()
close(pagesChan)
// Wait for all conversions to complete or context cancellation
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
defer close(done)
wgConvertedPages.Wait()
}()
select {
case <-done:
// Conversion completed successfully
case <-ctx.Done():
// Wait for in-flight goroutines to finish
<-done
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
return nil, ctx.Err()
}
// Collect results — separate fatal errors from page-ignored errors
var convertedPages []*manga.PageFile
var fatalErrors []error
var ignoredErrors []error
close(errChan)
close(guard)
for _, result := range results {
if result.err != nil {
if errors.Is(result.err, context.DeadlineExceeded) || errors.Is(result.err, context.Canceled) {
return nil, result.err
var errList []error
for err := range errChan {
errList = append(errList, err)
}
var pageIgnored *converterrors.PageIgnoredError
if errors.As(result.err, &pageIgnored) {
ignoredErrors = append(ignoredErrors, result.err)
var aggregatedError error = nil
if len(errList) > 0 {
aggregatedError = errors.Join(errList...)
log.Debug().
Str("chapter", chapter.FilePath).
Int("error_count", len(errList)).
Err(errors.Join(errList...)).
Msg("Conversion completed with errors")
} else {
fatalErrors = append(fatalErrors, result.err)
}
}
if result.pages != nil {
convertedPages = append(convertedPages, result.pages...)
}
log.Debug().
Str("chapter", chapter.FilePath).
Int("pages_converted", len(pages)).
Msg("Conversion completed successfully")
}
// Fatal errors take priority over ignored-page errors
if len(fatalErrors) > 0 {
return nil, errors.Join(fatalErrors...)
}
if len(convertedPages) == 0 {
if len(ignoredErrors) > 0 {
return nil, errors.Join(ignoredErrors...)
}
return nil, fmt.Errorf("no pages were converted")
}
// Sort pages by index and split part
sort.Slice(convertedPages, func(i, j int) bool {
a, b := convertedPages[i], convertedPages[j]
slices.SortFunc(pages, func(a, b *manga.Page) int {
if a.Index == b.Index {
return a.SplitPartIndex < b.SplitPartIndex
return int(a.SplitPartIndex) - int(b.SplitPartIndex)
}
return a.Index < b.Index
return int(a.Index) - int(b.Index)
})
chapter.Pages = convertedPages
var aggregatedError error
if len(ignoredErrors) > 0 {
aggregatedError = errors.Join(ignoredErrors...)
}
chapter.Pages = pages
log.Debug().
Str("chapter", chapter.FilePath).
Int("converted_pages", len(convertedPages)).
Msg("Chapter conversion completed")
Int("final_page_count", len(pages)).
Msg("Pages sorted and chapter updated")
runtime.GC()
log.Debug().Str("chapter", chapter.FilePath).Msg("Garbage collection completed")
return chapter, aggregatedError
}
// convertPageFile converts a single page file to WebP format.
// Returns the converted page(s) — multiple if splitting was needed.
func (converter *Converter) convertPageFile(ctx context.Context, page *manga.PageFile, outputDir string, quality uint8, split bool) ([]*manga.PageFile, error) {
log.Debug().
Uint16("page_index", page.Index).
Str("input", page.FilePath).
Msg("Converting page file")
// If the page is already WebP, just return it as-is. The returned page
// keeps any OriginalName set during extraction so the archive writer can
// honor --keep-filenames for the final entry name.
if strings.ToLower(page.Extension) == ".webp" {
log.Debug().Uint16("page_index", page.Index).Msg("Page already WebP, skipping")
return []*manga.PageFile{page}, nil
}
// Try direct file-to-file conversion first (happy path — no memory allocation)
outputPath := filepath.Join(outputDir, intermediatePageName(page, ""))
err := EncodeFile(page.FilePath, outputPath, uint(quality))
if err == nil {
// Success! No image decoding needed. Preserve OriginalName so
// --keep-filenames carries through to the final zip entry name.
return []*manga.PageFile{{
Index: page.Index,
Extension: ".webp",
FilePath: outputPath,
OriginalName: page.OriginalName,
}}, nil
}
// Direct conversion failed. Check if it's a dimension issue.
log.Debug().
Uint16("page_index", page.Index).
Err(err).
Msg("Direct conversion failed, checking dimensions")
// Read just the image header to get dimensions (no full decode)
width, height, decodeErr := getImageDimensions(page.FilePath)
if decodeErr != nil {
// Can't even read the image header — keep the original file
log.Info().
Uint16("page_index", page.Index).
Err(decodeErr).
Msg("Cannot decode image, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d: failed to decode image (%s)", page.Index, decodeErr.Error()))
}
log.Debug().
Uint16("page_index", page.Index).
Int("width", width).
Int("height", height).
Msg("Image dimensions read")
// If height exceeds WebP max and split is not enabled, keep original
if height >= webpMaxHeight && !split {
log.Info().
Uint16("page_index", page.Index).
Int("height", height).
Msg("Page too tall for WebP, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d is too tall [max: %dpx] to be converted to webp format", page.Index, webpMaxHeight))
}
// If height exceeds our split threshold and split is enabled, use cwebp -crop
if height >= converter.maxHeight && split {
return converter.splitAndConvert(ctx, page, outputDir, quality, width, height)
}
// Height is within limits but conversion still failed for another reason.
// Keep the original file.
log.Warn().
Uint16("page_index", page.Index).
Err(err).
Msg("Conversion failed for non-dimension reason, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d: conversion failed (%s)", page.Index, err.Error()))
}
// splitAndConvert splits a tall image into multiple parts using cwebp -crop
// and converts each part. No Go-side image decode is needed.
func (converter *Converter) splitAndConvert(ctx context.Context, page *manga.PageFile, outputDir string, quality uint8, width, height int) ([]*manga.PageFile, error) {
log.Debug().
Uint16("page_index", page.Index).
Int("width", width).
Int("height", height).
Int("crop_height", converter.cropHeight).
Msg("Splitting and converting page using cwebp -crop")
func (converter *Converter) cropImage(img image.Image) ([]image.Image, error) {
bounds := img.Bounds()
height := bounds.Dy()
width := bounds.Dx()
numParts := height / converter.cropHeight
if height%converter.cropHeight != 0 {
numParts++
}
var pages []*manga.PageFile
log.Debug().
Int("original_width", width).
Int("original_height", height).
Int("crop_height", converter.cropHeight).
Int("num_parts", numParts).
Msg("Starting image cropping for page splitting")
parts := make([]image.Image, numParts)
for i := 0; i < numParts; i++ {
select {
case <-ctx.Done():
return pages, ctx.Err()
default:
}
yOffset := i * converter.cropHeight
partHeight := converter.cropHeight
if i == numParts-1 {
partHeight = height - yOffset
}
outputPath := filepath.Join(outputDir, intermediatePageName(page, fmt.Sprintf("-%02d", i)))
err := EncodeFileWithCrop(page.FilePath, outputPath, uint(quality), 0, yOffset, width, partHeight)
if err != nil {
log.Error().
Uint16("page_index", page.Index).
Int("part", i).
Err(err).
Msg("Failed to convert split part")
return nil, fmt.Errorf("failed to convert split part %d of page %d: %w", i, page.Index, err)
}
pages = append(pages, &manga.PageFile{
Index: page.Index,
Extension: ".webp",
FilePath: outputPath,
IsSplitted: true,
SplitPartIndex: uint16(i),
OriginalName: page.OriginalName,
})
partHeight = height - i*converter.cropHeight
}
log.Debug().
Int("part_index", i).
Int("part_height", partHeight).
Int("y_offset", i*converter.cropHeight).
Msg("Cropping image part")
part, err := cutter.Crop(img, cutter.Config{
Width: bounds.Dx(),
Height: partHeight,
Anchor: image.Point{Y: i * converter.cropHeight},
Mode: cutter.TopLeft,
})
if err != nil {
log.Error().
Int("part_index", i).
Err(err).
Msg("Failed to crop image part")
return nil, fmt.Errorf("error cropping part %d: %v", i+1, err)
}
parts[i] = part
log.Debug().
Int("part_index", i).
Int("cropped_width", part.Bounds().Dx()).
Int("cropped_height", part.Bounds().Dy()).
Msg("Image part cropped successfully")
}
log.Debug().
Int("total_parts", len(parts)).
Msg("Image cropping completed")
return parts, nil
}
func (converter *Converter) checkPageNeedsSplit(page *manga.Page, splitRequested bool) (bool, image.Image, string, error) {
log.Debug().
Uint16("page_index", page.Index).
Int("parts", len(pages)).
Msg("Split conversion completed")
Bool("split_requested", splitRequested).
Int("page_size", len(page.Contents.Bytes())).
Msg("Analyzing page for splitting")
return pages, nil
}
// getImageDimensions reads only the image header to determine dimensions.
// This is much cheaper than a full image.Decode — only a few bytes are read.
func getImageDimensions(filePath string) (width, height int, err error) {
f, err := os.Open(filePath)
reader := bytes.NewBuffer(page.Contents.Bytes())
img, format, err := image.Decode(reader)
if err != nil {
return 0, 0, err
}
defer func() { _ = f.Close() }()
config, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
log.Debug().Uint16("page_index", page.Index).Err(err).Msg("Failed to decode page image")
return false, nil, format, converterrors.NewPageIgnored(fmt.Sprintf("page %d: failed to decode image (%s)", page.Index, err.Error()))
}
return config.Width, config.Height, nil
bounds := img.Bounds()
height := bounds.Dy()
width := bounds.Dx()
log.Debug().
Uint16("page_index", page.Index).
Int("width", width).
Int("height", height).
Str("format", format).
Int("max_height", converter.maxHeight).
Int("webp_max_height", webpMaxHeight).
Msg("Page dimensions analyzed")
if height >= webpMaxHeight && !splitRequested {
log.Debug().
Uint16("page_index", page.Index).
Int("height", height).
Int("webp_max", webpMaxHeight).
Msg("Page too tall for WebP format, would be ignored")
return false, img, format, converterrors.NewPageIgnored(fmt.Sprintf("page %d is too tall [max: %dpx] to be converted to webp format", page.Index, webpMaxHeight))
}
needsSplit := height >= converter.maxHeight && splitRequested
log.Debug().
Uint16("page_index", page.Index).
Bool("needs_split", needsSplit).
Msg("Page splitting decision made")
return needsSplit, img, format, nil
}
func (converter *Converter) convertPage(container *manga.PageContainer, quality uint8) (*manga.PageContainer, error) {
log.Debug().
Uint16("page_index", container.Page.Index).
Str("format", container.Format).
Bool("to_be_converted", container.IsToBeConverted).
Uint8("quality", quality).
Msg("Converting page")
// Fix WebP format detection (case insensitive)
if container.Format == "webp" || container.Format == "WEBP" {
log.Debug().
Uint16("page_index", container.Page.Index).
Msg("Page already in WebP format, skipping conversion")
container.Page.Extension = ".webp"
return container, nil
}
if !container.IsToBeConverted {
log.Debug().
Uint16("page_index", container.Page.Index).
Msg("Page marked as not to be converted, skipping")
return container, nil
}
log.Debug().
Uint16("page_index", container.Page.Index).
Uint8("quality", quality).
Msg("Encoding page to WebP format")
converted, err := converter.convert(container.Image, uint(quality))
if err != nil {
log.Error().
Uint16("page_index", container.Page.Index).
Err(err).
Msg("Failed to convert page to WebP")
return nil, err
}
container.SetConverted(converted, ".webp")
log.Debug().
Uint16("page_index", container.Page.Index).
Int("original_size", len(container.Page.Contents.Bytes())).
Int("converted_size", len(converted.Bytes())).
Msg("Page conversion completed")
return container, nil
}
// convert converts an image to the WebP format. It decodes the image from the input buffer,
// encodes it as a WebP file using the webp.Encode() function, and returns the resulting WebP
// file as a bytes.Buffer.
func (converter *Converter) convert(image image.Image, quality uint) (*bytes.Buffer, error) {
var buf bytes.Buffer
err := Encode(&buf, image, quality)
if err != nil {
return nil, err
}
return &buf, nil
}
+399 -324
View File
@@ -1,61 +1,127 @@
package webp
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/gif"
"image/jpeg"
"os"
"path/filepath"
"image/png"
"sync"
"testing"
"time"
_ "golang.org/x/image/webp"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTestImageFile(t *testing.T, path string, width, height int) {
t.Helper()
func createTestImage(width, height int, format string) (image.Image, error) {
img := image.NewRGBA(image.Rect(0, 0, width, height))
f, err := os.Create(path)
require.NoError(t, err)
err = jpeg.Encode(f, img, nil)
require.NoError(t, err)
_ = f.Close()
}
func createTestChapter(t *testing.T, pages []struct{ w, h int }) (*manga.Chapter, string) {
t.Helper()
dir := t.TempDir()
inputDir := filepath.Join(dir, "input")
require.NoError(t, os.MkdirAll(inputDir, 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "output"), 0755))
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
}
for i, p := range pages {
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.jpg", i))
createTestImageFile(t, pagePath, p.w, p.h)
chapter.Pages = append(chapter.Pages, &manga.PageFile{
Index: uint16(i),
Extension: ".jpg",
FilePath: pagePath,
// Create a gradient pattern to ensure we have actual image data
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{
R: uint8((x * 255) / width),
G: uint8((y * 255) / height),
B: 100,
A: 255,
})
}
return chapter, dir
}
return img, nil
}
func encodeImage(img image.Image, format string) (*bytes.Buffer, string, error) {
buf := new(bytes.Buffer)
switch format {
case "jpeg", "jpg":
if err := jpeg.Encode(buf, img, &jpeg.Options{Quality: 85}); err != nil {
return nil, "", err
}
return buf, ".jpg", nil
case "gif":
if err := gif.Encode(buf, img, nil); err != nil {
return nil, "", err
}
return buf, ".gif", nil
case "webp":
PrepareEncoder()
if err := Encode(buf, img, 80); err != nil {
return nil, "", err
}
return buf, ".webp", nil
case "png":
fallthrough
default:
if err := png.Encode(buf, img); err != nil {
return nil, "", err
}
return buf, ".png", nil
}
}
func createTestPage(t *testing.T, index int, width, height int, format string) *manga.Page {
img, err := createTestImage(width, height, format)
require.NoError(t, err)
buf, ext, err := encodeImage(img, format)
require.NoError(t, err)
return &manga.Page{
Index: uint16(index),
Contents: buf,
Extension: ext,
Size: uint64(buf.Len()),
}
}
func validateConvertedImage(t *testing.T, page *manga.Page) {
require.NotNil(t, page.Contents)
require.Greater(t, page.Contents.Len(), 0)
// Try to decode the image
img, format, err := image.Decode(bytes.NewReader(page.Contents.Bytes()))
require.NoError(t, err, "Failed to decode converted image")
if page.Extension == ".webp" {
assert.Equal(t, "webp", format, "Expected WebP format")
}
require.NotNil(t, img)
bounds := img.Bounds()
assert.Greater(t, bounds.Dx(), 0, "Image width should be positive")
assert.Greater(t, bounds.Dy(), 0, "Image height should be positive")
}
// TestConverter_ConvertChapter tests the ConvertChapter method of the WebP converter.
// It verifies various scenarios including:
// - Converting single normal images
// - Converting multiple normal images
// - Converting tall images with split enabled
// - Handling tall images that exceed maximum height
//
// For each test case it validates:
// - Proper error handling
// - Expected number of output pages
// - Correct page ordering
// - Split page handling and indexing
// - Progress callback behavior
//
// The test uses different image dimensions and split settings to ensure
// the converter handles all cases correctly while maintaining proper
// progress reporting and page ordering.
func TestConverter_ConvertChapter(t *testing.T) {
tests := []struct {
name string
pages []struct{ w, h int }
pages []*manga.Page
split bool
expectSplit bool
expectError bool
@@ -63,33 +129,47 @@ func TestConverter_ConvertChapter(t *testing.T) {
}{
{
name: "Single normal image",
pages: []struct{ w, h int }{{800, 1200}},
pages: []*manga.Page{createTestPage(t, 1, 800, 1200, "jpeg")},
split: false,
expectSplit: false,
numExpected: 1,
},
{
name: "Multiple normal images",
pages: []struct{ w, h int }{
{800, 1200},
{800, 1200},
{800, 1200},
pages: []*manga.Page{
createTestPage(t, 1, 800, 1200, "png"),
createTestPage(t, 2, 800, 1200, "jpeg"),
createTestPage(t, 3, 800, 1200, "gif"),
},
split: false,
expectSplit: false,
numExpected: 3,
},
{
name: "Multiple normal images with webp",
pages: []*manga.Page{
createTestPage(t, 1, 800, 1200, "png"),
createTestPage(t, 2, 800, 1200, "jpeg"),
createTestPage(t, 3, 800, 1200, "gif"),
createTestPage(t, 4, 800, 1200, "webp"),
},
split: false,
expectSplit: false,
numExpected: 4,
},
{
name: "Tall image with split enabled",
pages: []struct{ w, h int }{{800, 5000}},
pages: []*manga.Page{createTestPage(t, 1, 800, 5000, "jpeg")},
split: true,
expectSplit: false, // cwebp handles 5000px fine (< 16383 webp max), no split needed
numExpected: 1,
expectSplit: true,
numExpected: 3, // Based on cropHeight of 2000
},
{
name: "Tall image without split",
pages: []struct{ w, h int }{{800, webpMaxHeight + 100}},
pages: []*manga.Page{createTestPage(t, 1, 800, webpMaxHeight+100, "png")},
split: false,
expectError: true,
numExpected: 1, // kept as-is
numExpected: 1,
},
}
@@ -99,16 +179,18 @@ func TestConverter_ConvertChapter(t *testing.T) {
err := converter.PrepareConverter()
require.NoError(t, err)
chapter, _ := createTestChapter(t, tt.pages)
chapter := &manga.Chapter{
Pages: tt.pages,
}
var progressMutex sync.Mutex
var lastProgress uint32
progress := func(message string, current uint32, total uint32) {
progressMutex.Lock()
defer progressMutex.Unlock()
assert.GreaterOrEqual(t, current, lastProgress)
assert.GreaterOrEqual(t, current, lastProgress, "Progress should never decrease")
lastProgress = current
assert.LessOrEqual(t, current, total)
assert.LessOrEqual(t, current, total, "Current progress should not exceed total")
}
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, tt.split, progress)
@@ -125,14 +207,22 @@ func TestConverter_ConvertChapter(t *testing.T) {
require.NotNil(t, convertedChapter)
assert.Len(t, convertedChapter.Pages, tt.numExpected)
// Validate all converted images
for _, page := range convertedChapter.Pages {
validateConvertedImage(t, page)
}
// Verify page order
for i := 1; i < len(convertedChapter.Pages); i++ {
prev := convertedChapter.Pages[i-1]
curr := convertedChapter.Pages[i]
if prev.Index == curr.Index {
assert.Less(t, prev.SplitPartIndex, curr.SplitPartIndex)
prevPage := convertedChapter.Pages[i-1]
currPage := convertedChapter.Pages[i]
if prevPage.Index == currPage.Index {
assert.Less(t, prevPage.SplitPartIndex, currPage.SplitPartIndex,
"Split parts should be in ascending order for page %d", prevPage.Index)
} else {
assert.Less(t, prev.Index, curr.Index)
assert.Less(t, prevPage.Index, currPage.Index,
"Pages should be in ascending order")
}
}
@@ -144,13 +234,149 @@ func TestConverter_ConvertChapter(t *testing.T) {
break
}
}
assert.True(t, splitFound, "Expected split pages")
assert.True(t, splitFound, "Expected to find at least one split page")
}
})
}
}
func TestConverter_convertPage(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
tests := []struct {
name string
format string
isToBeConverted bool
expectWebP bool
expectError bool
}{
{
name: "Convert PNG to WebP",
format: "png",
isToBeConverted: true,
expectWebP: true,
expectError: false,
},
{
name: "Convert GIF to WebP",
format: "gif",
isToBeConverted: true,
expectWebP: true,
expectError: false,
},
{
name: "Already WebP",
format: "webp",
isToBeConverted: true,
expectWebP: true,
expectError: false,
},
{
name: "Skip conversion",
format: "png",
isToBeConverted: false,
expectWebP: false,
expectError: false,
},
}
// Verify all output files exist
for _, page := range convertedChapter.Pages {
assert.FileExists(t, page.FilePath)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := createTestPage(t, 1, 100, 100, tt.format)
img, err := createTestImage(100, 100, tt.format)
require.NoError(t, err)
container := manga.NewContainer(page, img, tt.format, tt.isToBeConverted)
converted, err := converter.convertPage(container, 80)
if tt.expectError {
assert.Error(t, err)
assert.Nil(t, converted)
} else {
require.NoError(t, err)
assert.NotNil(t, converted)
if tt.expectWebP {
assert.Equal(t, ".webp", converted.Page.Extension)
validateConvertedImage(t, converted.Page)
} else {
assert.NotEqual(t, ".webp", converted.Page.Extension)
}
}
})
}
}
func TestConverter_convertPage_EncodingError(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
// Create a test case with nil image to test encoding error path
// when isToBeConverted is true but the image is nil, simulating a failure in the encoding step
corruptedPage := &manga.Page{
Index: 1,
Contents: &bytes.Buffer{}, // Empty buffer
Extension: ".png",
Size: 0,
}
container := manga.NewContainer(corruptedPage, nil, "png", true)
converted, err := converter.convertPage(container, 80)
// This should return nil container and error because encoding will fail with nil image
assert.Error(t, err)
assert.Nil(t, converted)
}
func TestConverter_checkPageNeedsSplit(t *testing.T) {
converter := New()
tests := []struct {
name string
imageHeight int
split bool
expectSplit bool
expectError bool
}{
{
name: "Normal height",
imageHeight: 1000,
split: true,
expectSplit: false,
},
{
name: "Height exceeds max with split enabled",
imageHeight: 5000,
split: true,
expectSplit: true,
},
{
name: "Height exceeds webp max without split",
imageHeight: webpMaxHeight + 100,
split: false,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := createTestPage(t, 1, 800, tt.imageHeight, "jpeg")
needsSplit, img, format, err := converter.checkPageNeedsSplit(page, tt.split)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.NotNil(t, img)
assert.NotEmpty(t, format)
assert.Equal(t, tt.expectSplit, needsSplit)
})
}
}
@@ -165,38 +391,120 @@ func TestConverter_ConvertChapter_Timeout(t *testing.T) {
err := converter.PrepareConverter()
require.NoError(t, err)
chapter, _ := createTestChapter(t, []struct{ w, h int }{
{800, 1200},
{800, 1200},
{800, 1200},
})
// Create a test chapter with a few pages
pages := []*manga.Page{
createTestPage(t, 1, 800, 1200, "jpeg"),
createTestPage(t, 2, 800, 1200, "png"),
createTestPage(t, 3, 800, 1200, "gif"),
}
progress := func(message string, current uint32, total uint32) {}
chapter := &manga.Chapter{
FilePath: "/test/chapter.cbz",
Pages: pages,
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
var progressMutex sync.Mutex
var lastProgress uint32
progress := func(message string, current uint32, total uint32) {
progressMutex.Lock()
defer progressMutex.Unlock()
assert.GreaterOrEqual(t, current, lastProgress, "Progress should never decrease")
lastProgress = current
assert.LessOrEqual(t, current, total, "Current progress should not exceed total")
}
// Test with very short timeout (1 nanosecond)
ctx, cancel := context.WithTimeout(context.Background(), 1)
defer cancel()
convertedChapter, err := converter.ConvertChapter(ctx, chapter, 80, false, progress)
// Should return context error due to timeout
assert.Error(t, err)
assert.Nil(t, convertedChapter)
assert.Equal(t, context.DeadlineExceeded, err)
}
// TestConverter_ConvertChapter_ManyPages_NoDeadlock tests that converting chapters with many pages
// does not cause a deadlock. This test reproduces the scenario where processing
// many files with context cancellation could cause "all goroutines are asleep - deadlock!" error.
// The fix ensures that wgConvertedPages.Done() is called when context is cancelled after Add(1).
func TestConverter_ConvertChapter_ManyPages_NoDeadlock(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
pages := make([]struct{ w, h int }, 50)
for i := range pages {
pages[i] = struct{ w, h int }{100, 100}
// Create a chapter with many pages to increase the chance of hitting the race condition
numPages := 50
pages := make([]*manga.Page, numPages)
for i := 0; i < numPages; i++ {
pages[i] = createTestPage(t, i+1, 100, 100, "jpeg")
}
chapter, _ := createTestChapter(t, pages)
chapter := &manga.Chapter{
FilePath: "/test/chapter_many_pages.cbz",
Pages: pages,
}
progress := func(message string, current uint32, total uint32) {}
progress := func(message string, current uint32, total uint32) {
// No-op progress callback
}
// Run multiple iterations to increase the chance of hitting the race condition
for iteration := 0; iteration < 10; iteration++ {
t.Run(fmt.Sprintf("iteration_%d", iteration), func(t *testing.T) {
// Use a very short timeout to trigger context cancellation during processing
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
defer cancel()
// This should NOT deadlock - it should return quickly with context error
done := make(chan struct{})
var convertErr error
go func() {
defer close(done)
_, convertErr = converter.ConvertChapter(ctx, chapter, 80, false, progress)
}()
// Wait with a reasonable timeout - if it takes longer than 5 seconds, we have a deadlock
select {
case <-done:
// Expected - conversion should complete (with error) quickly
assert.Error(t, convertErr, "Expected context error")
case <-time.After(5 * time.Second):
t.Fatal("Deadlock detected: ConvertChapter did not return within 5 seconds")
}
})
}
}
// TestConverter_ConvertChapter_ManyPages_WithSplit_NoDeadlock tests that converting chapters
// with many pages and split enabled does not cause a deadlock.
func TestConverter_ConvertChapter_ManyPages_WithSplit_NoDeadlock(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
// Create pages with varying heights, some requiring splits
numPages := 30
pages := make([]*manga.Page, numPages)
for i := 0; i < numPages; i++ {
height := 1000 // Normal height
if i%5 == 0 {
height = 5000 // Tall image that will be split
}
pages[i] = createTestPage(t, i+1, 100, height, "png")
}
chapter := &manga.Chapter{
FilePath: "/test/chapter_split_test.cbz",
Pages: pages,
}
progress := func(message string, current uint32, total uint32) {
// No-op progress callback
}
// Run multiple iterations with short timeouts
for iteration := 0; iteration < 10; iteration++ {
t.Run(fmt.Sprintf("iteration_%d", iteration), func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
@@ -206,60 +514,72 @@ func TestConverter_ConvertChapter_ManyPages_NoDeadlock(t *testing.T) {
var convertErr error
go func() {
defer close(done)
_, convertErr = converter.ConvertChapter(ctx, chapter, 80, false, progress)
_, convertErr = converter.ConvertChapter(ctx, chapter, 80, true, progress) // split=true
}()
select {
case <-done:
assert.Error(t, convertErr)
assert.Error(t, convertErr, "Expected context error")
case <-time.After(5 * time.Second):
t.Fatal("Deadlock detected")
t.Fatal("Deadlock detected: ConvertChapter with split did not return within 5 seconds")
}
})
}
}
// TestConverter_ConvertChapter_ConcurrentChapters_NoDeadlock simulates the scenario from the
// original bug report where multiple chapters are processed in parallel with parallelism > 1.
// This test ensures no deadlock occurs when multiple goroutines are converting chapters concurrently.
func TestConverter_ConvertChapter_ConcurrentChapters_NoDeadlock(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
// Create multiple chapters, each with many pages
numChapters := 20
pagesPerChapter := 30
chapters := make([]*manga.Chapter, numChapters)
pages := make([]struct{ w, h int }, 30)
for i := range pages {
pages[i] = struct{ w, h int }{100, 100}
}
for c := 0; c < numChapters; c++ {
chapters[c], _ = createTestChapter(t, pages)
pages := make([]*manga.Page, pagesPerChapter)
for i := 0; i < pagesPerChapter; i++ {
pages[i] = createTestPage(t, i+1, 100, 100, "jpeg")
}
chapters[c] = &manga.Chapter{
FilePath: fmt.Sprintf("/test/chapter_%d.cbz", c+1),
Pages: pages,
}
}
progress := func(message string, current uint32, total uint32) {}
// Process chapters concurrently with short timeouts (simulating parallelism flag)
parallelism := 4
var wg sync.WaitGroup
semaphore := make(chan struct{}, parallelism)
// Overall test timeout
testCtx, testCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer testCancel()
for _, chapter := range chapters {
wg.Add(1)
semaphore <- struct{}{}
semaphore <- struct{}{} // Acquire
go func(ch *manga.Chapter) {
defer wg.Done()
defer func() { <-semaphore }()
defer func() { <-semaphore }() // Release
// Use very short timeout to trigger cancellation
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
defer cancel()
// This should not deadlock
_, _ = converter.ConvertChapter(ctx, ch, 80, false, progress)
}(chapter)
}
// Wait for all conversions with a timeout
done := make(chan struct{})
go func() {
wg.Wait()
@@ -268,253 +588,8 @@ func TestConverter_ConvertChapter_ConcurrentChapters_NoDeadlock(t *testing.T) {
select {
case <-done:
// All goroutines completed successfully
case <-testCtx.Done():
t.Fatal("Deadlock detected")
}
}
func TestConverter_SplitAndConvert(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
// Create a very tall image that exceeds webpMaxHeight
dir := t.TempDir()
inputDir := filepath.Join(dir, "input")
require.NoError(t, os.MkdirAll(inputDir, 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "output"), 0755))
// Create image taller than webpMaxHeight (16383)
tallImagePath := filepath.Join(inputDir, "0000.jpg")
createTestImageFile(t, tallImagePath, 800, webpMaxHeight+500)
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: tallImagePath},
},
}
progress := func(message string, current uint32, total uint32) {}
// With split=true, the oversized page should be split
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, true, progress)
require.NoError(t, err)
require.NotNil(t, convertedChapter)
// Should have more pages due to splitting
assert.Greater(t, len(convertedChapter.Pages), 1, "Tall image should be split into multiple parts")
// All parts should be split and have webp extension
for _, page := range convertedChapter.Pages {
assert.True(t, page.IsSplitted, "All pages should be marked as split")
assert.Equal(t, ".webp", page.Extension)
assert.FileExists(t, page.FilePath)
assert.Equal(t, uint16(0), page.Index, "All split parts should have same original index")
}
// Verify sequential split part indices
for i, page := range convertedChapter.Pages {
assert.Equal(t, uint16(i), page.SplitPartIndex)
}
}
func TestConverter_OversizedImageNoSplit(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
dir := t.TempDir()
inputDir := filepath.Join(dir, "input")
require.NoError(t, os.MkdirAll(inputDir, 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "output"), 0755))
tallImagePath := filepath.Join(inputDir, "0000.jpg")
createTestImageFile(t, tallImagePath, 800, webpMaxHeight+500)
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: tallImagePath},
},
}
progress := func(message string, current uint32, total uint32) {}
// With split=false, the oversized page should be kept as-is with error
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, false, progress)
assert.Error(t, err, "Should return error for oversized page without split")
if convertedChapter != nil {
// The page should be kept in original format
for _, page := range convertedChapter.Pages {
assert.Equal(t, ".jpg", page.Extension, "Page should keep original extension")
}
}
}
func TestGetImageDimensions(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
width, height int
expectW, expectH int
}{
{"small image", 100, 200, 100, 200},
{"wide image", 1920, 1080, 1920, 1080},
{"tall image", 800, 5000, 800, 5000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(dir, tt.name+".jpg")
createTestImageFile(t, path, tt.width, tt.height)
w, h, err := getImageDimensions(path)
require.NoError(t, err)
assert.Equal(t, tt.expectW, w)
assert.Equal(t, tt.expectH, h)
})
}
}
func TestGetImageDimensions_NonexistentFile(t *testing.T) {
_, _, err := getImageDimensions("/nonexistent/file.jpg")
assert.Error(t, err)
}
func TestGetImageDimensions_InvalidFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "invalid.jpg")
require.NoError(t, os.WriteFile(path, []byte("not an image"), 0644))
_, _, err := getImageDimensions(path)
assert.Error(t, err)
}
func TestConverter_ConvertChapter_EmptyChapter(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
dir := t.TempDir()
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: []*manga.PageFile{},
}
progress := func(message string, current uint32, total uint32) {}
_, err = converter.ConvertChapter(context.Background(), chapter, 80, false, progress)
assert.Error(t, err, "Should error on empty chapter")
}
func TestConverter_ConvertChapter_PreservesComicInfo(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
chapter, _ := createTestChapter(t, []struct{ w, h int }{{400, 600}})
chapter.ComicInfoXml = `<?xml version="1.0"?><ComicInfo><Series>Test</Series></ComicInfo>`
progress := func(message string, current uint32, total uint32) {}
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, false, progress)
require.NoError(t, err)
require.NotNil(t, convertedChapter)
assert.Equal(t, chapter.ComicInfoXml, convertedChapter.ComicInfoXml)
}
func TestConverter_ConvertChapter_OutputInCorrectDir(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
chapter, dir := createTestChapter(t, []struct{ w, h int }{{400, 600}})
progress := func(message string, current uint32, total uint32) {}
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, false, progress)
require.NoError(t, err)
require.NotNil(t, convertedChapter)
// All output files should be in the output directory
expectedOutputDir := filepath.Join(dir, "output")
for _, page := range convertedChapter.Pages {
pageDir := filepath.Dir(page.FilePath)
assert.Equal(t, expectedOutputDir, pageDir, "Output file should be in output directory")
}
}
func TestEncodeFile(t *testing.T) {
err := PrepareEncoder()
require.NoError(t, err)
dir := t.TempDir()
inputPath := filepath.Join(dir, "input.jpg")
outputPath := filepath.Join(dir, "output.webp")
createTestImageFile(t, inputPath, 200, 300)
err = EncodeFile(inputPath, outputPath, 80)
require.NoError(t, err)
// Verify output exists and is non-empty
info, err := os.Stat(outputPath)
require.NoError(t, err)
assert.Greater(t, info.Size(), int64(0))
}
func TestEncodeFileWithCrop(t *testing.T) {
err := PrepareEncoder()
require.NoError(t, err)
dir := t.TempDir()
inputPath := filepath.Join(dir, "input.jpg")
outputPath := filepath.Join(dir, "output.webp")
createTestImageFile(t, inputPath, 200, 600)
err = EncodeFileWithCrop(inputPath, outputPath, 80, 0, 0, 200, 300)
require.NoError(t, err)
info, err := os.Stat(outputPath)
require.NoError(t, err)
assert.Greater(t, info.Size(), int64(0))
}
// TestIntermediatePageName_StemsAreUnique pins the contract that
// intermediatePageName must produce a different on-disk filename for two
// pages whose OriginalNames share a stem via the original archive entry
// name. ExtractChapter guarantees stem-unique OriginalNames per chapter
// (so, e.g., a/page.png and b/page.jpg come out as "page.png" and
// "page_0001.jpg"), and the converter must reflect that: stripping the
// extension and appending ".webp" must not collapse two distinct stems
// onto the same intermediate path.
func TestIntermediatePageName_StemsAreUnique(t *testing.T) {
tests := []struct {
name string
originalName string
splitSuffix string
}{
{name: "bare stem", originalName: "page.png", splitSuffix: ""},
{name: "indexed stem (post-fix)", originalName: "page_0001.jpg", splitSuffix: ""},
{name: "bare stem + split suffix", originalName: "page.png", splitSuffix: "-00"},
{name: "indexed stem + split suffix", originalName: "page_0001.jpg", splitSuffix: "-00"},
}
names := make(map[string]struct{}, len(tests))
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := &manga.PageFile{Index: 0, OriginalName: tt.originalName}
got := intermediatePageName(page, tt.splitSuffix)
if _, dup := names[got]; dup {
t.Errorf("intermediate name %q collides with an earlier page's intermediate name", got)
}
names[got] = struct{}{}
})
t.Fatal("Deadlock detected: Concurrent chapter conversions did not complete within 30 seconds")
}
}
+5 -16
View File
@@ -2,6 +2,8 @@ package webp
import (
"fmt"
"image"
"io"
"strings"
"sync"
@@ -35,23 +37,10 @@ func PrepareEncoder() error {
return nil
}
// EncodeFile converts an image file directly to WebP using cwebp.
// This is a zero-copy operation: no image data is loaded into Go memory.
func EncodeFile(inputPath string, outputPath string, quality uint) error {
func Encode(w io.Writer, m image.Image, quality uint) error {
return webpbin.NewCWebP(config).
Quality(quality).
InputFile(inputPath).
OutputFile(outputPath).
Run()
}
// EncodeFileWithCrop converts a cropped region of an image file to WebP.
// Uses cwebp's native -crop flag — no Go-side image decode needed.
func EncodeFileWithCrop(inputPath string, outputPath string, quality uint, x, y, width, height int) error {
return webpbin.NewCWebP(config).
Quality(quality).
Crop(x, y, width, height).
InputFile(inputPath).
OutputFile(outputPath).
InputImage(m).
Output(w).
Run()
}