mirror of
https://github.com/Belphemur/CBZOptimizer.git
synced 2026-07-21 19:05:39 +02:00
Compare commits
187
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56b70b038 | ||
|
|
650623b340 | ||
|
|
4b961f18e3 | ||
|
|
2dfc39505c | ||
|
|
e596a026dd | ||
|
|
9423cde9f7 | ||
|
|
facb2f192b | ||
|
|
5e5cac4883 | ||
|
|
5ae05e5f0c | ||
|
|
164dc577b9 | ||
|
|
e87e73eb28 | ||
|
|
d7782fdb25 | ||
|
|
1d981be21b | ||
|
|
0b3c3fa90c | ||
|
|
52c93acd35 | ||
|
|
ab1e2ff05c | ||
|
|
eca9671881 | ||
|
|
40922078d5 | ||
|
|
efb4dce1b7 | ||
|
|
30d0d632bf | ||
|
|
1e692ed19b | ||
|
|
69c1a37577 | ||
|
|
e1e8ea92c1 | ||
|
|
c2f809ed2c | ||
|
|
f2a65425cf | ||
|
|
20de3ed0ae | ||
|
|
b9170b14a1 | ||
|
|
18ff04c81f | ||
|
|
2663f17be3 | ||
|
|
dc9d33bbca | ||
|
|
cc7da1ef3b | ||
|
|
8c538ce95b | ||
|
|
c8524e9b5f | ||
|
|
cbad6e99ac | ||
|
|
65b9961856 | ||
|
|
d044573019 | ||
|
|
964a6ea295 | ||
|
|
02b15c8124 | ||
|
|
deeb30aa24 | ||
|
|
ad24ab68ac | ||
|
|
d78b523ae7 | ||
|
|
7b1fb13a21 | ||
|
|
414a43476b | ||
|
|
dbac6c20ff | ||
|
|
ef1a658028 | ||
|
|
76aea38ff2 | ||
|
|
a69eb9d8a5 | ||
|
|
a6875c3a12 | ||
|
|
b3686e17d8 | ||
|
|
6ea989eedd | ||
|
|
f104add37a | ||
|
|
39632906da | ||
|
|
1be0978e89 | ||
|
|
71bdf7d111 | ||
|
|
3123fa27f9 | ||
|
|
7cd711ab5a | ||
|
|
df3206b80c | ||
|
|
878da22fdb | ||
|
|
fcf6bf514b | ||
|
|
70a3306ca9 | ||
|
|
0ff3eab0b9 | ||
|
|
45851713c1 | ||
|
|
cca06642f8 | ||
|
|
a0a79c6439 | ||
|
|
6c45a8507f | ||
|
|
d66d445bbb | ||
|
|
aabb0e2a02 | ||
|
|
8349569e33 | ||
|
|
b434a18802 | ||
|
|
4057cfb6c9 | ||
|
|
617ee8a0c3 | ||
|
|
f37ca7e4e7 | ||
|
|
38c8f2a9cb | ||
|
|
03479c8772 | ||
|
|
ee47432721 | ||
|
|
5a0fe68e68 | ||
|
|
e535809e74 | ||
|
|
af5bfe8000 | ||
|
|
9ac9901990 | ||
|
|
8c52010dfe | ||
|
|
aefadafc7d | ||
|
|
ba1ab20697 | ||
|
|
43593c37fc | ||
|
|
44a4726258 | ||
|
|
e71a3d7693 | ||
|
|
992e37f9af | ||
|
|
a2f6805d47 | ||
|
|
552364f69c | ||
|
|
da65eeecba | ||
|
|
5d35a2e3fa | ||
|
|
1568334c03 | ||
|
|
31ef12bb17 | ||
|
|
9529004554 | ||
|
|
6a2efc42ac | ||
|
|
44e2469e34 | ||
|
|
9b6a733012 | ||
|
|
b80535d211 | ||
|
|
3a2fb2a97e | ||
|
|
c5de49a310 | ||
|
|
cd0f056648 | ||
|
|
a2feca6cca | ||
|
|
1fa54e1936 | ||
|
|
ce8aaba165 | ||
|
|
647b139ea0 | ||
|
|
16b3ce3c9b | ||
|
|
8d359aa575 | ||
|
|
97f89a51c6 | ||
|
|
6840de3a89 | ||
|
|
117b55eeaf | ||
|
|
287ae8df8b | ||
|
|
481da7c769 | ||
|
|
e269537049 | ||
|
|
cc4829cb39 | ||
|
|
65747d35c0 | ||
|
|
eb8803302c | ||
|
|
e60e30f5a0 | ||
|
|
7f5f690e66 | ||
|
|
f752586432 | ||
|
|
9a72d64a38 | ||
|
|
09655e225c | ||
|
|
90d75361a7 | ||
|
|
503fad46a6 | ||
|
|
e842b49535 | ||
|
|
86d20e14b1 | ||
|
|
7081f4aa1c | ||
|
|
6d8e1e2f5e | ||
|
|
77279cb0c5 | ||
|
|
82ab972c2e | ||
|
|
ae754ae5d8 | ||
|
|
507d8df103 | ||
|
|
545382c887 | ||
|
|
255b158778 | ||
|
|
4f9dacdaf6 | ||
|
|
3e62ab40e3 | ||
|
|
51af843432 | ||
|
|
6b92336ba1 | ||
|
|
a6ad1dada3 | ||
|
|
17fe01f27c | ||
|
|
4fa3014d80 | ||
|
|
a47af5a7a8 | ||
|
|
d7f13132f4 | ||
|
|
a8587f3f1f | ||
|
|
12817b1bff | ||
|
|
19dcf9d40b | ||
|
|
a7fa5bd0c7 | ||
|
|
9bde56d6c1 | ||
|
|
9c28923c35 | ||
|
|
b878390b46 | ||
|
|
41ff843a80 | ||
|
|
221945cb66 | ||
|
|
35bba7c088 | ||
|
|
b5a894deba | ||
|
|
7ad0256b46 | ||
|
|
f08e8dad7b | ||
|
|
54de9bcdeb | ||
|
|
0a7cc506fd | ||
|
|
fe8c5606fc | ||
|
|
9a8a9693fb | ||
|
|
7047710fdd | ||
|
|
88786d4e53 | ||
|
|
e0c8bf340b | ||
|
|
36b9ddc80f | ||
|
|
a380de3fe5 | ||
|
|
e47e21386f | ||
|
|
1b1be3a83a | ||
|
|
44a919e4f3 | ||
|
|
1b9d83d2ff | ||
|
|
ddc5121216 | ||
|
|
a361f22951 | ||
|
|
d245b80c65 | ||
|
|
011f7a7a7f | ||
|
|
f159d3d0d0 | ||
|
|
ede8d62572 | ||
|
|
a151a1d4f8 | ||
|
|
30ea3d4583 | ||
|
|
6205e3ea28 | ||
|
|
f6bdc3cd86 | ||
|
|
70257a0439 | ||
|
|
41108514d9 | ||
|
|
7e2bb7cf90 | ||
|
|
8ab75421b1 | ||
|
|
4894b14b90 | ||
|
|
9a29b6b45c | ||
|
|
fcc4ac57ca | ||
|
|
4cc33db553 | ||
|
|
d36c5cf0f1 | ||
|
|
ed70eb81cd |
@@ -0,0 +1,319 @@
|
||||
# CBZOptimizer - GitHub Copilot Instructions
|
||||
|
||||
## Project Overview
|
||||
|
||||
CBZOptimizer is a Go-based command-line tool designed to optimize CBZ (Comic Book Zip) and CBR (Comic Book RAR) files by converting images to modern formats (primarily WebP) with configurable quality settings. The tool reduces the size of comic book archives while maintaining acceptable image quality.
|
||||
|
||||
**Key Features:**
|
||||
- Convert CBZ/CBR files to optimized CBZ format
|
||||
- WebP image encoding with quality control
|
||||
- Parallel chapter processing
|
||||
- File watching for automatic optimization
|
||||
- Optional page splitting for large images
|
||||
- Timeout handling for problematic files
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Language:** Go 1.25+
|
||||
- **CLI Framework:** Cobra + Viper
|
||||
- **Logging:** zerolog (structured logging)
|
||||
- **Image Processing:** go-webpbin/v2 for WebP encoding
|
||||
- **Archive Handling:** mholt/archives for CBZ/CBR processing
|
||||
- **Testing:** testify + gotestsum
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/
|
||||
│ ├── cbzoptimizer/ # Main CLI application
|
||||
│ │ ├── commands/ # Cobra commands (optimize, watch)
|
||||
│ │ └── main.go # Entry point
|
||||
│ └── encoder-setup/ # WebP encoder setup utility
|
||||
│ └── main.go # Encoder initialization (build tag: encoder_setup)
|
||||
├── internal/
|
||||
│ ├── cbz/ # CBZ/CBR file operations
|
||||
│ │ ├── cbz_loader.go # Load and parse comic archives
|
||||
│ │ └── cbz_creator.go # Create optimized archives
|
||||
│ ├── manga/ # Domain models
|
||||
│ │ ├── chapter.go # Chapter representation
|
||||
│ │ ├── page.go # Page image handling
|
||||
│ │ └── page_container.go # Page collection management
|
||||
│ └── utils/ # Utility functions
|
||||
│ ├── optimize.go # Core optimization logic
|
||||
│ └── errs/ # Error handling utilities
|
||||
└── pkg/
|
||||
└── converter/ # Image conversion abstractions
|
||||
├── converter.go # Converter interface
|
||||
├── webp/ # WebP implementation
|
||||
│ ├── webp_converter.go # WebP conversion logic
|
||||
│ └── webp_provider.go # WebP encoder provider
|
||||
├── errors/ # Conversion error types
|
||||
└── constant/ # Shared constants
|
||||
```
|
||||
|
||||
## Building and Testing
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before building or testing, the WebP encoder must be set up:
|
||||
|
||||
```bash
|
||||
# Build the encoder-setup utility
|
||||
go build -tags encoder_setup -o encoder-setup ./cmd/encoder-setup
|
||||
|
||||
# Run encoder setup (downloads and configures libwebp 1.6.0)
|
||||
./encoder-setup
|
||||
```
|
||||
|
||||
This step is **required** before running tests or building the main application.
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Build the main application
|
||||
go build -o cbzconverter ./cmd/cbzoptimizer
|
||||
|
||||
# Build with version information
|
||||
go build -ldflags "-s -w -X main.version=1.0.0 -X main.commit=abc123 -X main.date=2024-01-01" -o cbzconverter ./cmd/cbzoptimizer
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Install test runner
|
||||
go install gotest.tools/gotestsum@latest
|
||||
|
||||
# Run all tests with coverage
|
||||
gotestsum --format testname -- -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
# Run specific package tests
|
||||
go test -v ./internal/cbz/...
|
||||
go test -v ./pkg/converter/...
|
||||
|
||||
# Run integration tests
|
||||
go test -v ./internal/utils/...
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Install golangci-lint if not available
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
# Run linter
|
||||
golangci-lint run
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Go Style
|
||||
|
||||
- **Follow standard Go conventions:** Use `gofmt` and `goimports`
|
||||
- **Package naming:** Short, lowercase, single-word names
|
||||
- **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:
|
||||
|
||||
```go
|
||||
import "github.com/rs/zerolog/log"
|
||||
|
||||
// Info level with structured fields
|
||||
log.Info().Str("file", path).Int("pages", count).Msg("Processing file")
|
||||
|
||||
// Debug level for detailed diagnostics
|
||||
log.Debug().Str("file", path).Uint8("quality", quality).Msg("Optimization parameters")
|
||||
|
||||
// Error level with error wrapping
|
||||
log.Error().Str("file", path).Err(err).Msg("Failed to load chapter")
|
||||
```
|
||||
|
||||
**Log Levels (in order of verbosity):**
|
||||
- `panic` - System panic conditions
|
||||
- `fatal` - Fatal errors requiring exit
|
||||
- `error` - Error conditions
|
||||
- `warn` - Warning conditions
|
||||
- `info` - General information (default)
|
||||
- `debug` - Debug-level messages
|
||||
- `trace` - Trace-level messages
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use the custom `errs` package for deferred error handling:
|
||||
```go
|
||||
import "github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
|
||||
|
||||
func processFile() (err error) {
|
||||
defer errs.Wrap(&err, "failed to process file")
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
- Define custom error types in `pkg/converter/errors/` for specific error conditions
|
||||
- Always provide context when wrapping errors
|
||||
|
||||
### Testing
|
||||
|
||||
- Use **testify** for assertions:
|
||||
```go
|
||||
import "github.com/stretchr/testify/assert"
|
||||
|
||||
func TestSomething(t *testing.T) {
|
||||
result, err := DoSomething()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
}
|
||||
```
|
||||
|
||||
- Use table-driven tests for multiple scenarios:
|
||||
```go
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
{"case1", "input1", "output1", false},
|
||||
{"case2", "input2", "output2", true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// test implementation
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- Integration tests should be in `*_integration_test.go` files
|
||||
- Use temporary directories for file operations in tests
|
||||
|
||||
### Command Structure (Cobra)
|
||||
|
||||
- Commands are in `cmd/cbzoptimizer/commands/`
|
||||
- Each command is in its own file (e.g., `optimize_command.go`, `watch_command.go`)
|
||||
- Use Cobra's persistent flags for global options
|
||||
- Use Viper for configuration management
|
||||
|
||||
### Dependencies
|
||||
|
||||
**Key external packages:**
|
||||
- `github.com/belphemur/go-webpbin/v2` - WebP encoding (libwebp wrapper)
|
||||
- `github.com/mholt/archives` - Archive format handling
|
||||
- `github.com/spf13/cobra` - CLI framework
|
||||
- `github.com/spf13/viper` - Configuration management
|
||||
- `github.com/rs/zerolog` - Structured logging
|
||||
- `github.com/oliamb/cutter` - Image cropping for page splitting
|
||||
- `golang.org/x/image` - Extended image format support
|
||||
|
||||
## Docker Considerations
|
||||
|
||||
The Dockerfile uses a multi-stage build and requires:
|
||||
1. The compiled `CBZOptimizer` binary (from goreleaser)
|
||||
2. The `encoder-setup` binary (built with `-tags encoder_setup`)
|
||||
3. The encoder-setup is run during image build to configure WebP encoder
|
||||
|
||||
The encoder must be set up in the container before the application runs.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Command
|
||||
|
||||
1. Create `cmd/cbzoptimizer/commands/newcommand_command.go`
|
||||
2. Define the command using Cobra:
|
||||
```go
|
||||
var newCmd = &cobra.Command{
|
||||
Use: "new",
|
||||
Short: "Description",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// implementation
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(newCmd)
|
||||
}
|
||||
```
|
||||
3. Add tests in `newcommand_command_test.go`
|
||||
|
||||
### Adding a New Image Format Converter
|
||||
|
||||
1. Create a new package under `pkg/converter/` (e.g., `avif/`)
|
||||
2. Implement the `Converter` interface from `pkg/converter/converter.go`
|
||||
3. Add tests following existing patterns in `pkg/converter/webp/`
|
||||
4. Update command flags to support the new format
|
||||
|
||||
### Modifying Optimization Logic
|
||||
|
||||
The core optimization logic is in `internal/utils/optimize.go`:
|
||||
- Uses the `OptimizeOptions` struct for parameters
|
||||
- Handles chapter loading, conversion, and saving
|
||||
- Implements timeout handling with context
|
||||
- Provides structured logging at each step
|
||||
|
||||
## CI/CD
|
||||
|
||||
### GitHub Actions Workflows
|
||||
|
||||
1. **test.yml** - Runs on every push/PR
|
||||
- Sets up Go environment
|
||||
- Runs encoder-setup
|
||||
- Executes tests with coverage
|
||||
- Uploads results to Codecov
|
||||
|
||||
2. **release.yml** - Runs on version tags
|
||||
- Uses goreleaser for multi-platform builds
|
||||
- Builds Docker images for linux/amd64 and linux/arm64
|
||||
- Signs releases with cosign
|
||||
- Generates SBOMs with syft
|
||||
|
||||
3. **qodana.yml** - Code quality analysis
|
||||
|
||||
### Release Process
|
||||
|
||||
Releases are automated via goreleaser:
|
||||
- Tag format: `v*` (e.g., `v2.1.0`)
|
||||
- Builds for: linux, darwin, windows (amd64, arm64)
|
||||
- Creates Docker images and pushes to ghcr.io
|
||||
- Generates checksums and SBOMs
|
||||
|
||||
## 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.
|
||||
- **Timeouts:** Use `--timeout` flag to prevent hanging on problematic files
|
||||
- **WebP Quality:** Balance quality (0-100) vs file size; default is 85
|
||||
|
||||
## Security
|
||||
|
||||
- No credentials or secrets should be committed
|
||||
- Archive extraction includes path traversal protection
|
||||
- File permissions are preserved during operations
|
||||
- Docker images run as non-root user (`abc`, UID 99)
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- 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
|
||||
- Bash completion is available via `cbzconverter completion bash`
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Use `--help` flag for command documentation
|
||||
- Use `--log debug` for detailed diagnostic output
|
||||
- Check GitHub Issues for known problems
|
||||
- Review test files for usage examples
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Copilot Setup Steps
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
name: Setup Go and gopls
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Verify Go installation
|
||||
run: |
|
||||
go version
|
||||
go env
|
||||
|
||||
- name: Install gopls
|
||||
run: |
|
||||
go install golang.org/x/tools/gopls@latest
|
||||
|
||||
- name: Verify gopls installation
|
||||
run: |
|
||||
gopls version
|
||||
|
||||
- name: Install golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Download Go dependencies
|
||||
run: |
|
||||
go mod download
|
||||
go mod verify
|
||||
|
||||
- name: Build encoder-setup utility
|
||||
run: |
|
||||
go build -tags encoder_setup -o encoder-setup ./cmd/encoder-setup
|
||||
ls -lh encoder-setup
|
||||
|
||||
- name: Run encoder-setup
|
||||
run: |
|
||||
./encoder-setup
|
||||
|
||||
- name: Install gotestsum
|
||||
run: |
|
||||
go install gotest.tools/gotestsum@latest
|
||||
|
||||
- name: Verify gotestsum installation
|
||||
run: |
|
||||
gotestsum --version
|
||||
|
||||
- name: Setup complete
|
||||
run: |
|
||||
echo "✅ Go environment setup complete"
|
||||
echo "✅ gopls (Go language server) installed"
|
||||
echo "✅ golangci-lint installed"
|
||||
echo "✅ Dependencies downloaded and verified"
|
||||
echo "✅ WebP encoder configured (libwebp 1.6.0)"
|
||||
echo "✅ gotestsum (test runner) installed"
|
||||
@@ -1,23 +0,0 @@
|
||||
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@v5
|
||||
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@v2024.1
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
@@ -20,33 +20,40 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # this is important, otherwise it won't checkout the full tree (i.e. no previous tags)
|
||||
- uses: actions/setup-go@v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: sigstore/cosign-installer@v3.9.2 # installs cosign
|
||||
- uses: anchore/sbom-action/download-syft@v0.20.5 # installs syft
|
||||
- name: Install Syft
|
||||
uses: anchore/sbom-action/download-syft@v0.24.0 # installs syft
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/login-action@v3 # login to ghcr
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v4 # login to ghcr
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: goreleaser/goreleaser-action@v6 # run goreleaser
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7 # run goreleaser
|
||||
with:
|
||||
version: nightly
|
||||
args: release --clean
|
||||
args: release --clean --verbose
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# After GoReleaser runs, attest all the files in ./dist/checksums.txt:
|
||||
- uses: actions/attest-build-provenance@v2
|
||||
- name: Attest Build Provenance for Archives
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
# After GoReleaser runs, attest all the images in ./dist/digests.txt:
|
||||
- uses: actions/attest-build-provenance@v2
|
||||
- name: Attest Build Provenance for Docker Images
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
+24
-27
@@ -6,49 +6,46 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Install gotestsum
|
||||
run: go install gotest.tools/gotestsum@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod tidy
|
||||
|
||||
- name: Install Junit reporter
|
||||
- name: Setup test environment
|
||||
run: |
|
||||
wget https://github.com/jstemmer/go-junit-report/releases/download/v2.1.0/go-junit-report-v2.1.0-linux-amd64.tar.gz && \
|
||||
tar -xzf go-junit-report-v2.1.0-linux-amd64.tar.gz && \
|
||||
chmod +x go-junit-report && \
|
||||
mv go-junit-report /usr/local/bin/
|
||||
go build -tags encoder_setup -o encoder-setup ./cmd/encoder-setup
|
||||
./encoder-setup
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -v 2>&1 ./... -coverprofile=coverage.txt | tee test-results.txt
|
||||
- name: Analyse test results
|
||||
if: ${{ !cancelled() }}
|
||||
run: go-junit-report < test-results.txt > junit.xml
|
||||
mkdir -p test-results
|
||||
gotestsum --junitfile test-results/junit.xml --format testname -- -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
- name: Upload test result artifact
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
test-results.txt
|
||||
junit.xml
|
||||
path: |
|
||||
test-results/junit.xml
|
||||
test-results/coverage.txt
|
||||
retention-days: 7
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload test results to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: test-results/junit.xml
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: ${{ !cancelled() }}
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
# 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
|
||||
|
||||
+100
-76
@@ -1,11 +1,34 @@
|
||||
# .goreleaser.yml
|
||||
version: 2
|
||||
project_name: CBZOptimizer
|
||||
|
||||
# Configures the release process on GitHub
|
||||
# https://goreleaser.com/customization/release/
|
||||
release:
|
||||
github:
|
||||
owner: belphemur
|
||||
name: CBZOptimizer
|
||||
include_meta: true
|
||||
# draft: false # Default is false
|
||||
# prerelease: auto # Default is auto
|
||||
# mode: replace # Default is append
|
||||
|
||||
# Configures the binary archive generation
|
||||
# https://goreleaser.com/customization/archive/
|
||||
archives:
|
||||
- ids:
|
||||
- cbzoptimizer
|
||||
formats: ["tar.zst"]
|
||||
format_overrides:
|
||||
- # Which GOOS to override the format for.
|
||||
goos: windows
|
||||
formats: ["zip"] # Plural form, multiple formats. Since: v2.6
|
||||
|
||||
# Configures the changelog generation
|
||||
# https://goreleaser.com/customization/changelog/
|
||||
changelog:
|
||||
use: github
|
||||
format: "{{.SHA}}: {{.Message}} (@{{.AuthorUsername}})"
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
@@ -22,6 +45,16 @@ changelog:
|
||||
- title: "Performance"
|
||||
regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$'
|
||||
order: 2
|
||||
|
||||
# Hooks to run before the build process starts
|
||||
# https://goreleaser.com/customization/hooks/
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
# Configures the Go build process
|
||||
# https://goreleaser.com/customization/build/
|
||||
builds:
|
||||
- id: cbzoptimizer
|
||||
main: cmd/cbzoptimizer/main.go
|
||||
@@ -44,89 +77,80 @@ builds:
|
||||
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{ .CommitDate }}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
# config the checksum filename
|
||||
# https://goreleaser.com/customization/checksum
|
||||
- id: encoder-setup
|
||||
main: cmd/encoder-setup/main.go
|
||||
binary: encoder-setup
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# ensures mod timestamp to be the commit timestamp
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
flags:
|
||||
# trims path
|
||||
- -trimpath
|
||||
tags:
|
||||
- encoder_setup
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{ .CommitDate }}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
# Configures the checksum file generation
|
||||
# https://goreleaser.com/customization/checksum/
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
# Change the digests filename:
|
||||
|
||||
# Change the digests filename for attestation
|
||||
# https://goreleaser.com/customization/docker_digest/
|
||||
docker_digest:
|
||||
name_template: "digests.txt"
|
||||
# create a source tarball
|
||||
|
||||
# Creates a source code archive (tar.gz and zip)
|
||||
# https://goreleaser.com/customization/source/
|
||||
source:
|
||||
enabled: true
|
||||
# proxies from the go mod proxy before building
|
||||
# https://goreleaser.com/customization/gomod
|
||||
|
||||
# Configures Go Modules settings
|
||||
# https://goreleaser.com/customization/gomod/
|
||||
gomod:
|
||||
proxy: true
|
||||
# creates SBOMs of all archives and the source tarball using syft
|
||||
# https://goreleaser.com/customization/sbom
|
||||
|
||||
# Creates SBOMs (Software Bill of Materials)
|
||||
# https://goreleaser.com/customization/sbom/
|
||||
sboms:
|
||||
- artifacts: archive
|
||||
- id: source # Two different sbom configurations need two different IDs
|
||||
artifacts: source
|
||||
# create a docker image
|
||||
# https://goreleaser.com/customization/docker
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "ghcr.io/belphemur/cbzoptimizer:latest-amd64"
|
||||
- "ghcr.io/belphemur/cbzoptimizer:{{ .Version }}-amd64"
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.name={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.source={{.GitURL}}"
|
||||
- image_templates:
|
||||
- "ghcr.io/belphemur/cbzoptimizer:latest-arm64"
|
||||
- "ghcr.io/belphemur/cbzoptimizer:{{ .Version }}-arm64"
|
||||
use: buildx
|
||||
goarch: arm64
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.name={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.source={{.GitURL}}"
|
||||
# signs the checksum file
|
||||
# all files (including the sboms) are included in the checksum, so we don't need to sign each one if we don't want to
|
||||
# https://goreleaser.com/customization/sign
|
||||
signs:
|
||||
- cmd: cosign
|
||||
env:
|
||||
- COSIGN_EXPERIMENTAL=1
|
||||
certificate: "${artifact}.pem"
|
||||
args:
|
||||
- sign-blob
|
||||
- "--output-certificate=${certificate}"
|
||||
- "--output-signature=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes" # needed on cosign 2.0.0+
|
||||
artifacts: checksum
|
||||
output: true
|
||||
# signs our docker image
|
||||
# https://goreleaser.com/customization/docker_sign
|
||||
docker_signs:
|
||||
- cmd: cosign
|
||||
env:
|
||||
- COSIGN_EXPERIMENTAL=1
|
||||
artifacts: images
|
||||
output: true
|
||||
args:
|
||||
- "sign"
|
||||
- "${artifact}"
|
||||
- "--yes" # needed on cosign 2.0.0+
|
||||
docker_manifests:
|
||||
- name_template: "ghcr.io/belphemur/cbzoptimizer:{{ .Version }}"
|
||||
image_templates:
|
||||
- "ghcr.io/belphemur/cbzoptimizer:{{ .Version }}-amd64"
|
||||
- "ghcr.io/belphemur/cbzoptimizer:{{ .Version }}-arm64"
|
||||
- name_template: "ghcr.io/belphemur/cbzoptimizer:latest"
|
||||
image_templates:
|
||||
- "ghcr.io/belphemur/cbzoptimizer:latest-amd64"
|
||||
- "ghcr.io/belphemur/cbzoptimizer:latest-arm64"
|
||||
- id: archive # Default ID for archive SBOMs
|
||||
artifacts: archive # Generate SBOMs for binary archives using Syft
|
||||
- id: source # Unique ID for source SBOM
|
||||
artifacts: source # Generate SBOM for the source code archive
|
||||
|
||||
# Creates Docker images and pushes them to registries using Docker v2 API
|
||||
# https://goreleaser.com/customization/docker/
|
||||
dockers_v2:
|
||||
- id: cbzoptimizer-image
|
||||
ids:
|
||||
- cbzoptimizer
|
||||
- encoder-setup
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
images:
|
||||
- "ghcr.io/belphemur/cbzoptimizer"
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- latest
|
||||
annotations:
|
||||
"org.opencontainers.image.description": "CBZOptimizer is a Go-based tool designed to optimize CBZ (Comic Book Zip) and CBR (Comic Book RAR) files by converting images to a specified format and quality. This tool is useful for reducing the size of comic book archives while maintaining acceptable image quality."
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.name": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
labels:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.name": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"org.opencontainers.image.description": "CBZOptimizer is a Go-based tool designed to optimize CBZ (Comic Book Zip) and CBR (Comic Book RAR) files by converting images to a specified format and quality. This tool is useful for reducing the size of comic book archives while maintaining acceptable image quality."
|
||||
|
||||
Vendored
+7
@@ -4,6 +4,13 @@
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch file",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${file}"
|
||||
},
|
||||
{
|
||||
"name": "Optimize Testdata",
|
||||
"type": "go",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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/`
|
||||
+26
-15
@@ -1,29 +1,40 @@
|
||||
FROM alpine:latest
|
||||
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
|
||||
|
||||
RUN mkdir -p "${CONFIG_FOLDER}" && \
|
||||
adduser \
|
||||
-S \
|
||||
-H \
|
||||
-h "${CONFIG_FOLDER}" \
|
||||
-G "users" \
|
||||
-u "${PUID}" \
|
||||
"${USER}" && \
|
||||
chown ${PUID}:users "${CONFIG_FOLDER}"
|
||||
RUN adduser \
|
||||
-S \
|
||||
-D \
|
||||
-h "${CONFIG_FOLDER}" \
|
||||
-u "${PUID}" \
|
||||
-G users \
|
||||
-s /bin/bash \
|
||||
"${USER}"
|
||||
|
||||
COPY CBZOptimizer ${APP_PATH}
|
||||
COPY ${TARGETPLATFORM}/CBZOptimizer ${APP_PATH}
|
||||
|
||||
RUN apk add --no-cache \
|
||||
inotify-tools \
|
||||
bash \
|
||||
bash-completion && \
|
||||
ca-certificates \
|
||||
bash-completion \
|
||||
libwebp-tools && \
|
||||
chmod +x ${APP_PATH} && \
|
||||
${APP_PATH} completion bash > /etc/bash_completion.d/CBZOptimizer
|
||||
${APP_PATH} completion bash > /etc/bash_completion.d/CBZOptimizer.bash && \
|
||||
mkdir -p "${CONFIG_FOLDER}" && \
|
||||
chown -R "${PUID}":users "${CONFIG_FOLDER}"
|
||||
|
||||
|
||||
VOLUME ${CONFIG_FOLDER}
|
||||
USER ${USER}
|
||||
ENTRYPOINT ["/usr/local/bin/CBZOptimizer"]
|
||||
|
||||
# Need to run as the user to have the right config folder created
|
||||
RUN --mount=type=bind,source=${TARGETPLATFORM},target=/tmp/target \
|
||||
/tmp/target/encoder-setup
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/CBZOptimizer"]
|
||||
|
||||
@@ -42,6 +42,22 @@ Optimize all CBZ/CBR files in a folder recursively:
|
||||
cbzconverter optimize [folder] --quality 85 --parallelism 2 --override --format webp --split
|
||||
```
|
||||
|
||||
The format flag can be specified in multiple ways:
|
||||
|
||||
```sh
|
||||
# Using space-separated syntax
|
||||
cbzconverter optimize [folder] --format webp
|
||||
|
||||
# Using short form with space
|
||||
cbzconverter optimize [folder] -f webp
|
||||
|
||||
# Using equals syntax
|
||||
cbzconverter optimize [folder] --format=webp
|
||||
|
||||
# Format is case-insensitive
|
||||
cbzconverter optimize [folder] --format WEBP
|
||||
```
|
||||
|
||||
With timeout to avoid hanging on problematic chapters:
|
||||
|
||||
```sh
|
||||
@@ -62,6 +78,16 @@ 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
|
||||
@@ -71,11 +97,14 @@ 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.
|
||||
- `--parallelism`, `-n`: Number of chapters to convert in parallel. Default is 2. Regardless of this value, the total number of pages converted at the same time (i.e. concurrent `cwebp` processes) is capped to the number of CPU cores, so increasing parallelism spreads that budget across more chapters rather than multiplying resource usage.
|
||||
- `--override`, `-o`: Override the original files. For CBZ files, overwrites the original. For CBR files, deletes the original CBR and creates a new CBZ. Default is false.
|
||||
- `--split`, `-s`: Split long pages into smaller chunks. Default is false.
|
||||
- `--format`, `-f`: Format to convert the images to (e.g., webp). Default is webp.
|
||||
- `--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
|
||||
- `--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
|
||||
@@ -139,15 +168,81 @@ LOG_LEVEL=warn cbzconverter optimize comics/
|
||||
docker run -e LOG_LEVEL=debug -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest optimize /comics
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- For Docker usage: No additional requirements needed
|
||||
- For binary usage: Needs `libwebp` installed on the system for WebP conversion
|
||||
|
||||
## Docker Image
|
||||
|
||||
The official Docker image is available at: `ghcr.io/belphemur/cbzoptimizer:latest`
|
||||
|
||||
### Docker Compose
|
||||
|
||||
You can use Docker Compose to run CBZOptimizer with persistent configuration. Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
cbzoptimizer:
|
||||
image: ghcr.io/belphemur/cbzoptimizer:latest
|
||||
container_name: cbzoptimizer
|
||||
environment:
|
||||
# Set log level (panic, fatal, error, warn, info, debug, trace)
|
||||
- LOG_LEVEL=info
|
||||
# User and Group ID for file permissions
|
||||
- PUID=99
|
||||
- PGID=100
|
||||
volumes:
|
||||
# Mount your comics directory
|
||||
- /path/to/your/comics:/comics
|
||||
# Optional: Mount a config directory for persistent settings
|
||||
- ./config:/config
|
||||
# Example: Optimize all comics in the /comics directory
|
||||
command: optimize /comics --quality 85 --parallelism 2 --override --format webp --split
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
For watch mode, you can create a separate service:
|
||||
|
||||
```yaml
|
||||
cbzoptimizer-watch:
|
||||
image: ghcr.io/belphemur/cbzoptimizer:latest
|
||||
container_name: cbzoptimizer-watch
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- PUID=99
|
||||
- PGID=100
|
||||
volumes:
|
||||
- /path/to/watch/directory:/watch
|
||||
- ./config:/config
|
||||
# Watch for new files and automatically optimize them
|
||||
command: watch /watch --quality 85 --override --format webp --split
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Replace `/path/to/your/comics` and `/path/to/watch/directory` with your actual directory paths
|
||||
- The `PUID` and `PGID` environment variables control file permissions (default: 99/100)
|
||||
- The `LOG_LEVEL` environment variable sets the logging verbosity
|
||||
- For one-time optimization, remove the `restart: unless-stopped` line
|
||||
- Watch mode only works on Linux systems
|
||||
|
||||
#### Running with Docker Compose
|
||||
|
||||
```sh
|
||||
# Start the service (one-time optimization)
|
||||
docker-compose up cbzoptimizer
|
||||
|
||||
# Start in detached mode
|
||||
docker-compose up -d cbzoptimizer
|
||||
|
||||
# Start watch mode service
|
||||
docker-compose up -d cbzoptimizer-watch
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f cbzoptimizer
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/thediveo/enumflag/v2"
|
||||
)
|
||||
|
||||
// setupFormatFlag sets up the format flag for a command.
|
||||
//
|
||||
// Parameters:
|
||||
// - cmd: The Cobra command to add the format flag to
|
||||
// - converterType: Pointer to the ConversionFormat variable that will store the flag value
|
||||
// - bindViper: If true, binds the flag to viper for configuration file support.
|
||||
// Set to true for commands that use viper for configuration (e.g., watch command),
|
||||
// and false for commands that don't (e.g., optimize command).
|
||||
func setupFormatFlag(cmd *cobra.Command, converterType *constant.ConversionFormat, bindViper bool) {
|
||||
formatFlag := enumflag.New(converterType, "format", constant.CommandValue, enumflag.EnumCaseInsensitive)
|
||||
_ = formatFlag.RegisterCompletion(cmd, "format", constant.HelpText)
|
||||
|
||||
cmd.Flags().VarP(
|
||||
formatFlag,
|
||||
"format", "f",
|
||||
fmt.Sprintf("Format to convert the images to: %s", constant.ListAll()))
|
||||
|
||||
if bindViper {
|
||||
_ = viper.BindPFlag("format", cmd.Flags().Lookup("format"))
|
||||
}
|
||||
}
|
||||
|
||||
// setupQualityFlag sets up the quality flag for a command.
|
||||
//
|
||||
// Parameters:
|
||||
// - cmd: The Cobra command to add the quality flag to
|
||||
// - defaultValue: The default quality value (0-100)
|
||||
// - bindViper: If true, binds the flag to viper for configuration file support
|
||||
func setupQualityFlag(cmd *cobra.Command, defaultValue uint8, bindViper bool) {
|
||||
cmd.Flags().Uint8P("quality", "q", defaultValue, "Quality for conversion (0-100)")
|
||||
if bindViper {
|
||||
_ = viper.BindPFlag("quality", cmd.Flags().Lookup("quality"))
|
||||
}
|
||||
}
|
||||
|
||||
// setupOverrideFlag sets up the override flag for a command.
|
||||
//
|
||||
// Parameters:
|
||||
// - cmd: The Cobra command to add the override flag to
|
||||
// - defaultValue: The default override value
|
||||
// - bindViper: If true, binds the flag to viper for configuration file support
|
||||
func setupOverrideFlag(cmd *cobra.Command, defaultValue bool, bindViper bool) {
|
||||
cmd.Flags().BoolP("override", "o", defaultValue, "Override the original CBZ/CBR files")
|
||||
if bindViper {
|
||||
_ = viper.BindPFlag("override", cmd.Flags().Lookup("override"))
|
||||
}
|
||||
}
|
||||
|
||||
// setupSplitFlag sets up the split flag for a command.
|
||||
//
|
||||
// Parameters:
|
||||
// - cmd: The Cobra command to add the split flag to
|
||||
// - defaultValue: The default split value
|
||||
// - bindViper: If true, binds the flag to viper for configuration file support
|
||||
func setupSplitFlag(cmd *cobra.Command, defaultValue bool, bindViper bool) {
|
||||
cmd.Flags().BoolP("split", "s", defaultValue, "Split long pages into smaller chunks")
|
||||
if bindViper {
|
||||
_ = viper.BindPFlag("split", cmd.Flags().Lookup("split"))
|
||||
}
|
||||
}
|
||||
|
||||
// setupTimeoutFlag sets up the timeout flag for a command.
|
||||
//
|
||||
// Parameters:
|
||||
// - cmd: The Cobra command to add the timeout flag to
|
||||
// - bindViper: If true, binds the flag to viper for configuration file support
|
||||
func setupTimeoutFlag(cmd *cobra.Command, bindViper bool) {
|
||||
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout")
|
||||
if bindViper {
|
||||
_ = viper.BindPFlag("timeout", cmd.Flags().Lookup("timeout"))
|
||||
}
|
||||
}
|
||||
|
||||
// setupCommonFlags sets up all common flags for optimize and watch commands.
|
||||
//
|
||||
// Parameters:
|
||||
// - cmd: The Cobra command to add the flags to
|
||||
// - converterType: Pointer to the ConversionFormat variable that will store the format flag value
|
||||
// - qualityDefault: The default quality value (0-100)
|
||||
// - overrideDefault: The default override value
|
||||
// - splitDefault: The default split value
|
||||
// - bindViper: If true, binds all flags to viper for configuration file support
|
||||
func setupCommonFlags(cmd *cobra.Command, converterType *constant.ConversionFormat, qualityDefault uint8, overrideDefault bool, splitDefault bool, bindViper bool) {
|
||||
setupFormatFlag(cmd, converterType, bindViper)
|
||||
setupQualityFlag(cmd, qualityDefault, bindViper)
|
||||
setupOverrideFlag(cmd, overrideDefault, bindViper)
|
||||
setupSplitFlag(cmd, splitDefault, bindViper)
|
||||
setupTimeoutFlag(cmd, bindViper)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/thediveo/enumflag/v2"
|
||||
)
|
||||
|
||||
var converterType constant.ConversionFormat
|
||||
@@ -25,19 +24,12 @@ func init() {
|
||||
RunE: ConvertCbzCommand,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
formatFlag := enumflag.New(&converterType, "format", constant.CommandValue, enumflag.EnumCaseInsensitive)
|
||||
_ = formatFlag.RegisterCompletion(command, "format", constant.HelpText)
|
||||
|
||||
command.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)")
|
||||
|
||||
// Setup common flags (format, quality, override, split, timeout)
|
||||
setupCommonFlags(command, &converterType, 85, false, false, false)
|
||||
|
||||
// Setup optimize-specific flags
|
||||
command.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel")
|
||||
command.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
|
||||
command.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
|
||||
command.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout")
|
||||
command.PersistentFlags().VarP(
|
||||
formatFlag,
|
||||
"format", "f",
|
||||
fmt.Sprintf("Format to convert the images to: %s", constant.ListAll()))
|
||||
command.PersistentFlags().Lookup("format").NoOptDefVal = constant.DefaultConversion.String()
|
||||
|
||||
AddCommand(command)
|
||||
}
|
||||
@@ -113,8 +105,9 @@ func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Channel to manage the files to process
|
||||
fileChan := make(chan string)
|
||||
// Channel to collect errors
|
||||
errorChan := make(chan error, parallelism)
|
||||
// Slice to collect errors with mutex for thread safety
|
||||
var errs []error
|
||||
var errMutex sync.Mutex
|
||||
|
||||
// WaitGroup to wait for all goroutines to finish
|
||||
var wg sync.WaitGroup
|
||||
@@ -138,7 +131,9 @@ func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Int("worker_id", workerID).Str("file_path", path).Err(err).Msg("Worker encountered error")
|
||||
errorChan <- fmt.Errorf("error processing file %s: %w", path, err)
|
||||
errMutex.Lock()
|
||||
errs = append(errs, fmt.Errorf("error processing file %s: %w", path, err))
|
||||
errMutex.Unlock()
|
||||
} else {
|
||||
log.Debug().Int("worker_id", workerID).Str("file_path", path).Msg("Worker completed file successfully")
|
||||
}
|
||||
@@ -177,13 +172,6 @@ func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
|
||||
log.Debug().Msg("File channel closed, waiting for workers to complete")
|
||||
wg.Wait() // Wait for all workers to finish
|
||||
log.Debug().Msg("All workers completed")
|
||||
close(errorChan) // Close the error channel
|
||||
|
||||
var errs []error
|
||||
for err := range errorChan {
|
||||
errs = append(errs, err)
|
||||
log.Error().Err(err).Msg("Collected processing error")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
log.Error().Int("error_count", len(errs)).Msg("Command completed with errors")
|
||||
|
||||
@@ -2,6 +2,7 @@ package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -172,3 +173,384 @@ func TestConvertCbzCommand(t *testing.T) {
|
||||
// Log summary
|
||||
t.Logf("Found %d converted files", len(convertedFiles))
|
||||
}
|
||||
|
||||
// setupTestCommand creates a test command with all required flags for testing.
|
||||
// It mocks the converter.Get function and sets up a complete command with all flags.
|
||||
//
|
||||
// Returns:
|
||||
// - *cobra.Command: A configured command ready for testing
|
||||
// - func(): A cleanup function that must be deferred to restore the original converter.Get
|
||||
func setupTestCommand(t *testing.T) (*cobra.Command, func()) {
|
||||
t.Helper()
|
||||
// Mock the converter.Get function
|
||||
originalGet := converter.Get
|
||||
converter.Get = func(format constant.ConversionFormat) (converter.Converter, error) {
|
||||
return &MockConverter{}, nil
|
||||
}
|
||||
cleanup := func() { converter.Get = originalGet }
|
||||
|
||||
// Set up the command
|
||||
cmd := &cobra.Command{
|
||||
Use: "optimize",
|
||||
}
|
||||
cmd.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)")
|
||||
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().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
|
||||
|
||||
// Reset converterType to default before test for consistency
|
||||
converterType = constant.DefaultConversion
|
||||
setupFormatFlag(cmd, &converterType, false)
|
||||
|
||||
return cmd, cleanup
|
||||
}
|
||||
|
||||
// TestFormatFlagWithSpace tests that the format flag works with space-separated values
|
||||
func TestFormatFlagWithSpace(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "test_format_space")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer func() { _ = 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)
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
err = ConvertCbzCommand(cmd, []string{tempDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Command execution failed with --format webp: %v", err)
|
||||
}
|
||||
|
||||
// Verify the format was set correctly
|
||||
if converterType != constant.WebP {
|
||||
t.Errorf("Expected format to be WebP, got %v", converterType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagWithShortForm tests that the short form of format flag works with space-separated values
|
||||
func TestFormatFlagWithShortForm(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "test_format_short")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer func() { _ = 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)
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
err = ConvertCbzCommand(cmd, []string{tempDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Command execution failed with -f webp: %v", err)
|
||||
}
|
||||
|
||||
// Verify the format was set correctly
|
||||
if converterType != constant.WebP {
|
||||
t.Errorf("Expected format to be WebP, got %v", converterType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagWithEquals tests that the format flag works with equals syntax
|
||||
func TestFormatFlagWithEquals(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "test_format_equals")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer func() { _ = 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)
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
err = ConvertCbzCommand(cmd, []string{tempDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Command execution failed with --format=webp: %v", err)
|
||||
}
|
||||
|
||||
// Verify the format was set correctly
|
||||
if converterType != constant.WebP {
|
||||
t.Errorf("Expected format to be WebP, got %v", converterType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagDefaultValue tests that the default format is used when flag is not provided
|
||||
func TestFormatFlagDefaultValue(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "test_format_default")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer func() { _ = 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)
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
err = ConvertCbzCommand(cmd, []string{tempDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Command execution failed with default format: %v", err)
|
||||
}
|
||||
|
||||
// Verify the default format is used
|
||||
if converterType != constant.DefaultConversion {
|
||||
t.Errorf("Expected format to be default (%v), got %v", constant.DefaultConversion, converterType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatFlagCaseInsensitive tests that the format flag is case-insensitive
|
||||
func TestFormatFlagCaseInsensitive(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "test_format_case")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
testCases := []string{"webp", "WEBP", "WebP", "WeBp"}
|
||||
|
||||
for _, formatValue := range testCases {
|
||||
t.Run(formatValue, func(t *testing.T) {
|
||||
cmd, cleanup := setupTestCommand(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)
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
err = ConvertCbzCommand(cmd, []string{tempDir})
|
||||
if err != nil {
|
||||
t.Fatalf("Command execution failed with format '%s': %v", formatValue, err)
|
||||
}
|
||||
|
||||
// Verify the format was set correctly
|
||||
if converterType != constant.WebP {
|
||||
t.Errorf("Expected format to be WebP for input '%s', got %v", formatValue, converterType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCbzCommand_ManyFiles_NoDeadlock tests that processing many files in parallel
|
||||
// does not cause a deadlock. This reproduces the scenario where processing
|
||||
// recursive folders of CBZ files with parallelism > 1 could cause a "all goroutines are asleep - deadlock!" error.
|
||||
func TestConvertCbzCommand_ManyFiles_NoDeadlock(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "test_cbz_many_files")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory")
|
||||
|
||||
// Locate the testdata directory
|
||||
testdataDir := filepath.Join("../../../testdata")
|
||||
if _, err := os.Stat(testdataDir); os.IsNotExist(err) {
|
||||
t.Fatalf("testdata directory not found")
|
||||
}
|
||||
|
||||
// Create subdirectories to simulate the recursive folder structure from the bug report
|
||||
subdirs := []string{"author1/book1", "author2/book2", "author3/book3", "author4/book4"}
|
||||
for _, subdir := range subdirs {
|
||||
err := os.MkdirAll(filepath.Join(tempDir, subdir), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Find a sample CBZ file to copy
|
||||
var sampleCBZ string
|
||||
err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".cbz") && !strings.Contains(info.Name(), "converted") {
|
||||
sampleCBZ = path
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || sampleCBZ == "" {
|
||||
t.Fatalf("Failed to find sample CBZ file: %v", err)
|
||||
}
|
||||
|
||||
// Copy the sample file to multiple locations (simulating many files to process)
|
||||
numFilesPerDir := 5
|
||||
totalFiles := 0
|
||||
for _, subdir := range subdirs {
|
||||
for i := 0; i < numFilesPerDir; i++ {
|
||||
destPath := filepath.Join(tempDir, subdir, fmt.Sprintf("Chapter_%d.cbz", i+1))
|
||||
data, err := os.ReadFile(sampleCBZ)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read sample file: %v", err)
|
||||
}
|
||||
err = os.WriteFile(destPath, data, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
totalFiles++
|
||||
}
|
||||
}
|
||||
t.Logf("Created %d test files across %d directories", totalFiles, len(subdirs))
|
||||
|
||||
// Mock the converter.Get function
|
||||
originalGet := converter.Get
|
||||
converter.Get = func(format constant.ConversionFormat) (converter.Converter, error) {
|
||||
return &MockConverter{}, nil
|
||||
}
|
||||
defer func() { converter.Get = originalGet }()
|
||||
|
||||
// Set up the command with parallelism = 2 (same as the bug report)
|
||||
cmd := &cobra.Command{
|
||||
Use: "optimize",
|
||||
}
|
||||
cmd.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)")
|
||||
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().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
|
||||
|
||||
converterType = constant.DefaultConversion
|
||||
setupFormatFlag(cmd, &converterType, false)
|
||||
|
||||
// Run the command with a timeout to detect deadlocks
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ConvertCbzCommand(cmd, []string{tempDir})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Command execution failed: %v", err)
|
||||
}
|
||||
t.Logf("Command completed successfully without deadlock")
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("Deadlock detected: Command did not complete within 60 seconds")
|
||||
}
|
||||
|
||||
// Verify that converted files were created
|
||||
var convertedCount int
|
||||
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), "_converted.cbz") {
|
||||
convertedCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Error counting converted files: %v", err)
|
||||
}
|
||||
|
||||
if convertedCount != totalFiles {
|
||||
t.Errorf("Expected %d converted files, found %d", totalFiles, convertedCount)
|
||||
}
|
||||
t.Logf("Found %d converted files as expected", convertedCount)
|
||||
}
|
||||
|
||||
// TestConvertCbzCommand_HighParallelism_NoDeadlock tests processing with high parallelism setting.
|
||||
func TestConvertCbzCommand_HighParallelism_NoDeadlock(t *testing.T) {
|
||||
// Create a temporary directory
|
||||
tempDir, err := os.MkdirTemp("", "test_cbz_high_parallel")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory")
|
||||
|
||||
// Locate the testdata directory
|
||||
testdataDir := filepath.Join("../../../testdata")
|
||||
if _, err := os.Stat(testdataDir); os.IsNotExist(err) {
|
||||
t.Fatalf("testdata directory not found")
|
||||
}
|
||||
|
||||
// Find and copy sample CBZ files
|
||||
var sampleCBZ string
|
||||
err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".cbz") && !strings.Contains(info.Name(), "converted") {
|
||||
sampleCBZ = path
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || sampleCBZ == "" {
|
||||
t.Fatalf("Failed to find sample CBZ file: %v", err)
|
||||
}
|
||||
|
||||
// Create many test files
|
||||
numFiles := 15
|
||||
for i := 0; i < numFiles; i++ {
|
||||
destPath := filepath.Join(tempDir, fmt.Sprintf("test_file_%d.cbz", i+1))
|
||||
data, err := os.ReadFile(sampleCBZ)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read sample file: %v", err)
|
||||
}
|
||||
err = os.WriteFile(destPath, data, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mock the converter
|
||||
originalGet := converter.Get
|
||||
converter.Get = func(format constant.ConversionFormat) (converter.Converter, error) {
|
||||
return &MockConverter{}, nil
|
||||
}
|
||||
defer func() { converter.Get = originalGet }()
|
||||
|
||||
// Test with high parallelism (8)
|
||||
cmd := &cobra.Command{
|
||||
Use: "optimize",
|
||||
}
|
||||
cmd.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)")
|
||||
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().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
|
||||
|
||||
converterType = constant.DefaultConversion
|
||||
setupFormatFlag(cmd, &converterType, false)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ConvertCbzCommand(cmd, []string{tempDir})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Command execution failed: %v", err)
|
||||
}
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("Deadlock detected with high parallelism")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func init() {
|
||||
ef,
|
||||
"log", "l",
|
||||
"Set log level; can be 'panic', 'fatal', 'error', 'warn', 'info', 'debug', or 'trace'")
|
||||
ef.RegisterCompletion(rootCmd, "log", enumflag.Help[zerolog.Level]{
|
||||
if err := 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,11 +67,17 @@ 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
|
||||
viper.BindEnv("log", "LOG_LEVEL")
|
||||
viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log"))
|
||||
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))
|
||||
}
|
||||
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
ConfigureLogging()
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
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/pablodz/inotifywaitgo/inotifywaitgo"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/thediveo/enumflag/v2"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -27,27 +37,12 @@ func init() {
|
||||
RunE: WatchCommand,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
formatFlag := enumflag.New(&converterType, "format", constant.CommandValue, enumflag.EnumCaseInsensitive)
|
||||
_ = formatFlag.RegisterCompletion(command, "format", constant.HelpText)
|
||||
|
||||
command.Flags().Uint8P("quality", "q", 85, "Quality for conversion (0-100)")
|
||||
_ = viper.BindPFlag("quality", command.Flags().Lookup("quality"))
|
||||
// Setup common flags (format, quality, override, split, timeout) with viper binding
|
||||
setupCommonFlags(command, &converterType, 85, true, false, true)
|
||||
|
||||
command.Flags().BoolP("override", "o", true, "Override the original CBZ/CBR files")
|
||||
_ = viper.BindPFlag("override", command.Flags().Lookup("override"))
|
||||
|
||||
command.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
|
||||
_ = viper.BindPFlag("split", command.Flags().Lookup("split"))
|
||||
|
||||
command.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout")
|
||||
_ = viper.BindPFlag("timeout", command.Flags().Lookup("timeout"))
|
||||
|
||||
command.PersistentFlags().VarP(
|
||||
formatFlag,
|
||||
"format", "f",
|
||||
fmt.Sprintf("Format to convert the images to: %s", constant.ListAll()))
|
||||
command.PersistentFlags().Lookup("format").NoOptDefVal = constant.DefaultConversion.String()
|
||||
_ = viper.BindPFlag("format", command.PersistentFlags().Lookup("format"))
|
||||
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)
|
||||
}
|
||||
@@ -72,81 +67,317 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
|
||||
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: %v", err)
|
||||
return fmt.Errorf("failed to get chapterConverter: %w", err)
|
||||
}
|
||||
|
||||
err = chapterConverter.PrepareConverter()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare converter: %v", err)
|
||||
return fmt.Errorf("failed to prepare converter: %w", err)
|
||||
}
|
||||
log.Info().Str("path", path).Bool("override", override).Uint8("quality", quality).Str("format", converterType.String()).Bool("split", split).Msg("Watching directory")
|
||||
log.Info().Str("path", path).Bool("override", override).Uint8("quality", quality).Str("format", converterType.String()).Bool("split", split).Bool("backfill", backfill).Msg("Watching directory")
|
||||
|
||||
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,
|
||||
})
|
||||
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")
|
||||
}
|
||||
}()
|
||||
|
||||
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")
|
||||
// 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{
|
||||
ChapterConverter: chapterConverter,
|
||||
Quality: quality,
|
||||
Override: override,
|
||||
Split: split,
|
||||
Timeout: timeout,
|
||||
})
|
||||
defer queue.Stop()
|
||||
|
||||
filename := strings.ToLower(event.Filename)
|
||||
if !strings.HasSuffix(filename, ".cbz") && !strings.HasSuffix(filename, ".cbr") {
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
Timeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("error processing file %s: %w", event.Filename, err)
|
||||
}
|
||||
default:
|
||||
// ignored
|
||||
}
|
||||
if !isComicArchive(event.Name) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for err := range errors {
|
||||
debouncer.Trigger(event.Name)
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
log.Error().Err(err).Msg("Watch error")
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build encoder_setup
|
||||
// +build encoder_setup
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/webp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Setting up WebP encoder ...")
|
||||
if err := webp.PrepareEncoder(); err != nil {
|
||||
log.Fatalf("Failed to prepare WebP encoder: %v", err)
|
||||
}
|
||||
fmt.Println("WebP encoder setup complete.")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
cbzoptimizer:
|
||||
image: ghcr.io/belphemur/cbzoptimizer:latest
|
||||
container_name: cbzoptimizer
|
||||
environment:
|
||||
# Set log level (panic, fatal, error, warn, info, debug, trace)
|
||||
- LOG_LEVEL=info
|
||||
# User and Group ID for file permissions
|
||||
- PUID=99
|
||||
- PGID=100
|
||||
volumes:
|
||||
# Mount your comics directory
|
||||
- /path/to/your/comics:/comics
|
||||
# Optional: Mount a config directory for persistent settings
|
||||
- ./config:/config
|
||||
# Example: Optimize all comics in the /comics directory
|
||||
command: optimize /comics --quality 85 --parallelism 2 --override --format webp --split
|
||||
restart: unless-stopped
|
||||
|
||||
# Example: Watch mode service
|
||||
cbzoptimizer-watch:
|
||||
image: ghcr.io/belphemur/cbzoptimizer:latest
|
||||
container_name: cbzoptimizer-watch
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
- PUID=99
|
||||
- PGID=100
|
||||
volumes:
|
||||
- /path/to/watch/directory:/watch
|
||||
- ./config:/config
|
||||
# Watch for new files and automatically optimize them
|
||||
command: watch /watch --quality 85 --override --format webp --split
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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.
|
||||
@@ -1,64 +1,56 @@
|
||||
module github.com/belphemur/CBZOptimizer/v2
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
|
||||
github.com/belphemur/go-webpbin/v2 v2.0.0
|
||||
github.com/mholt/archives v0.1.3
|
||||
github.com/oliamb/cutter v0.2.2
|
||||
github.com/pablodz/inotifywaitgo v0.0.9
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/samber/lo v1.51.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.11.0
|
||||
github.com/thediveo/enumflag/v2 v2.0.7
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
|
||||
golang.org/x/image v0.30.0
|
||||
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/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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/STARRY-S/zip v0.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect
|
||||
github.com/belphemur/go-binwrapper v0.0.0-20240827152605-33977349b1f0 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/belphemur/go-binwrapper v1.0.0 // indirect
|
||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.0 // indirect
|
||||
github.com/bodgit/sevenzip v1.6.1 // indirect
|
||||
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.8.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jfrog/archiver/v3 v3.6.1 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.0 // indirect
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.5 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
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/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.28.0 // 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
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -17,20 +17,22 @@ 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/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg=
|
||||
github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4=
|
||||
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ=
|
||||
github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
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=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
||||
github.com/belphemur/go-binwrapper v0.0.0-20240827152605-33977349b1f0 h1:EzKgPYK90TyAOmytK7bvapqlkG/m7KWKK28mOAdQEaM=
|
||||
github.com/belphemur/go-binwrapper v0.0.0-20240827152605-33977349b1f0/go.mod h1:s2Dv+CfgVbNM9ucqvE5qCCC0AkI1PE2OZb7N8PPlOh4=
|
||||
github.com/belphemur/go-webpbin/v2 v2.0.0 h1:Do0TTTJ6cS6lgi+R67De+jXRYe+ZOwxFqTiFggyX5p8=
|
||||
github.com/belphemur/go-webpbin/v2 v2.0.0/go.mod h1:VIHXZQaIwaIYDn08w0qeJFPj1MuYt5pyJnkQALPYc5g=
|
||||
github.com/belphemur/go-binwrapper v1.0.0 h1:kXNRqO3vrqex4O0Q1pfD9w5kKwrQT1Mg9CJOd/IWbtI=
|
||||
github.com/belphemur/go-binwrapper v1.0.0/go.mod h1:PNID1xFdXpkAwjr7gCidIiC/JA8tpYl3zzNSIK9lCjc=
|
||||
github.com/belphemur/go-webpbin/v2 v2.1.0 h1:SvdjLz/9wb7kqD7jYDjlbTA2xRwwQRo3L/a5Ee+Br5E=
|
||||
github.com/belphemur/go-webpbin/v2 v2.1.0/go.mod h1:jRdjIZYdSkW6DM9pfiH2fjSYgX/jshRooDI03f6o658=
|
||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A=
|
||||
github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc=
|
||||
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
|
||||
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -38,7 +40,6 @@ 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=
|
||||
@@ -51,18 +52,16 @@ 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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
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-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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=
|
||||
@@ -75,8 +74,6 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -84,22 +81,17 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
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-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
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/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=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -107,14 +99,12 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jfrog/archiver/v3 v3.6.1 h1:LOxnkw9pOn45DzCbZNFV6K0+6dCsQ0L8mR3ZcujO5eI=
|
||||
github.com/jfrog/archiver/v3 v3.6.1/go.mod h1:VgR+3WZS4N+i9FaDwLZbq+jeU4B4zctXL+gL4EMzfLw=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
@@ -125,35 +115,27 @@ 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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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.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=
|
||||
github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458=
|
||||
github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU=
|
||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
||||
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||
github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ=
|
||||
github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U=
|
||||
github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
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.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/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/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=
|
||||
@@ -162,54 +144,51 @@ 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.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.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
|
||||
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
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.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.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg=
|
||||
github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
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=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
|
||||
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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.0.7 h1:uxXDU+rTel7Hg4X0xdqICpG9rzuI/mzLAEYXWLflOfs=
|
||||
github.com/thediveo/enumflag/v2 v2.0.7/go.mod h1:bWlnNvTJuUK+huyzf3WECFLy557Ttlc+yk3o+BPs0EA=
|
||||
github.com/thediveo/success v1.0.2 h1:w+r3RbSjLmd7oiNnlCblfGqItcsaShcuAorRVh/+0xk=
|
||||
github.com/thediveo/success v1.0.2/go.mod h1:hdPJB77k70w764lh8uLUZgNhgeTl3DYeZ4d4bwMO2CU=
|
||||
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/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
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=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -217,10 +196,8 @@ go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
|
||||
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -236,12 +213,12 @@ 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-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
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/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=
|
||||
@@ -258,6 +235,8 @@ 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=
|
||||
@@ -274,8 +253,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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
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/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=
|
||||
@@ -288,8 +267,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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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/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=
|
||||
@@ -306,12 +285,10 @@ 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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/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=
|
||||
@@ -322,8 +299,10 @@ 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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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/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=
|
||||
@@ -351,8 +330,9 @@ 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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
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/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=
|
||||
|
||||
+33
-39
@@ -3,6 +3,7 @@ package cbz
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -11,7 +12,9 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
|
||||
// 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) {
|
||||
log.Debug().
|
||||
Str("chapter_file", chapter.FilePath).
|
||||
Str("output_path", outputFilePath).
|
||||
@@ -19,8 +22,7 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
|
||||
Bool("is_converted", chapter.IsConverted).
|
||||
Msg("Starting CBZ file creation")
|
||||
|
||||
// Create a new ZIP file
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("Creating output CBZ file")
|
||||
// Create output file
|
||||
zipFile, err := os.Create(outputFilePath)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Err(err).Msg("Failed to create CBZ file")
|
||||
@@ -28,96 +30,88 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
|
||||
}
|
||||
defer errs.Capture(&err, zipFile.Close, "failed to close .cbz file")
|
||||
|
||||
// Create a new ZIP writer
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("Creating ZIP writer")
|
||||
// Create 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 ZIP archive
|
||||
log.Debug().Str("output_path", outputFilePath).Int("pages_to_write", len(chapter.Pages)).Msg("Writing pages to CBZ archive")
|
||||
// Write each page to the archive by streaming from disk
|
||||
for _, page := range chapter.Pages {
|
||||
// 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).
|
||||
Int("size", len(page.Contents.Bytes())).
|
||||
Str("source", page.FilePath).
|
||||
Msg("Writing page to CBZ archive")
|
||||
|
||||
// Create a new file in the ZIP archive
|
||||
// Create file entry in the zip (Store method = no compression, images are already compressed)
|
||||
fileWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: fileName,
|
||||
Method: zip.Store,
|
||||
Modified: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(err).Msg("Failed to create file in CBZ archive")
|
||||
log.Error().Str("filename", fileName).Err(err).Msg("Failed to create file in CBZ archive")
|
||||
return fmt.Errorf("failed to create file in .cbz: %w", err)
|
||||
}
|
||||
|
||||
// Write the page contents to the file
|
||||
bytesWritten, err := fileWriter.Write(page.Contents.Bytes())
|
||||
// Stream the page file from disk into the archive
|
||||
pageFile, err := os.Open(page.FilePath)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputFilePath).Str("filename", fileName).Err(err).Msg("Failed to write page contents")
|
||||
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")
|
||||
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).
|
||||
Int("bytes_written", bytesWritten).
|
||||
Int64("bytes_written", bytesWritten).
|
||||
Msg("Page written successfully")
|
||||
}
|
||||
|
||||
// Optionally, write the ComicInfo.xml file if present
|
||||
// Write ComicInfo.xml if present
|
||||
if chapter.ComicInfoXml != "" {
|
||||
log.Debug().Str("output_path", outputFilePath).Int("xml_size", len(chapter.ComicInfoXml)).Msg("Writing ComicInfo.xml to CBZ archive")
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("Writing ComicInfo.xml")
|
||||
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)
|
||||
}
|
||||
|
||||
bytesWritten, err := comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
|
||||
_, err = comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
|
||||
if err != nil {
|
||||
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)
|
||||
return fmt.Errorf("failed to write ComicInfo.xml: %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 {
|
||||
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)
|
||||
comment := fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", chapter.ConvertedTime)
|
||||
err = zipWriter.SetComment(comment)
|
||||
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 successfully")
|
||||
log.Debug().Str("output_path", outputFilePath).Msg("CBZ file creation completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,88 +2,102 @@ package cbz
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
|
||||
)
|
||||
|
||||
func TestWriteChapterToCBZ(t *testing.T) {
|
||||
currentTime := time.Now()
|
||||
|
||||
// Define test cases
|
||||
// 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()
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
chapter *manga.Chapter
|
||||
chapter func(t *testing.T, dir string) *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: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data")),
|
||||
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", ".jpg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
|
||||
IsConverted: true,
|
||||
ConvertedTime: currentTime,
|
||||
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: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data")),
|
||||
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", ".jpg"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
expectedFiles: []string{"0000.jpg"},
|
||||
},
|
||||
{
|
||||
name: "Multiple pages with ComicInfo",
|
||||
chapter: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data 1")),
|
||||
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")},
|
||||
},
|
||||
{
|
||||
Index: 1,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("image data 2")),
|
||||
},
|
||||
},
|
||||
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
|
||||
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
|
||||
}
|
||||
},
|
||||
expectedFiles: []string{"0000.jpg", "0001.jpg", "ComicInfo.xml"},
|
||||
},
|
||||
{
|
||||
name: "Split page",
|
||||
chapter: &manga.Chapter{
|
||||
Pages: []*manga.Page{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
Contents: bytes.NewBuffer([]byte("split image data")),
|
||||
IsSplitted: true,
|
||||
SplitPartIndex: 1,
|
||||
chapter: func(t *testing.T, dir string) *manga.Chapter {
|
||||
return &manga.Chapter{
|
||||
Pages: []*manga.PageFile{
|
||||
{
|
||||
Index: 0,
|
||||
Extension: ".jpg",
|
||||
FilePath: createTempPage(t, dir, "split image data", ".jpg"),
|
||||
IsSplitted: true,
|
||||
SplitPartIndex: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
expectedFiles: []string{"0000-01.jpg"},
|
||||
},
|
||||
@@ -91,33 +105,36 @@ func TestWriteChapterToCBZ(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create a temporary file for the .cbz output
|
||||
// Create temp dir for page files
|
||||
pageDir := t.TempDir()
|
||||
chapter := tc.chapter(t, pageDir)
|
||||
|
||||
// Create temp file for output CBZ
|
||||
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 the chapter to the .cbz file
|
||||
err = WriteChapterToCBZ(tc.chapter, tempFile.Name())
|
||||
// Write chapter
|
||||
err = WriteChapterToCBZ(chapter, tempFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write chapter to CBZ: %v", err)
|
||||
}
|
||||
|
||||
// Open the .cbz file as a zip archive
|
||||
// Verify the archive
|
||||
r, err := zip.OpenReader(tempFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open CBZ file: %v", err)
|
||||
}
|
||||
defer errs.Capture(&err, r.Close, "failed to close CBZ file")
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
// 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 {
|
||||
@@ -135,10 +152,61 @@ 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", len(tc.expectedFiles), len(filesInArchive))
|
||||
t.Errorf("Expected %d files, but found %d: %v", len(tc.expectedFiles), len(filesInArchive), 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)
|
||||
}
|
||||
}
|
||||
|
||||
+268
-107
@@ -3,13 +3,14 @@ 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"
|
||||
@@ -18,149 +19,309 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func LoadChapter(filePath string) (*manga.Chapter, error) {
|
||||
log.Debug().Str("file_path", filePath).Msg("Starting chapter loading")
|
||||
// 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,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// 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.
|
||||
func ExtractChapter(ctx context.Context, filePath string) (*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)
|
||||
}
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filePath,
|
||||
TempDir: tempDir,
|
||||
}
|
||||
|
||||
// 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")
|
||||
// For CBZ files, read metadata from zip comment
|
||||
pathLower := strings.ToLower(filepath.Ext(filePath))
|
||||
if pathLower == ".cbz" {
|
||||
r, err := zip.OpenReader(filePath)
|
||||
if err == nil {
|
||||
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
|
||||
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")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Debug().Str("file_path", filePath).Msg("No CBZ comment found")
|
||||
if t, ok := parseConvertedCommentTime(r.Comment); ok {
|
||||
chapter.IsConverted = true
|
||||
chapter.ConvertedTime = t
|
||||
}
|
||||
} else {
|
||||
log.Debug().Str("file_path", filePath).Err(err).Msg("Failed to open CBZ file for comment reading")
|
||||
_ = r.Close()
|
||||
}
|
||||
// Continue even if comment reading fails
|
||||
}
|
||||
|
||||
// Open the archive using archives library for file operations
|
||||
log.Debug().Str("file_path", filePath).Msg("Opening archive file system")
|
||||
// Extract files using the archives library (supports both CBZ and CBR)
|
||||
fsys, err := archives.FileSystem(ctx, filePath, nil)
|
||||
if err != nil {
|
||||
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)
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to open archive: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return func() error {
|
||||
// Open the file
|
||||
// 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 file %s: %w", path, err)
|
||||
return fmt.Errorf("failed to open ComicInfo.xml: %w", err)
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, fmt.Sprintf("failed to close file %s", path))
|
||||
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
|
||||
}
|
||||
|
||||
// Determine the file extension
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
fileName := strings.ToLower(filepath.Base(path))
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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
|
||||
log.Debug().Str("file_path", filePath).Time("converted_time", chapter.ConvertedTime).Msg("Chapter marked as converted from converted.txt")
|
||||
chapter.ConvertedTime = t
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
// Create a new Page object
|
||||
page := &manga.Page{
|
||||
Index: uint16(len(chapter.Pages)), // Simple index based on order
|
||||
Extension: ext,
|
||||
Size: uint64(buf.Len()),
|
||||
Contents: buf,
|
||||
IsSplitted: false,
|
||||
}
|
||||
|
||||
// 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", page.Index).
|
||||
Int64("bytes_read", bytesCopied).
|
||||
Msg("Page loaded successfully")
|
||||
}
|
||||
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
|
||||
file, err := fsys.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// Create output file with sequential naming
|
||||
pageIndex := uint16(len(chapter.Pages))
|
||||
outputName := fmt.Sprintf("%04d%s", pageIndex, ext)
|
||||
outputPath := filepath.Join(inputDir, outputName)
|
||||
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, file)
|
||||
closeErr := outFile.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(outputPath)
|
||||
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(outputPath)
|
||||
return fmt.Errorf("failed to close file %s: %w", outputPath, closeErr)
|
||||
}
|
||||
|
||||
page := &manga.PageFile{
|
||||
Index: pageIndex,
|
||||
Extension: ext,
|
||||
FilePath: outputPath,
|
||||
}
|
||||
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")
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Error().Str("file_path", filePath).Err(err).Msg("Failed during filesystem walk")
|
||||
return nil, err
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return nil, fmt.Errorf("failed to extract archive: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("file_path", filePath).
|
||||
Int("pages_loaded", len(chapter.Pages)).
|
||||
Int("pages_extracted", len(chapter.Pages)).
|
||||
Bool("is_converted", chapter.IsConverted).
|
||||
Bool("has_comic_info", chapter.ComicInfoXml != "").
|
||||
Msg("Chapter loading completed successfully")
|
||||
Msg("Chapter extraction completed")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// LoadChapter extracts the chapter from a CBZ/CBR file to disk.
|
||||
// It delegates to ExtractChapter 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)
|
||||
}
|
||||
|
||||
+380
-17
@@ -1,8 +1,17 @@
|
||||
package cbz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadChapter(t *testing.T) {
|
||||
@@ -17,9 +26,9 @@ func TestLoadChapter(t *testing.T) {
|
||||
testCases := []testCase{
|
||||
{
|
||||
name: "Original Chapter CBZ",
|
||||
filePath: "../../testdata/Chapter 1.cbz",
|
||||
expectedPages: 16,
|
||||
expectedSeries: "<Series>Boundless Necromancer</Series>",
|
||||
filePath: "../../testdata/Chapter 128.cbz",
|
||||
expectedPages: 14,
|
||||
expectedSeries: "<Series>The Knight King Who Returned with a God</Series>",
|
||||
expectedConversion: false,
|
||||
},
|
||||
{
|
||||
@@ -36,28 +45,382 @@ 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)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load chapter: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
actualPages := len(chapter.Pages)
|
||||
if actualPages != tc.expectedPages {
|
||||
t.Errorf("Expected %d pages, but got %d", tc.expectedPages, actualPages)
|
||||
}
|
||||
assert.Equal(t, tc.expectedPages, len(chapter.Pages))
|
||||
|
||||
if !strings.Contains(chapter.ComicInfoXml, tc.expectedSeries) {
|
||||
t.Errorf("ComicInfoXml does not contain the expected series: %s", tc.expectedSeries)
|
||||
}
|
||||
assert.Contains(t, chapter.ComicInfoXml, tc.expectedSeries)
|
||||
|
||||
if chapter.IsConverted != tc.expectedConversion {
|
||||
t.Errorf("Expected chapter to be converted: %t, but got %t", tc.expectedConversion, chapter.IsConverted)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestExtractChapter_PageExtensions(t *testing.T) {
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz")
|
||||
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) {
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
for i, page := range chapter.Pages {
|
||||
assert.Equal(t, uint16(i), page.Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapter_Cleanup(t *testing.T) {
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz")
|
||||
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")
|
||||
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")
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
package manga
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chapter represents a comic book chapter with pages stored on disk.
|
||||
type Chapter struct {
|
||||
// FilePath is the path to the chapter's directory.
|
||||
// FilePath is the path to the original archive file.
|
||||
FilePath string
|
||||
// Pages is a slice of pointers to Page objects.
|
||||
Pages []*Page
|
||||
// ComicInfo is a string containing information about the chapter.
|
||||
// Pages is a slice of page files on disk.
|
||||
Pages []*PageFile
|
||||
// ComicInfoXml holds the ComicInfo.xml content (small, kept in memory).
|
||||
ComicInfoXml string
|
||||
// IsConverted is a boolean that indicates whether the chapter has been converted.
|
||||
// IsConverted indicates whether the chapter has already been converted.
|
||||
IsConverted bool
|
||||
// ConvertedTime is a pointer to a time.Time object that indicates when the chapter was converted. Nil mean not converted.
|
||||
// ConvertedTime is when the chapter was converted.
|
||||
ConvertedTime time.Time
|
||||
// TempDir is the root temp directory for this chapter's extracted/converted files.
|
||||
// Cleanup removes this entire directory.
|
||||
TempDir string
|
||||
}
|
||||
|
||||
// SetConverted sets the IsConverted field to true and sets the ConvertedTime field to the current time.
|
||||
// SetConverted marks the chapter as converted with the current timestamp.
|
||||
func (chapter *Chapter) SetConverted() {
|
||||
chapter.IsConverted = true
|
||||
chapter.ConvertedTime = time.Now()
|
||||
}
|
||||
|
||||
// Cleanup removes the chapter's 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package manga
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChapter_SetConverted(t *testing.T) {
|
||||
chapter := &Chapter{}
|
||||
|
||||
if chapter.IsConverted {
|
||||
t.Error("New chapter should not be converted")
|
||||
}
|
||||
if !chapter.ConvertedTime.IsZero() {
|
||||
t.Error("New chapter should have zero ConvertedTime")
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
chapter.SetConverted()
|
||||
after := time.Now()
|
||||
|
||||
if !chapter.IsConverted {
|
||||
t.Error("Chapter should be converted after SetConverted()")
|
||||
}
|
||||
if chapter.ConvertedTime.Before(before) || chapter.ConvertedTime.After(after) {
|
||||
t.Error("ConvertedTime should be between before and after")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapter_Cleanup(t *testing.T) {
|
||||
// Create a temp dir with some files
|
||||
tmpDir := t.TempDir()
|
||||
chapterDir := filepath.Join(tmpDir, "chapter-cleanup-test")
|
||||
if err := os.MkdirAll(filepath.Join(chapterDir, "input"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(chapterDir, "input", "page.jpg"), []byte("test"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chapter := &Chapter{TempDir: chapterDir}
|
||||
|
||||
err := chapter.Cleanup()
|
||||
if err != nil {
|
||||
t.Fatalf("Cleanup failed: %v", err)
|
||||
}
|
||||
|
||||
if chapter.TempDir != "" {
|
||||
t.Error("TempDir should be empty after cleanup")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(chapterDir); !os.IsNotExist(err) {
|
||||
t.Error("Directory should not exist after cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapter_Cleanup_EmptyTempDir(t *testing.T) {
|
||||
chapter := &Chapter{TempDir: ""}
|
||||
|
||||
err := chapter.Cleanup()
|
||||
if err != nil {
|
||||
t.Errorf("Cleanup with empty TempDir should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChapter_Cleanup_NonexistentDir(t *testing.T) {
|
||||
chapter := &Chapter{TempDir: "/nonexistent/path/that/does/not/exist"}
|
||||
|
||||
// os.RemoveAll on a nonexistent path returns nil
|
||||
err := chapter.Cleanup()
|
||||
if err != nil {
|
||||
t.Errorf("Cleanup of nonexistent dir should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageFile_Struct(t *testing.T) {
|
||||
page := &PageFile{
|
||||
Index: 5,
|
||||
Extension: ".webp",
|
||||
FilePath: "/tmp/test/0005.webp",
|
||||
IsSplitted: true,
|
||||
SplitPartIndex: 2,
|
||||
}
|
||||
|
||||
if page.Index != 5 {
|
||||
t.Errorf("Expected Index 5, got %d", page.Index)
|
||||
}
|
||||
if page.Extension != ".webp" {
|
||||
t.Errorf("Expected Extension .webp, got %s", page.Extension)
|
||||
}
|
||||
if page.FilePath != "/tmp/test/0005.webp" {
|
||||
t.Errorf("Expected FilePath /tmp/test/0005.webp, got %s", page.FilePath)
|
||||
}
|
||||
if !page.IsSplitted {
|
||||
t.Error("Expected IsSplitted true")
|
||||
}
|
||||
if page.SplitPartIndex != 2 {
|
||||
t.Errorf("Expected SplitPartIndex 2, got %d", page.SplitPartIndex)
|
||||
}
|
||||
}
|
||||
+13
-15
@@ -1,18 +1,16 @@
|
||||
package manga
|
||||
|
||||
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."`
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package manga
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
)
|
||||
|
||||
// PageContainer is a struct that holds a manga page, its image, and the image format.
|
||||
type PageContainer struct {
|
||||
// Page is a pointer to a manga page object.
|
||||
Page *Page
|
||||
// Image is the decoded image of the manga page.
|
||||
Image image.Image
|
||||
// Format is a string representing the format of the image (e.g., "png", "jpeg", "webp").
|
||||
Format string
|
||||
// IsToBeConverted is a boolean flag indicating whether the image needs to be converted to another format.
|
||||
IsToBeConverted bool
|
||||
// HasBeenConverted is a boolean flag indicating whether the image has been converted to another format.
|
||||
HasBeenConverted bool
|
||||
}
|
||||
|
||||
func NewContainer(Page *Page, img image.Image, format string, isToBeConverted bool) *PageContainer {
|
||||
return &PageContainer{Page: Page, Image: img, Format: format, IsToBeConverted: isToBeConverted, HasBeenConverted: false}
|
||||
}
|
||||
|
||||
// SetConverted sets the converted image, its extension, and its size in the PageContainer.
|
||||
func (pc *PageContainer) SetConverted(converted *bytes.Buffer, extension string) {
|
||||
pc.Page.Contents = converted
|
||||
pc.Page.Extension = extension
|
||||
pc.Page.Size = uint64(converted.Len())
|
||||
pc.HasBeenConverted = true
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestCapture(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error = tt.initial
|
||||
var err = tt.initial
|
||||
Capture(&err, tt.errFunc, tt.msg)
|
||||
if err != nil && err.Error() != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, err.Error())
|
||||
@@ -110,7 +110,7 @@ func TestCaptureGeneric(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var err error = tt.initial
|
||||
var err = tt.initial
|
||||
CaptureGeneric(&err, tt.errFunc, tt.value, tt.msg)
|
||||
if err != nil && err.Error() != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, err.Error())
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+48
-58
@@ -25,6 +25,12 @@ type OptimizeOptions struct {
|
||||
}
|
||||
|
||||
// Optimize optimizes a CBZ/CBR file using the specified converter.
|
||||
// The new pipeline is disk-first:
|
||||
// 1. Fast check if already converted (no extraction)
|
||||
// 2. Extract archive to temp directory on disk
|
||||
// 3. Convert pages file-to-file (no image data in memory)
|
||||
// 4. Create output CBZ by streaming from disk
|
||||
// 5. Cleanup temp files
|
||||
func Optimize(options *OptimizeOptions) error {
|
||||
log.Info().Str("file", options.Path).Msg("Processing file")
|
||||
log.Debug().
|
||||
@@ -34,43 +40,54 @@ func Optimize(options *OptimizeOptions) error {
|
||||
Bool("split", options.Split).
|
||||
Msg("Optimization parameters")
|
||||
|
||||
// Load the chapter
|
||||
log.Debug().Str("file", options.Path).Msg("Loading chapter")
|
||||
chapter, err := cbz.LoadChapter(options.Path)
|
||||
// Step 1: Fast conversion check before extracting (new requirement)
|
||||
alreadyConverted, err := cbz.IsAlreadyConverted(context.Background(), options.Path)
|
||||
if err != 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).Err(err).Msg("Conversion check failed, proceeding with extraction")
|
||||
}
|
||||
if alreadyConverted {
|
||||
log.Info().Str("file", options.Path).Msg("Chapter already converted")
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
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", chapter.FilePath).
|
||||
Str("file", options.Path).
|
||||
Int("pages", len(chapter.Pages)).
|
||||
Uint8("quality", options.Quality).
|
||||
Bool("split", options.Split).
|
||||
Msg("Starting chapter conversion")
|
||||
Msg("Chapter extracted successfully")
|
||||
|
||||
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) {
|
||||
// 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) {
|
||||
if current%10 == 0 || current == total {
|
||||
log.Info().Str("file", chapter.FilePath).Uint32("current", current).Uint32("total", total).Msg("Converting")
|
||||
} else {
|
||||
@@ -83,7 +100,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: %v", err)
|
||||
return fmt.Errorf("failed to convert chapter: %w", err)
|
||||
}
|
||||
}
|
||||
if convertedChapter == nil {
|
||||
@@ -93,71 +110,45 @@ 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()
|
||||
|
||||
// Determine output path and handle CBR override logic
|
||||
log.Debug().
|
||||
Str("input_path", options.Path).
|
||||
Bool("override", options.Override).
|
||||
Msg("Determining output path")
|
||||
|
||||
// Step 4: Determine output path
|
||||
outputPath := options.Path
|
||||
originalPath := options.Path
|
||||
isCbrOverride := false
|
||||
|
||||
if options.Override {
|
||||
// For override mode, check if it's a CBR file that needs to be converted to CBZ
|
||||
pathLower := strings.ToLower(options.Path)
|
||||
if strings.HasSuffix(pathLower, ".cbr") {
|
||||
// Convert CBR to CBZ: change extension and mark for deletion
|
||||
outputPath = strings.TrimSuffix(options.Path, filepath.Ext(options.Path)) + ".cbz"
|
||||
isCbrOverride = true
|
||||
log.Debug().
|
||||
Str("original_path", originalPath).
|
||||
Str("output_path", outputPath).
|
||||
Msg("CBR to CBZ conversion: will delete original after conversion")
|
||||
} else {
|
||||
log.Debug().
|
||||
Str("original_path", originalPath).
|
||||
Str("output_path", outputPath).
|
||||
Msg("CBZ override mode: will overwrite original file")
|
||||
}
|
||||
} else {
|
||||
// Handle both .cbz and .cbr files - strip the extension and add _converted.cbz
|
||||
pathLower := strings.ToLower(options.Path)
|
||||
if strings.HasSuffix(pathLower, ".cbz") {
|
||||
outputPath = strings.TrimSuffix(options.Path, ".cbz") + "_converted.cbz"
|
||||
} else if strings.HasSuffix(pathLower, ".cbr") {
|
||||
outputPath = strings.TrimSuffix(options.Path, ".cbr") + "_converted.cbz"
|
||||
} else {
|
||||
// Fallback for other extensions - just add _converted.cbz
|
||||
outputPath = options.Path + "_converted.cbz"
|
||||
}
|
||||
log.Debug().
|
||||
Str("original_path", originalPath).
|
||||
Str("output_path", outputPath).
|
||||
Msg("Non-override mode: creating converted file alongside original")
|
||||
}
|
||||
|
||||
// Write the converted chapter to CBZ file
|
||||
// Step 5: Write converted chapter to CBZ (streaming from disk)
|
||||
log.Debug().Str("output_path", outputPath).Msg("Writing converted chapter to CBZ file")
|
||||
err = cbz.WriteChapterToCBZ(convertedChapter, outputPath)
|
||||
if err != nil {
|
||||
log.Error().Str("output_path", outputPath).Err(err).Msg("Failed to write converted chapter")
|
||||
return fmt.Errorf("failed to write converted chapter: %v", err)
|
||||
return fmt.Errorf("failed to write converted chapter: %w", err)
|
||||
}
|
||||
log.Debug().Str("output_path", outputPath).Msg("Successfully wrote converted chapter")
|
||||
|
||||
// If we're overriding a CBR file, delete the original CBR after successful write
|
||||
// If overriding a CBR file, delete the original
|
||||
if isCbrOverride {
|
||||
log.Debug().Str("file", originalPath).Msg("Attempting to delete original CBR file")
|
||||
err = os.Remove(originalPath)
|
||||
if err != nil {
|
||||
// Log the error but don't fail the operation since conversion succeeded
|
||||
log.Warn().Str("file", originalPath).Err(err).Msg("Failed to delete original CBR file")
|
||||
} else {
|
||||
log.Info().Str("file", originalPath).Msg("Deleted original CBR file")
|
||||
@@ -166,5 +157,4 @@ func Optimize(options *OptimizeOptions) error {
|
||||
|
||||
log.Info().Str("output", outputPath).Msg("Converted file written")
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/cbz"
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
)
|
||||
|
||||
func TestOptimizeIntegration(t *testing.T) {
|
||||
// Skip integration tests if no libwebp is available or testdata doesn't exist
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Check if testdata directory exists
|
||||
testdataDir := "../../testdata"
|
||||
if _, err := os.Stat(testdataDir); os.IsNotExist(err) {
|
||||
t.Skip("testdata directory not found, skipping integration tests")
|
||||
}
|
||||
|
||||
// Create temporary directory for tests
|
||||
tempDir, err := os.MkdirTemp("", "test_optimize_integration")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory")
|
||||
|
||||
// Get the real webp converter
|
||||
converterInstance, err := converter.Get(constant.WebP)
|
||||
if err != nil {
|
||||
t.Skip("WebP converter not available, skipping integration tests")
|
||||
}
|
||||
|
||||
// Prepare the converter
|
||||
err = converterInstance.PrepareConverter()
|
||||
if err != nil {
|
||||
t.Skip("Failed to prepare WebP converter, skipping integration tests")
|
||||
}
|
||||
|
||||
// Collect all test files (CBZ/CBR, excluding converted ones)
|
||||
var testFiles []string
|
||||
err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
fileName := strings.ToLower(info.Name())
|
||||
if (strings.HasSuffix(fileName, ".cbz") || strings.HasSuffix(fileName, ".cbr")) && !strings.Contains(fileName, "converted") {
|
||||
testFiles = append(testFiles, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(testFiles) == 0 {
|
||||
t.Skip("No test files found")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputFile string
|
||||
override bool
|
||||
expectedOutput string
|
||||
shouldDelete bool
|
||||
expectError bool
|
||||
}{}
|
||||
|
||||
// Generate test cases for each available test file
|
||||
for _, testFile := range testFiles {
|
||||
baseName := strings.TrimSuffix(filepath.Base(testFile), filepath.Ext(testFile))
|
||||
isCBR := strings.HasSuffix(strings.ToLower(testFile), ".cbr")
|
||||
|
||||
// Test without override
|
||||
tests = append(tests, struct {
|
||||
name string
|
||||
inputFile string
|
||||
override bool
|
||||
expectedOutput string
|
||||
shouldDelete bool
|
||||
expectError bool
|
||||
}{
|
||||
name: fmt.Sprintf("%s file without override", strings.ToUpper(filepath.Ext(testFile)[1:])),
|
||||
inputFile: testFile,
|
||||
override: false,
|
||||
expectedOutput: filepath.Join(filepath.Dir(testFile), baseName+"_converted.cbz"),
|
||||
shouldDelete: false,
|
||||
expectError: false,
|
||||
})
|
||||
|
||||
// Test with override
|
||||
if isCBR {
|
||||
tests = append(tests, struct {
|
||||
name string
|
||||
inputFile string
|
||||
override bool
|
||||
expectedOutput string
|
||||
shouldDelete bool
|
||||
expectError bool
|
||||
}{
|
||||
name: fmt.Sprintf("%s file with override", strings.ToUpper(filepath.Ext(testFile)[1:])),
|
||||
inputFile: testFile,
|
||||
override: true,
|
||||
expectedOutput: filepath.Join(filepath.Dir(testFile), baseName+".cbz"),
|
||||
shouldDelete: true,
|
||||
expectError: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a copy of the input file for this test
|
||||
testFile := filepath.Join(tempDir, tt.name+"_"+filepath.Base(tt.inputFile))
|
||||
data, err := os.ReadFile(tt.inputFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = os.WriteFile(testFile, data, 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Setup options with real converter
|
||||
options := &OptimizeOptions{
|
||||
ChapterConverter: converterInstance,
|
||||
Path: testFile,
|
||||
Quality: 85,
|
||||
Override: tt.override,
|
||||
Split: false,
|
||||
Timeout: 0,
|
||||
}
|
||||
|
||||
// Run optimization
|
||||
err = Optimize(options)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Determine expected output path for this test
|
||||
expectedOutput := tt.expectedOutput
|
||||
if tt.override && strings.HasSuffix(strings.ToLower(testFile), ".cbr") {
|
||||
expectedOutput = strings.TrimSuffix(testFile, filepath.Ext(testFile)) + ".cbz"
|
||||
} else if !tt.override {
|
||||
if strings.HasSuffix(strings.ToLower(testFile), ".cbz") {
|
||||
expectedOutput = strings.TrimSuffix(testFile, ".cbz") + "_converted.cbz"
|
||||
} else if strings.HasSuffix(strings.ToLower(testFile), ".cbr") {
|
||||
expectedOutput = strings.TrimSuffix(testFile, ".cbr") + "_converted.cbz"
|
||||
}
|
||||
} else {
|
||||
expectedOutput = testFile
|
||||
}
|
||||
|
||||
// Verify output file exists
|
||||
if _, err := os.Stat(expectedOutput); os.IsNotExist(err) {
|
||||
t.Errorf("Expected output file not found: %s", expectedOutput)
|
||||
}
|
||||
|
||||
// Verify output is a valid CBZ with converted content
|
||||
chapter, err := cbz.LoadChapter(expectedOutput)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to load converted chapter: %v", err)
|
||||
}
|
||||
|
||||
if !chapter.IsConverted {
|
||||
t.Error("Chapter is not marked as converted")
|
||||
}
|
||||
|
||||
// Verify all pages are in WebP format (real conversion indicator)
|
||||
for i, page := range chapter.Pages {
|
||||
if page.Extension != ".webp" {
|
||||
t.Errorf("Page %d is not converted to WebP format (got: %s)", i, page.Extension)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify original file deletion for CBR override
|
||||
if tt.shouldDelete {
|
||||
if _, err := os.Stat(testFile); !os.IsNotExist(err) {
|
||||
t.Error("Original CBR file should have been deleted but still exists")
|
||||
}
|
||||
} else {
|
||||
// Verify original file still exists (unless it's the same as output)
|
||||
if testFile != expectedOutput {
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Error("Original file should not have been deleted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up output file
|
||||
_ = os.Remove(expectedOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizeIntegration_AlreadyConverted(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create temporary directory
|
||||
tempDir, err := os.MkdirTemp("", "test_optimize_integration_converted")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory")
|
||||
|
||||
// Use a converted test file
|
||||
testdataDir := "../../testdata"
|
||||
if _, err := os.Stat(testdataDir); os.IsNotExist(err) {
|
||||
t.Skip("testdata directory not found, skipping integration tests")
|
||||
}
|
||||
|
||||
// Get the real webp converter
|
||||
converterInstance, err := converter.Get(constant.WebP)
|
||||
if err != nil {
|
||||
t.Skip("WebP converter not available, skipping integration tests")
|
||||
}
|
||||
|
||||
// Prepare the converter
|
||||
err = converterInstance.PrepareConverter()
|
||||
if err != nil {
|
||||
t.Skip("Failed to prepare WebP converter, skipping integration tests")
|
||||
}
|
||||
|
||||
var convertedFile string
|
||||
err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.Contains(strings.ToLower(info.Name()), "converted") {
|
||||
destPath := filepath.Join(tempDir, info.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(destPath, data, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
convertedFile = destPath
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if convertedFile == "" {
|
||||
t.Skip("No converted test file found")
|
||||
}
|
||||
|
||||
options := &OptimizeOptions{
|
||||
ChapterConverter: converterInstance,
|
||||
Path: convertedFile,
|
||||
Quality: 85,
|
||||
Override: false,
|
||||
Split: false,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
err = Optimize(options)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should not create a new file since it's already converted
|
||||
expectedOutput := strings.TrimSuffix(convertedFile, ".cbz") + "_converted.cbz"
|
||||
if _, err := os.Stat(expectedOutput); !os.IsNotExist(err) {
|
||||
t.Error("Should not have created a new converted file for already converted chapter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizeIntegration_InvalidFile(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Get the real webp converter
|
||||
converterInstance, err := converter.Get(constant.WebP)
|
||||
if err != nil {
|
||||
t.Skip("WebP converter not available, skipping integration tests")
|
||||
}
|
||||
|
||||
// Prepare the converter
|
||||
err = converterInstance.PrepareConverter()
|
||||
if err != nil {
|
||||
t.Skip("Failed to prepare WebP converter, skipping integration tests")
|
||||
}
|
||||
|
||||
options := &OptimizeOptions{
|
||||
ChapterConverter: converterInstance,
|
||||
Path: "/nonexistent/file.cbz",
|
||||
Quality: 85,
|
||||
Override: false,
|
||||
Split: false,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
err = Optimize(options)
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizeIntegration_Timeout(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create temporary directory
|
||||
tempDir, err := os.MkdirTemp("", "test_optimize_integration_timeout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer errs.CaptureGeneric(&err, os.RemoveAll, tempDir, "failed to remove temporary directory")
|
||||
|
||||
// Copy test files
|
||||
testdataDir := "../../testdata"
|
||||
if _, err := os.Stat(testdataDir); os.IsNotExist(err) {
|
||||
t.Skip("testdata directory not found, skipping integration tests")
|
||||
}
|
||||
|
||||
// Get the real webp converter
|
||||
converterInstance, err := converter.Get(constant.WebP)
|
||||
if err != nil {
|
||||
t.Skip("WebP converter not available, skipping integration tests")
|
||||
}
|
||||
|
||||
// Prepare the converter
|
||||
err = converterInstance.PrepareConverter()
|
||||
if err != nil {
|
||||
t.Skip("Failed to prepare WebP converter, skipping integration tests")
|
||||
}
|
||||
|
||||
var cbzFile string
|
||||
err = filepath.Walk(testdataDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".cbz") && !strings.Contains(info.Name(), "converted") {
|
||||
destPath := filepath.Join(tempDir, "test.cbz")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(destPath, data, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cbzFile = destPath
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cbzFile == "" {
|
||||
t.Skip("No CBZ test file found")
|
||||
}
|
||||
|
||||
// Test with short timeout to force timeout during conversion
|
||||
options := &OptimizeOptions{
|
||||
ChapterConverter: converterInstance,
|
||||
Path: cbzFile,
|
||||
Quality: 85,
|
||||
Override: false,
|
||||
Split: false,
|
||||
Timeout: 1 * time.Nanosecond, // Extremely short timeout to force timeout
|
||||
}
|
||||
|
||||
err = Optimize(options)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error but got none")
|
||||
}
|
||||
|
||||
// Check that the error contains timeout information
|
||||
if err != nil && !strings.Contains(err.Error(), "context deadline exceeded") && !strings.Contains(err.Error(), "timeout") {
|
||||
t.Errorf("Expected timeout error message, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func TestOptimize(t *testing.T) {
|
||||
}
|
||||
|
||||
// Clean up output file
|
||||
os.Remove(expectedOutput)
|
||||
_ = os.Remove(expectedOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,19 @@ 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 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.
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -30,8 +36,8 @@ func Available() []constant.ConversionFormat {
|
||||
return lo.Keys(converters)
|
||||
}
|
||||
|
||||
// Get returns a packer by name.
|
||||
// If the packer is not available, an error is returned.
|
||||
// Get returns a converter by format name.
|
||||
// If the converter is not available, an error is returned.
|
||||
var Get = getConverter
|
||||
|
||||
func getConverter(name constant.ConversionFormat) (Converter, error) {
|
||||
@@ -39,7 +45,7 @@ func getConverter(name constant.ConversionFormat) (Converter, error) {
|
||||
return converter, nil
|
||||
}
|
||||
|
||||
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 nil, fmt.Errorf("unknown converter \"%s\", available options are %s", name, strings.Join(lo.Map(Available(), func(item constant.ConversionFormat, index int) string {
|
||||
return item.String()
|
||||
}), ", "))
|
||||
}
|
||||
|
||||
+169
-161
@@ -1,116 +1,90 @@
|
||||
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"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
func TestConvertChapter(t *testing.T) {
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
genTestChapter func(path string) (*manga.Chapter, error)
|
||||
split bool
|
||||
expectFailure []constant.ConversionFormat
|
||||
expectPartialSuccess []constant.ConversionFormat
|
||||
name string
|
||||
genTestChapter func(t *testing.T, dir string) (*manga.Chapter, []string)
|
||||
split bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "All split pages",
|
||||
genTestChapter: genHugePage,
|
||||
split: true,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
name: "All split pages",
|
||||
genTestChapter: genHugePage,
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
name: "Big Pages, no split",
|
||||
genTestChapter: genHugePage,
|
||||
split: false,
|
||||
expectFailure: []constant.ConversionFormat{constant.WebP},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
name: "Big Pages, no split",
|
||||
genTestChapter: genHugePageNoSplit,
|
||||
split: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "No split pages",
|
||||
genTestChapter: genSmallPages,
|
||||
split: false,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
name: "No split pages",
|
||||
genTestChapter: genSmallPages,
|
||||
split: false,
|
||||
},
|
||||
{
|
||||
name: "Mix of split and no split pages",
|
||||
genTestChapter: genMixSmallBig,
|
||||
split: true,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{},
|
||||
name: "Mix of split and no split pages",
|
||||
genTestChapter: genMixSmallBig,
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
name: "Mix of Huge and small page",
|
||||
genTestChapter: genMixSmallHuge,
|
||||
split: false,
|
||||
expectFailure: []constant.ConversionFormat{},
|
||||
expectPartialSuccess: []constant.ConversionFormat{constant.WebP},
|
||||
name: "Mix of Huge and small page",
|
||||
genTestChapter: genMixSmallHuge,
|
||||
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)
|
||||
|
||||
}
|
||||
defer errs.CaptureGeneric(&err, os.Remove, temp.Name(), "failed to remove temporary file")
|
||||
for _, converter := range Available() {
|
||||
converter, err := Get(converter)
|
||||
for _, converterFormat := range Available() {
|
||||
conv, err := Get(converterFormat)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get converter: %v", err)
|
||||
}
|
||||
t.Run(converter.Format().String(), func(t *testing.T) {
|
||||
t.Run(conv.Format().String(), func(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
chapter, err := tc.genTestChapter(temp.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load test genTestChapter: %v", err)
|
||||
}
|
||||
tempDir := t.TempDir()
|
||||
chapter, expectedExtensions := tc.genTestChapter(t, tempDir)
|
||||
|
||||
quality := uint8(80)
|
||||
|
||||
progress := func(msg string, current uint32, total uint32) {
|
||||
t.Log(msg)
|
||||
}
|
||||
|
||||
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, quality, tc.split, progress)
|
||||
if err != nil {
|
||||
if convertedChapter != nil && slices.Contains(tc.expectPartialSuccess, converter.Format()) {
|
||||
t.Logf("Partial success to convert genTestChapter: %v", err)
|
||||
return
|
||||
}
|
||||
if slices.Contains(tc.expectFailure, converter.Format()) {
|
||||
t.Logf("Expected failure to convert genTestChapter: %v", err)
|
||||
return
|
||||
}
|
||||
t.Fatalf("failed to convert genTestChapter: %v", err)
|
||||
} else if slices.Contains(tc.expectFailure, converter.Format()) {
|
||||
t.Fatalf("expected failure to convert genTestChapter didn't happen")
|
||||
convertedChapter, err := conv.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")
|
||||
}
|
||||
|
||||
if len(convertedChapter.Pages) == 0 {
|
||||
t.Fatalf("no pages were converted")
|
||||
t.Fatal("no pages were converted")
|
||||
}
|
||||
|
||||
if len(convertedChapter.Pages) != len(chapter.Pages) {
|
||||
t.Fatalf("converted chapter has different number of pages")
|
||||
if len(convertedChapter.Pages) != len(expectedExtensions) {
|
||||
t.Fatalf("converted chapter has %d pages but expected %d", len(convertedChapter.Pages), len(expectedExtensions))
|
||||
}
|
||||
|
||||
for _, page := range convertedChapter.Pages {
|
||||
if page.Extension != ".webp" {
|
||||
t.Errorf("page %d was not converted to webp format", page.Index)
|
||||
for i, page := range convertedChapter.Pages {
|
||||
expectedExt := expectedExtensions[i]
|
||||
if page.Extension != expectedExt {
|
||||
t.Errorf("page %d has extension %s but expected %s", page.Index, page.Extension, expectedExt)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -119,118 +93,152 @@ func TestConvertChapter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func genHugePage(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
|
||||
var pages []*manga.Page
|
||||
for i := 0; i < 1; i++ { // Assuming there are 5 pages for the test
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1, 17000))
|
||||
buf := new(bytes.Buffer)
|
||||
err := jpeg.Encode(buf, img, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
Index: uint16(i),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
err = jpeg.Encode(f, img, nil)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func genSmallPages(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 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)
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
|
||||
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, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
Index: uint16(i),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "output"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
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 genMixSmallBig(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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)
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
|
||||
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, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
Index: uint16(i),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
if err := os.MkdirAll(filepath.Join(dir, "output"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
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 genMixSmallHuge(path string) (*manga.Chapter, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer errs.Capture(&err, file.Close, "failed to close file")
|
||||
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.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, err
|
||||
}
|
||||
page := &manga.Page{
|
||||
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),
|
||||
Contents: buf,
|
||||
Extension: ".jpg",
|
||||
}
|
||||
pages = append(pages, page)
|
||||
FilePath: pagePath,
|
||||
})
|
||||
}
|
||||
|
||||
return &manga.Chapter{
|
||||
FilePath: path,
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(dir, "test.cbz"),
|
||||
TempDir: dir,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return chapter, []string{".webp", ".webp", ".webp", ".webp", ".webp"}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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{
|
||||
Index: uint16(i),
|
||||
Extension: ".jpg",
|
||||
FilePath: pagePath,
|
||||
})
|
||||
}
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(dir, "test.cbz"),
|
||||
TempDir: dir,
|
||||
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"}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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{
|
||||
Index: uint16(i),
|
||||
Extension: ".jpg",
|
||||
FilePath: pagePath,
|
||||
})
|
||||
}
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(dir, "test.cbz"),
|
||||
TempDir: dir,
|
||||
Pages: pages,
|
||||
}
|
||||
|
||||
// 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"}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
package webp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -27,18 +29,20 @@ type Converter struct {
|
||||
maxHeight int
|
||||
cropHeight int
|
||||
isPrepared bool
|
||||
// pageWorkerGuard limits concurrent cwebp processes across all chapters.
|
||||
pageWorkerGuard chan struct{}
|
||||
}
|
||||
|
||||
func (converter *Converter) Format() (format constant.ConversionFormat) {
|
||||
func (converter *Converter) Format() constant.ConversionFormat {
|
||||
return constant.WebP
|
||||
}
|
||||
|
||||
func New() *Converter {
|
||||
return &Converter{
|
||||
//maxHeight: 16383 / 2,
|
||||
maxHeight: 4000,
|
||||
cropHeight: 2000,
|
||||
isPrepared: false,
|
||||
maxHeight: 4000,
|
||||
cropHeight: 2000,
|
||||
isPrepared: false,
|
||||
pageWorkerGuard: make(chan struct{}, runtime.NumCPU()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,400 +58,306 @@ 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).
|
||||
Int("max_goroutines", runtime.NumCPU()).
|
||||
Msg("Starting chapter conversion")
|
||||
Msg("Starting file-to-file chapter conversion")
|
||||
|
||||
err := converter.PrepareConverter()
|
||||
if err != nil {
|
||||
log.Error().Str("chapter", chapter.FilePath).Err(err).Msg("Failed to prepare converter")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var wgConvertedPages sync.WaitGroup
|
||||
maxGoroutines := runtime.NumCPU()
|
||||
// Validate TempDir is set to prevent writing to cwd
|
||||
if chapter.TempDir == "" {
|
||||
return nil, fmt.Errorf("chapter TempDir is empty, cannot create output directory")
|
||||
}
|
||||
|
||||
pagesChan := make(chan *manga.PageContainer, maxGoroutines)
|
||||
errChan := make(chan error, maxGoroutines)
|
||||
doneChan := make(chan struct{})
|
||||
// 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)
|
||||
}
|
||||
|
||||
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
|
||||
// Check for early context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Start the worker pool
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
for page := range pagesChan {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case guard <- struct{}{}: // would block if guard channel is already filled
|
||||
}
|
||||
guard := converter.pageWorkerGuard
|
||||
var totalPages atomic.Uint32
|
||||
totalPages.Store(uint32(len(chapter.Pages)))
|
||||
|
||||
go func(pageToConvert *manga.PageContainer) {
|
||||
defer func() {
|
||||
wgConvertedPages.Done()
|
||||
<-guard
|
||||
}()
|
||||
|
||||
// Check context cancellation before processing
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
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()
|
||||
pages = append(pages, convertedPage.Page)
|
||||
progress(fmt.Sprintf("Converted %d/%d pages to %s format", len(pages), totalPages, converter.Format()), uint32(len(pages)), totalPages)
|
||||
pagesMutex.Unlock()
|
||||
}(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:
|
||||
}
|
||||
|
||||
go func(page *manga.Page) {
|
||||
defer wgPages.Done()
|
||||
|
||||
splitNeeded, img, format, err := converter.checkPageNeedsSplit(page, split)
|
||||
if err != nil {
|
||||
select {
|
||||
case errChan <- err:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if img != nil {
|
||||
wgConvertedPages.Add(1)
|
||||
select {
|
||||
case pagesChan <- manga.NewContainer(page, img, format, false):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !splitNeeded {
|
||||
wgConvertedPages.Add(1)
|
||||
select {
|
||||
case pagesChan <- manga.NewContainer(page, img, format, true):
|
||||
case <-ctx.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():
|
||||
return
|
||||
}
|
||||
}
|
||||
}(page)
|
||||
type pageResult struct {
|
||||
pages []*manga.PageFile
|
||||
err error
|
||||
}
|
||||
|
||||
wgPages.Wait()
|
||||
close(pagesChan)
|
||||
results := make([]pageResult, len(chapter.Pages))
|
||||
var wg sync.WaitGroup
|
||||
var convertedCount atomic.Uint32
|
||||
|
||||
// Wait for all conversions to complete or context cancellation
|
||||
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
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
results[idx] = pageResult{err: ctx.Err()}
|
||||
return
|
||||
case guard <- struct{}{}:
|
||||
}
|
||||
defer func() { <-guard }()
|
||||
|
||||
// Check context after acquiring worker slot
|
||||
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}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Wait for completion or context cancellation
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
wgConvertedPages.Wait()
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Conversion completed successfully
|
||||
case <-ctx.Done():
|
||||
log.Warn().Str("chapter", chapter.FilePath).Msg("Chapter conversion cancelled due to timeout")
|
||||
// Wait for in-flight goroutines to finish
|
||||
<-done
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
close(errChan)
|
||||
close(guard)
|
||||
// Collect results — separate fatal errors from page-ignored errors
|
||||
var convertedPages []*manga.PageFile
|
||||
var fatalErrors []error
|
||||
var ignoredErrors []error
|
||||
|
||||
var errList []error
|
||||
for err := range errChan {
|
||||
errList = append(errList, err)
|
||||
}
|
||||
|
||||
var aggregatedError error = nil
|
||||
if len(errList) > 0 {
|
||||
aggregatedError = errors.Join(errList...)
|
||||
log.Debug().
|
||||
Str("chapter", chapter.FilePath).
|
||||
Int("error_count", len(errList)).
|
||||
Msg("Conversion completed with errors")
|
||||
} else {
|
||||
log.Debug().
|
||||
Str("chapter", chapter.FilePath).
|
||||
Int("pages_converted", len(pages)).
|
||||
Msg("Conversion completed successfully")
|
||||
}
|
||||
|
||||
slices.SortFunc(pages, func(a, b *manga.Page) int {
|
||||
if a.Index == b.Index {
|
||||
return int(a.SplitPartIndex) - int(b.SplitPartIndex)
|
||||
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 pageIgnored *converterrors.PageIgnoredError
|
||||
if errors.As(result.err, &pageIgnored) {
|
||||
ignoredErrors = append(ignoredErrors, result.err)
|
||||
} else {
|
||||
fatalErrors = append(fatalErrors, result.err)
|
||||
}
|
||||
}
|
||||
return int(a.Index) - int(b.Index)
|
||||
if result.pages != nil {
|
||||
convertedPages = append(convertedPages, result.pages...)
|
||||
}
|
||||
}
|
||||
|
||||
// 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]
|
||||
if a.Index == b.Index {
|
||||
return a.SplitPartIndex < b.SplitPartIndex
|
||||
}
|
||||
return a.Index < b.Index
|
||||
})
|
||||
chapter.Pages = pages
|
||||
|
||||
chapter.Pages = convertedPages
|
||||
|
||||
var aggregatedError error
|
||||
if len(ignoredErrors) > 0 {
|
||||
aggregatedError = errors.Join(ignoredErrors...)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("chapter", chapter.FilePath).
|
||||
Int("final_page_count", len(pages)).
|
||||
Msg("Pages sorted and chapter updated")
|
||||
|
||||
runtime.GC()
|
||||
log.Debug().Str("chapter", chapter.FilePath).Msg("Garbage collection completed")
|
||||
Int("converted_pages", len(convertedPages)).
|
||||
Msg("Chapter conversion completed")
|
||||
|
||||
return chapter, aggregatedError
|
||||
}
|
||||
|
||||
func (converter *Converter) cropImage(img image.Image) ([]image.Image, error) {
|
||||
bounds := img.Bounds()
|
||||
height := bounds.Dy()
|
||||
width := bounds.Dx()
|
||||
// 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
|
||||
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, fmt.Sprintf("%04d.webp", page.Index))
|
||||
err := EncodeFile(page.FilePath, outputPath, uint(quality))
|
||||
|
||||
if err == nil {
|
||||
// Success! No image decoding needed.
|
||||
return []*manga.PageFile{{
|
||||
Index: page.Index,
|
||||
Extension: ".webp",
|
||||
FilePath: outputPath,
|
||||
}}, 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")
|
||||
|
||||
numParts := height / converter.cropHeight
|
||||
if height%converter.cropHeight != 0 {
|
||||
numParts++
|
||||
}
|
||||
|
||||
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)
|
||||
var pages []*manga.PageFile
|
||||
|
||||
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 - i*converter.cropHeight
|
||||
partHeight = height - yOffset
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("part_index", i).
|
||||
Int("part_height", partHeight).
|
||||
Int("y_offset", i*converter.cropHeight).
|
||||
Msg("Cropping image part")
|
||||
outputPath := filepath.Join(outputDir, fmt.Sprintf("%04d-%02d.webp", page.Index, i))
|
||||
err := EncodeFileWithCrop(page.FilePath, outputPath, uint(quality), 0, yOffset, width, partHeight)
|
||||
|
||||
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).
|
||||
Uint16("page_index", page.Index).
|
||||
Int("part", i).
|
||||
Err(err).
|
||||
Msg("Failed to crop image part")
|
||||
return nil, fmt.Errorf("error cropping part %d: %v", i+1, 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)
|
||||
}
|
||||
|
||||
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")
|
||||
pages = append(pages, &manga.PageFile{
|
||||
Index: page.Index,
|
||||
Extension: ".webp",
|
||||
FilePath: outputPath,
|
||||
IsSplitted: true,
|
||||
SplitPartIndex: uint16(i),
|
||||
})
|
||||
}
|
||||
|
||||
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).
|
||||
Bool("split_requested", splitRequested).
|
||||
Int("page_size", len(page.Contents.Bytes())).
|
||||
Msg("Analyzing page for splitting")
|
||||
|
||||
reader := bytes.NewBuffer(page.Contents.Bytes())
|
||||
img, format, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
log.Debug().Uint16("page_index", page.Index).Err(err).Msg("Failed to decode page image")
|
||||
return false, nil, format, err
|
||||
}
|
||||
|
||||
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")
|
||||
Int("parts", len(pages)).
|
||||
Msg("Split conversion completed")
|
||||
|
||||
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
|
||||
return pages, 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))
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Uint16("page_index", container.Page.Index).
|
||||
Err(err).
|
||||
Msg("Failed to convert page to WebP")
|
||||
return nil, err
|
||||
return 0, 0, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
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)
|
||||
config, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
return config.Width, config.Height, nil
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package webp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
"time"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
|
||||
@@ -18,102 +17,45 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createTestImage(width, height int, format string) (image.Image, error) {
|
||||
func createTestImageFile(t *testing.T, path string, width, height int) {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
// 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 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 "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)
|
||||
f, err := os.Create(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
buf, ext, err := encodeImage(img, format)
|
||||
err = jpeg.Encode(f, img, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &manga.Page{
|
||||
Index: uint16(index),
|
||||
Contents: buf,
|
||||
Extension: ext,
|
||||
Size: uint64(buf.Len()),
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
func validateConvertedImage(t *testing.T, page *manga.Page) {
|
||||
require.NotNil(t, page.Contents)
|
||||
require.Greater(t, page.Contents.Len(), 0)
|
||||
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))
|
||||
|
||||
// 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")
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: filepath.Join(dir, "test.cbz"),
|
||||
TempDir: dir,
|
||||
}
|
||||
|
||||
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")
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
return chapter, dir
|
||||
}
|
||||
|
||||
// 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 []*manga.Page
|
||||
pages []struct{ w, h int }
|
||||
split bool
|
||||
expectSplit bool
|
||||
expectError bool
|
||||
@@ -121,34 +63,33 @@ func TestConverter_ConvertChapter(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "Single normal image",
|
||||
pages: []*manga.Page{createTestPage(t, 1, 800, 1200, "jpeg")},
|
||||
pages: []struct{ w, h int }{{800, 1200}},
|
||||
split: false,
|
||||
expectSplit: false,
|
||||
numExpected: 1,
|
||||
},
|
||||
{
|
||||
name: "Multiple normal images",
|
||||
pages: []*manga.Page{
|
||||
createTestPage(t, 1, 800, 1200, "png"),
|
||||
createTestPage(t, 2, 800, 1200, "jpeg"),
|
||||
pages: []struct{ w, h int }{
|
||||
{800, 1200},
|
||||
{800, 1200},
|
||||
{800, 1200},
|
||||
},
|
||||
split: false,
|
||||
expectSplit: false,
|
||||
numExpected: 2,
|
||||
numExpected: 3,
|
||||
},
|
||||
{
|
||||
name: "Tall image with split enabled",
|
||||
pages: []*manga.Page{createTestPage(t, 1, 800, 5000, "jpeg")},
|
||||
pages: []struct{ w, h int }{{800, 5000}},
|
||||
split: true,
|
||||
expectSplit: true,
|
||||
numExpected: 3, // Based on cropHeight of 2000
|
||||
expectSplit: false, // cwebp handles 5000px fine (< 16383 webp max), no split needed
|
||||
numExpected: 1,
|
||||
},
|
||||
{
|
||||
name: "Tall image without split",
|
||||
pages: []*manga.Page{createTestPage(t, 1, 800, webpMaxHeight+100, "png")},
|
||||
pages: []struct{ w, h int }{{800, webpMaxHeight + 100}},
|
||||
split: false,
|
||||
expectError: true,
|
||||
numExpected: 1,
|
||||
numExpected: 1, // kept as-is
|
||||
},
|
||||
}
|
||||
|
||||
@@ -158,18 +99,16 @@ func TestConverter_ConvertChapter(t *testing.T) {
|
||||
err := converter.PrepareConverter()
|
||||
require.NoError(t, err)
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
Pages: tt.pages,
|
||||
}
|
||||
chapter, _ := createTestChapter(t, 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, "Progress should never decrease")
|
||||
assert.GreaterOrEqual(t, current, lastProgress)
|
||||
lastProgress = current
|
||||
assert.LessOrEqual(t, current, total, "Current progress should not exceed total")
|
||||
assert.LessOrEqual(t, current, total)
|
||||
}
|
||||
|
||||
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, tt.split, progress)
|
||||
@@ -186,22 +125,14 @@ 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++ {
|
||||
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)
|
||||
prev := convertedChapter.Pages[i-1]
|
||||
curr := convertedChapter.Pages[i]
|
||||
if prev.Index == curr.Index {
|
||||
assert.Less(t, prev.SplitPartIndex, curr.SplitPartIndex)
|
||||
} else {
|
||||
assert.Less(t, prevPage.Index, currPage.Index,
|
||||
"Pages should be in ascending order")
|
||||
assert.Less(t, prev.Index, curr.Index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,109 +144,13 @@ func TestConverter_ConvertChapter(t *testing.T) {
|
||||
break
|
||||
}
|
||||
}
|
||||
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
|
||||
}{
|
||||
{
|
||||
name: "Convert PNG to WebP",
|
||||
format: "png",
|
||||
isToBeConverted: true,
|
||||
expectWebP: true,
|
||||
},
|
||||
{
|
||||
name: "Already WebP",
|
||||
format: "webp",
|
||||
isToBeConverted: true,
|
||||
expectWebP: true,
|
||||
},
|
||||
{
|
||||
name: "Skip conversion",
|
||||
format: "png",
|
||||
isToBeConverted: false,
|
||||
expectWebP: false,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
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_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
|
||||
assert.True(t, splitFound, "Expected split pages")
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, img)
|
||||
assert.NotEmpty(t, format)
|
||||
assert.Equal(t, tt.expectSplit, needsSplit)
|
||||
// Verify all output files exist
|
||||
for _, page := range convertedChapter.Pages {
|
||||
assert.FileExists(t, page.FilePath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -330,36 +165,323 @@ func TestConverter_ConvertChapter_Timeout(t *testing.T) {
|
||||
err := converter.PrepareConverter()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a test chapter with a few pages
|
||||
pages := []*manga.Page{
|
||||
createTestPage(t, 1, 800, 1200, "jpeg"),
|
||||
createTestPage(t, 2, 800, 1200, "jpeg"),
|
||||
createTestPage(t, 3, 800, 1200, "jpeg"),
|
||||
}
|
||||
chapter, _ := createTestChapter(t, []struct{ w, h int }{
|
||||
{800, 1200},
|
||||
{800, 1200},
|
||||
{800, 1200},
|
||||
})
|
||||
|
||||
chapter := &manga.Chapter{
|
||||
FilePath: "/test/chapter.cbz",
|
||||
Pages: pages,
|
||||
}
|
||||
progress := func(message string, current uint32, total uint32) {}
|
||||
|
||||
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)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
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)
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
chapter, _ := createTestChapter(t, pages)
|
||||
|
||||
progress := func(message string, current uint32, total uint32) {}
|
||||
|
||||
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)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
var convertErr error
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, convertErr = converter.ConvertChapter(ctx, chapter, 80, false, progress)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
assert.Error(t, convertErr)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Deadlock detected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConverter_ConvertChapter_ConcurrentChapters_NoDeadlock(t *testing.T) {
|
||||
converter := New()
|
||||
err := converter.PrepareConverter()
|
||||
require.NoError(t, err)
|
||||
|
||||
numChapters := 20
|
||||
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)
|
||||
}
|
||||
|
||||
progress := func(message string, current uint32, total uint32) {}
|
||||
|
||||
parallelism := 4
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, parallelism)
|
||||
|
||||
testCtx, testCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer testCancel()
|
||||
|
||||
for _, chapter := range chapters {
|
||||
wg.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
|
||||
go func(ch *manga.Chapter) {
|
||||
defer wg.Done()
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
_, _ = converter.ConvertChapter(ctx, ch, 80, false, progress)
|
||||
}(chapter)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1,22 +1,57 @@
|
||||
package webp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/belphemur/go-webpbin/v2"
|
||||
"image"
|
||||
"io"
|
||||
)
|
||||
|
||||
const libwebpVersion = "1.6.0"
|
||||
|
||||
func PrepareEncoder() error {
|
||||
webpbin.SetLibVersion(libwebpVersion)
|
||||
container := webpbin.NewCWebP()
|
||||
return container.BinWrapper.Run()
|
||||
var config = webpbin.NewConfig()
|
||||
|
||||
var prepareMutex sync.Mutex
|
||||
|
||||
func init() {
|
||||
config.SetLibVersion(libwebpVersion)
|
||||
}
|
||||
func Encode(w io.Writer, m image.Image, quality uint) error {
|
||||
return webpbin.NewCWebP().
|
||||
|
||||
func PrepareEncoder() error {
|
||||
prepareMutex.Lock()
|
||||
defer prepareMutex.Unlock()
|
||||
|
||||
container := webpbin.NewCWebP(config)
|
||||
version, err := container.Version()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(version, libwebpVersion) {
|
||||
return fmt.Errorf("unexpected webp version: got %s, want %s", version, libwebpVersion)
|
||||
}
|
||||
|
||||
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 {
|
||||
return webpbin.NewCWebP(config).
|
||||
Quality(quality).
|
||||
InputImage(m).
|
||||
Output(w).
|
||||
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).
|
||||
Run()
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user