Compare commits

...
294 Commits
Author SHA1 Message Date
Antoine AflaloandGitHub 868347cc55 Merge pull request #215 from Belphemur/renovate/actions-setup-go-7.x
chore(deps): update actions/setup-go action to v7
2026-07-21 20:26:52 -04:00
Antoine AflaloandGitHub bf21d3842f Merge pull request #217 from Belphemur/feat/keep-filenames
feat(converter): add --keep-filenames flag to preserve original page filenames
2026-07-21 20:26:36 -04:00
Antoine Aflalo b58999c6ec fix(loader): deduplicate original names by stem to prevent same-stem conversion race
CodeRabbit review on #217: allocateUniqueBaseName deduplicated by full
filename, but intermediatePageName strips extensions, so a/page.png and
b/page.jpg both converted to outputDir/page.webp concurrently. Track
used stems instead so colliding pairs resolve to e.g. page.png +
page_0001.jpg, keeping both intermediate and final names unique.
2026-07-21 20:13:02 -04:00
Antoine Aflalo 4cb5d10d3f fix(loader): normalize archive separators before preserving filenames
CodeRabbit review on #217: a ZIP entry named with Windows separators
(e.g. "..\outside.png") survives filepath.Base on Unix and would be
written verbatim as a ZIP entry name, which Windows consumers may
interpret as path traversal. Add archiveBaseName helper that replaces
"\" with "/" before deriving the bare base name for OriginalName.
2026-07-21 19:51:47 -04:00
Antoine Aflalo 21f92bc6f5 fix(converter): preserve OriginalName through conversion and fix race
- Fix critical bug: convertPageFile and splitAndConvert now copy
  OriginalName to the output PageFile so --keep-filenames works
  end-to-end on actually-converted pages.
- Fix concurrency race: ExtractChapter deduplicates OriginalName when
  source archive has same base filename in different subdirectories.
- Fix collision fallback: resolvePageName loops until an unused
  indexed name is found instead of single-shot assignment.
- Fix gofmt indentation in optimize_command.go.
2026-07-21 19:45:25 -04:00
Antoine Aflalo f3ba585c13 feat(converter): add --keep-filenames flag to preserve original page filenames
Add a new `--keep-filenames` boolean flag (no shorthand, default false) on
both `optimize` and `watch` commands. When enabled, page entries in the
output CBZ preserve their original base filenames instead of being
renumbered to `%04d` sequential indices. Extension is swapped on format
conversion (e.g. `001.png` → `001.webp`), split pages get `-NN` suffixes
on the original stem, and duplicate base names fall back to
`<stem>_<index>.<ext>`. Watch mode supports the flag via the
`keep-filenames` viper config key.

Design: data-driven `OriginalName` field on `manga.PageFile`, set at
extraction in `cbz_loader.ExtractChapter`. The `Converter` interface is
unchanged. Flag off → behavior identical to before.

Closes #216
2026-07-21 19:38:17 -04:00
renovate[bot]andGitHub e56b70b038 chore(deps): update actions/setup-go action to v7 2026-07-16 04:49:34 +00:00
Antoine AflaloandGitHub 650623b340 Merge pull request #214 from Belphemur/copilot/avoid-runnning-big-file-test
chore: remove Git LFS large-file test and fixture
2026-07-11 08:14:06 -04:00
4b961f18e3 chore: stop tracking build artifact binaries
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-11 12:12:21 +00:00
2dfc39505c chore: remove Git LFS large-file test and fixture
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-11 12:06:33 +00:00
e596a026dd chore: remove Git LFS large-file test and fixture
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-11 12:05:02 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9423cde9f7 fix(deps): update module golang.org/x/image to v0.44.0 (#213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-08 20:12:54 +00:00
Antoine AflaloandGitHub facb2f192b Merge pull request #211 from Belphemur/copilot/full-refactor-memory-usage
Full project refactoring for memory efficiency
2026-07-04 18:54:02 -04:00
5e5cac4883 fix: address copilot and coderabbit review feedback
- Use named returns for IsAlreadyConverted and WriteChapterToCBZ so
  errs.Capture defers propagate close errors to the caller.
- Add context.Context to IsAlreadyConverted and ExtractChapter for
  cancellation/timeout support; thread it from Optimize.
- Filter non-image files (__MACOSX, Thumbs.db, .DS_Store, etc.) and
  only extract supported image extensions in ExtractChapter.
- Validate TempDir is non-empty in ConvertChapter before creating output dir.
- Separate fatal conversion errors from PageIgnoredError in the WebP
  converter result aggregation so real failures aren't masked.
- Fix error message "failed to load chapter" → "failed to extract chapter".
- Use %w instead of %v in fmt.Errorf for proper error wrapping.
- Fix LoadChapter doc comment to match its actual behavior.
- Extract shared parseConvertedComment/parseConvertedCommentTime helpers
  to avoid duplicated zip-comment parsing logic.
- Convert tests to use testify assertions (assert/require) per guidelines.

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-04 22:39:19 +00:00
5ae05e5f0c fix: address review feedback from copilot and coderabbit
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-04 22:34:28 +00:00
164dc577b9 refactor!: disk-first zero-copy architecture with file-to-file cwebp conversion
BREAKING CHANGE: Complete refactoring of the conversion pipeline.

- Replace in-memory Page/PageContainer with disk-based PageFile struct
- Extract archives to temp directory instead of loading into memory
- Use cwebp InputFile/OutputFile for zero-copy file-to-file conversion
- Use cwebp -crop for splitting (no Go-side image decode needed)
- Check if already converted before extracting (fast pre-check)
- Remove oliamb/cutter dependency (replaced by cwebp -crop)
- Remove page_container.go (no longer needed)
- Add comprehensive tests with improved coverage

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-04 22:18:22 +00:00
Antoine AflaloandGitHub e87e73eb28 Merge pull request #210 from Belphemur/copilot/optimize-file-processing
fix(watch): don't backfill existing archives at watch startup
2026-07-03 11:53:11 -04:00
d7782fdb25 refactor(watch): extract testable maybeBackfillExistingArchives helper
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 15:45:35 +00:00
1d981be21b feat(watch): add opt-in --backfill flag for existing archives
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 15:44:08 +00:00
0b3c3fa90c fix(watch): don't backfill existing archives at watch startup
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 15:38:44 +00:00
Antoine AflaloandGitHub 52c93acd35 Merge pull request #209 from Belphemur/copilot/fix-docker-permission-denied-issue
fix(docker): create and chown /config home directory for non-root user
2026-07-03 11:17:27 -04:00
ab1e2ff05c fix(docker): create and chown /config home directory for non-root user
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 15:15:49 +00:00
Antoine AflaloandGitHub eca9671881 Merge pull request #208 from Belphemur/copilot/move-to-alpine-latest-docker-image
chore(docker): switch base image to alpine:latest
2026-07-03 10:57:52 -04:00
40922078d5 chore(docker): switch base image to alpine:latest
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 14:53:55 +00:00
Antoine AflaloandGitHub efb4dce1b7 Merge pull request #207 from Belphemur/copilot/fix-comments-in-pr-204
fix(watch): debounce fsnotify events, backfill archives, resilient recursive watch
2026-07-03 10:35:38 -04:00
30d0d632bf fix(watch): address review feedback in watcher queue and tests
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 14:26:40 +00:00
1e692ed19b fix(watch): remove mutable package var, fix debouncer race
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 13:55:58 +00:00
69c1a37577 fix(watch): debounce fsnotify events, backfill archives, resilient walk
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 13:52:17 +00:00
Antoine AflaloandGitHub e1e8ea92c1 chore: update go version 2026-07-03 13:22:11 +00:00
CopilotandGitHub c2f809ed2c fix(memory): use staging folder to avoid keeping everything in memory
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

Fixes #203
2026-07-03 09:15:56 -04:00
Antoine AflaloandGitHub f2a65425cf chore: remove qodana 2026-07-03 13:14:00 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
20de3ed0ae fix(deps): update module github.com/fsnotify/fsnotify to v1.10.1 (#205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 11:53:02 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Belphemur
b9170b14a1 feat: replace inotify watch path with fsnotify and clean up related tooling/docs (#204)
* Plan fsnotify migration and docs updates

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

* Refactor watch mode to fsnotify and add agent docs

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

* Fix errcheck violations in command and converter tests

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

* Complete validation for fsnotify and lint fixes

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2026-07-03 07:47:03 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
18ff04c81f chore(deps): update jetbrains/qodana-action action to v2026 (#187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 07:35:23 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2663f17be3 chore(deps): update codecov/codecov-action action to v7 (#198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 07:35:12 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
dc9d33bbca chore(deps): update actions/checkout action to v7 (#202)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 07:34:58 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cc7da1ef3b fix(deps): update module golang.org/x/image to v0.43.0 (#201)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-18 21:48:48 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8c538ce95b fix(deps): update golang.org/x/exp digest to c48552f (#200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 01:55:28 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c8524e9b5f fix(deps): update module golang.org/x/image to v0.42.0 (#199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 17:02:47 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cbad6e99ac fix(deps): update module github.com/thediveo/enumflag/v2 to v2.2.1 (#197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 15:08:39 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
65b9961856 fix(deps): update golang.org/x/exp digest to 055de63 (#196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 23:05:57 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
d044573019 fix(deps): update module github.com/pablodz/inotifywaitgo to v0.0.12 (#195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 18:42:52 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
964a6ea295 fix(deps): update module github.com/pablodz/inotifywaitgo to v0.0.11 (#194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 20:02:53 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
02b15c8124 fix(deps): update golang.org/x/exp digest to c761662 (#193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 15:04:10 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
deeb30aa24 fix(deps): update golang.org/x/exp digest to 50dc527 (#192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-28 21:35:50 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ad24ab68ac fix(deps): update golang.org/x/exp digest to 08cc537 (#191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 06:19:53 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
d78b523ae7 fix(deps): update module golang.org/x/image to v0.41.0 (#190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 22:44:17 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7b1fb13a21 fix(deps): update module golang.org/x/image to v0.40.0 (#189)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 17:20:37 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
414a43476b fix(deps): update golang.org/x/exp digest to 74f9aab (#188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 15:59:04 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
dbac6c20ff chore(deps): update jetbrains/qodana-action action to v2024.3.4 (#185)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 19:41:03 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ef1a658028 fix(deps): update module github.com/rs/zerolog to v1.35.1 (#184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 00:41:29 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
76aea38ff2 fix(deps): update golang.org/x/exp digest to 746e56f (#183)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 12:47:57 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a69eb9d8a5 fix(deps): update module golang.org/x/image to v0.39.0 (#182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 19:04:44 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a6875c3a12 chore(deps): update codecov/codecov-action action to v6 (#180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 06:57:32 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b3686e17d8 fix(deps): update module github.com/rs/zerolog to v1.35.0 (#181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 00:34:43 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6ea989eedd fix(deps): update module github.com/thediveo/enumflag/v2 to v2.2.0 (#179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 21:07:58 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f104add37a fix(deps): update module golang.org/x/image to v0.38.0 (#178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-24 00:52:00 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
39632906da chore(deps): update anchore/sbom-action action to v0.24.0 (#177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 21:47:22 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1be0978e89 fix(deps): update golang.org/x/exp digest to 7ab1446 (#176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 17:52:26 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
71bdf7d111 fix(deps): update module golang.org/x/image to v0.37.0 (#175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 00:57:41 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3123fa27f9 chore(deps): update anchore/sbom-action action to v0.23.1 (#174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 22:45:16 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7cd711ab5a chore(deps): update docker/setup-qemu-action action to v4 (#171)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 18:08:17 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
df3206b80c chore(deps): update docker/login-action action to v4 (#172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 18:08:06 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
878da22fdb chore(deps): update docker/setup-buildx-action action to v4 (#173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 18:07:54 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
fcf6bf514b fix(deps): update module github.com/samber/lo to v1.53.0 (#170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 16:42:49 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
70a3306ca9 chore(deps): update actions/upload-artifact action to v7 (#169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 19:51:10 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0ff3eab0b9 chore(deps): update actions/attest-build-provenance action to v4 (#168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 22:15:51 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
45851713c1 chore(deps): update anchore/sbom-action action to v0.23.0 (#167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 20:47:24 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
cca06642f8 chore(deps): update goreleaser/goreleaser-action action to v7 (#166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 12:35:12 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a0a79c6439 fix(deps): update golang.org/x/exp digest to 3dfff04 (#165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 22:53:19 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6c45a8507f fix(deps): update golang.org/x/exp digest to 81e46e3 (#164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 20:03:41 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
d66d445bbb fix(deps): update golang.org/x/exp digest to 2735e65 (#163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 21:33:26 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
aabb0e2a02 fix(deps): update golang.org/x/exp digest to 2842357 (#162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 21:32:38 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8349569e33 fix(deps): update module golang.org/x/image to v0.36.0 (#161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 18:18:25 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b434a18802 chore(deps): update anchore/sbom-action action to v0.22.2 (#160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 18:43:13 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4057cfb6c9 chore(deps): update anchore/sbom-action action to v0.22.1 (#159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 19:38:33 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
617ee8a0c3 chore(deps): update anchore/sbom-action action to v0.22.0 (#158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-21 21:04:48 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f37ca7e4e7 fix(deps): update golang.org/x/exp digest to 716be56 (#157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 21:33:22 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
38c8f2a9cb fix(deps): update module golang.org/x/image to v0.35.0 (#156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-12 18:35:03 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
03479c8772 chore(deps): update anchore/sbom-action action to v0.21.1 (#155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 17:14:58 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ee47432721 fix(deps): update module github.com/thediveo/enumflag/v2 to v2.1.0 (#154)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-03 17:42:02 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5a0fe68e68 chore(deps): update anchore/sbom-action action to v0.21.0 (#153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-23 17:47:47 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Belphemur
e535809e74 fix: Fix deadlock in ConvertChapter when context cancelled during page processing (#152)
* Initial plan

* Fix deadlock in ConvertChapter when context is cancelled after wgConvertedPages.Add

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

* Fix test comments to remove placeholder issue references

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-20 14:26:33 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
af5bfe8000 fix(deps): update golang.org/x/exp digest to 944ab1f (#151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-19 21:52:44 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9ac9901990 chore(deps): update actions/upload-artifact action to v6 (#149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-14 14:31:21 -05:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Belphemur
8c52010dfe Remove cosign signing in favor of GitHub native attestation (#148)
* Initial plan

* Remove cosign signing and improve GoReleaser configuration

- Removed cosign-installer step from release workflow
- Removed cosign signing sections (signs and docker_signs) from .goreleaser.yml
- Added include_meta: true to release configuration
- Added use: github and format to changelog configuration
- Added before hooks section for go mod tidy and go generate
- Improved comments and structure following best practices
- Added proper step names to workflow for better readability
- Kept attestation steps for checksums.txt and digests.txt using GitHub's native attestation

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 16:39:25 -05:00
Antoine AflaloandGitHub aefadafc7d Merge pull request #146 from Belphemur/copilot/update-readme-docker-compose
docs: add Docker Compose examples and update copilot workflow
2025-12-10 16:19:50 -05:00
copilot-swe-agent[bot] ba1ab20697 fix: improve format flag flexibility and usability
The format flag now supports multiple input syntaxes for better user experience:
- Space-separated: --format webp or -f webp
- Equals syntax: --format=webp
- Case-insensitive: webp, WEBP, and WebP are all valid

This change centralizes format flag setup in setupFormatFlag() function,
making it consistent across optimize and watch commands while supporting
both command-line usage and viper configuration binding.

The improvements enhance CLI usability without breaking existing usage patterns.
2025-12-10 21:16:16 +00:00
copilot-swe-agent[bot] 43593c37fc ci: remove application build from copilot-setup-steps workflow
Only build and run encoder-setup utility for WebP configuration.
Application building is not required for Copilot development environment setup.
2025-12-10 21:16:16 +00:00
copilot-swe-agent[bot]andBelphemur 44a4726258 ci: rename copilot-setup workflow to copilot-setup-steps and follow standard pattern
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 21:14:57 +00:00
copilot-swe-agent[bot]andBelphemur e71a3d7693 docs: add Docker Compose examples and usage instructions
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 21:11:33 +00:00
copilot-swe-agent[bot] 992e37f9af Initial plan 2025-12-10 21:08:27 +00:00
Antoine AflaloandGitHub a2f6805d47 Merge pull request #145 from Belphemur/copilot/add-copilot-instructions
Add GitHub Copilot instructions and setup workflow
2025-12-10 16:06:00 -05:00
copilot-swe-agent[bot]andBelphemur 552364f69c Fix: Use correct command name 'cbzconverter' and make workflow manual-only
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:59:54 +00:00
Antoine AflaloandGitHub da65eeecba Merge branch 'main' into copilot/add-copilot-instructions 2025-12-10 15:49:41 -05:00
copilot-swe-agent[bot]andBelphemur 5d35a2e3fa Fix: Add explicit permissions block to copilot-setup workflow for security
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:49:17 +00:00
Antoine AflaloGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1568334c03 Potential fix for code scanning alert no. 5: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-10 15:48:56 -05:00
Antoine AflaloandGitHub 31ef12bb17 Merge pull request #144 from Belphemur/copilot/fix-format-flag-crash
Fix format flag crash with space-separated values
2025-12-10 15:47:25 -05:00
copilot-swe-agent[bot]andBelphemur 9529004554 Add GitHub Copilot instructions and setup workflow
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:45:23 +00:00
copilot-swe-agent[bot] 6a2efc42ac Initial plan 2025-12-10 20:39:42 +00:00
copilot-swe-agent[bot]andBelphemur 44e2469e34 Consolidate all common flags into flags.go
- Create individual setup functions for each common flag (quality, override, split, timeout)
- Create setupCommonFlags function that sets up all common flags in one call
- Simplify optimize_command.go and watch_command.go by using setupCommonFlags
- All flags now centralized in flags.go for better maintainability
- All tests continue to pass

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:38:58 +00:00
copilot-swe-agent[bot]andBelphemur 9b6a733012 Complete format flag fix with all requirements met
All changes completed and verified

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:29:34 +00:00
copilot-swe-agent[bot]andBelphemur b80535d211 Fix formatting in setupTestCommand function
- Remove extra blank line after t.Helper() for Go formatting consistency

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:28:36 +00:00
copilot-swe-agent[bot]andBelphemur 3a2fb2a97e Add comprehensive documentation to shared functions
- Add detailed docstring to setupFormatFlag explaining parameters and usage
- Add detailed docstring to setupTestCommand explaining return values
- Use constant.DefaultConversion instead of hard-coded constant.WebP for better maintainability
- Clarify when bindViper should be true vs false

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:27:28 +00:00
copilot-swe-agent[bot]andBelphemur c5de49a310 Refactor tests to use shared setupFormatFlag and helper function
- Create setupTestCommand helper function to reduce test duplication
- Update all format flag tests to use shared setupFormatFlag function
- Remove unused enumflag import from test file
- Ensures test consistency with production code
- All tests continue to pass

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:25:59 +00:00
copilot-swe-agent[bot]andBelphemur cd0f056648 Eliminate code duplication with shared setupFormatFlag function
- Extract format flag setup logic into shared flags.go file
- Create setupFormatFlag function to eliminate duplication between optimize and watch commands
- Add bindViper parameter to support different flag binding strategies
- Remove duplicate enumflag imports from command files
- All tests continue to pass

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:22:58 +00:00
copilot-swe-agent[bot]andBelphemur a2feca6cca Fix format flag crash and add comprehensive tests
- Remove NoOptDefVal which caused the format flag to fail with space-separated values
- Add 5 comprehensive unit tests for format flag: space syntax, short form, equals syntax, default value, and case-insensitive
- Update README with detailed format flag documentation and examples
- Format flag now works with all syntaxes: --format webp, -f webp, --format=webp
- Default value (webp) is preserved and shown in help text

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
2025-12-10 20:19:10 +00:00
copilot-swe-agent[bot] 1fa54e1936 Initial plan 2025-12-10 20:07:44 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ce8aaba165 chore(deps): update anchore/sbom-action action to v0.20.11 (#142)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 03:49:38 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
647b139ea0 fix(deps): update golang.org/x/exp digest to 8475f28 (#141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 19:54:13 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
16b3ce3c9b fix(deps): update module golang.org/x/image to v0.34.0 (#140)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 23:57:17 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8d359aa575 fix(deps): update module github.com/spf13/cobra to v1.10.2 (#139)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 02:18:16 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
97f89a51c6 fix(deps): update golang.org/x/exp digest to 87e1e73 (#137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 21:51:06 +00:00
Antoine AflaloandGitHub 6840de3a89 Merge pull request #136 from Belphemur/renovate/actions-checkout-6.x 2025-11-23 20:46:44 -05:00
renovate[bot]andGitHub 117b55eeaf chore(deps): update actions/checkout action to v6 2025-11-20 16:41:02 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
287ae8df8b chore(deps): update anchore/sbom-action action to v0.20.10 (#135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-17 23:47:37 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
481da7c769 fix(deps): update golang.org/x/exp digest to e25ba8c (#134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 02:37:56 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e269537049 fix(deps): update module golang.org/x/image to v0.33.0 (#133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-11 23:51:57 +00:00
Antoine AflaloandGitHub cc4829cb39 Merge pull request #132 from Belphemur/renovate/major-github-artifact-actions 2025-10-24 15:36:44 -04:00
renovate[bot]andGitHub 65747d35c0 chore(deps): update actions/upload-artifact action to v5 2025-10-24 19:35:39 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
eb8803302c fix(deps): update golang.org/x/exp digest to a4bb9ff (#131)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 20:41:51 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e60e30f5a0 chore(deps): update anchore/sbom-action action to v0.20.9 (#130)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 14:38:00 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7f5f690e66 fix(deps): update golang.org/x/exp digest to 90e834f (#129)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 04:16:25 +00:00
Antoine AflaloandGitHub f752586432 Merge pull request #128 from Belphemur/renovate/sigstore-cosign-installer-4.x 2025-10-17 09:27:18 -04:00
renovate[bot]andGitHub 9a72d64a38 chore(deps): update sigstore/cosign-installer action to v4 2025-10-17 01:13:10 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
09655e225c chore(deps): update sigstore/cosign-installer action to v3.10.1 (#127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-16 22:52:31 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
90d75361a7 chore(deps): update anchore/sbom-action action to v0.20.8 (#126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-16 16:00:36 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
503fad46a6 chore(deps): update anchore/sbom-action action to v0.20.7 (#125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 21:56:44 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e842b49535 fix(deps): update module github.com/mholt/archives to v0.1.5 (#124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 20:42:51 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
86d20e14b1 fix(deps): update golang.org/x/exp digest to d2f985d (#122)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-09 18:34:12 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7081f4aa1c fix(deps): update module golang.org/x/image to v0.32.0 (#121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 18:40:16 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6d8e1e2f5e fix(deps): update module github.com/samber/lo to v1.52.0 (#120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 15:59:33 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
77279cb0c5 fix(deps): update golang.org/x/exp digest to 27f1f14 (#119)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 19:07:05 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
82ab972c2e fix(deps): update module github.com/mholt/archives to v0.1.4 (#118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 01:45:17 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ae754ae5d8 chore(deps): update anchore/sbom-action action to v0.20.6 (#117)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 14:49:14 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
507d8df103 chore(deps): update sigstore/cosign-installer action to v3.10.0 (#115)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-13 23:45:35 +00:00
Antoine AflaloandGitHub 545382c887 Merge pull request #114 from Belphemur/renovate/golang.org-x-exp-digest
fix(deps): update golang.org/x/exp digest to df92998
2025-09-11 08:43:10 -04:00
renovate[bot]andGitHub 255b158778 fix(deps): update golang.org/x/exp digest to df92998 2025-09-11 10:32:41 +00:00
Antoine AflaloandGitHub 4f9dacdaf6 chore: remove requirements 2025-09-10 09:13:59 -04:00
Antoine Aflalo 3e62ab40e3 fix(docker): add proper annotation for multi-arch 2025-09-10 09:06:19 -04:00
Antoine Aflalo 51af843432 chore: typo fix 2025-09-10 09:02:42 -04:00
Antoine Aflalo 6b92336ba1 fix(docker): be sure we have the encoder installed in the right user folder 2025-09-10 09:00:35 -04:00
Antoine Aflalo a6ad1dada3 fix(release): fix not having archive in the release 2025-09-10 08:49:50 -04:00
Antoine Aflalo 17fe01f27c fix(docker): remove unnecessary volume declaration and clean up bashrc setup 2025-09-09 22:58:24 -04:00
Antoine Aflalo 4fa3014d80 fix(docker): when we set the volume
To be sure we have the cache downloaded
2025-09-09 22:55:27 -04:00
Antoine Aflalo a47af5a7a8 ci: fix cosign 2025-09-09 22:32:09 -04:00
Antoine Aflalo d7f13132f4 ci: fix test workflow 2025-09-09 22:23:22 -04:00
Antoine Aflalo a8587f3f1f fix(docker): have already the converter in the docker image 2025-09-09 22:21:50 -04:00
Antoine Aflalo 12817b1bff ci: full upgrade 2025-09-09 21:48:26 -04:00
Antoine Aflalo 19dcf9d40b fix(docker): missing ca-certificate 2025-09-09 21:47:47 -04:00
Antoine Aflalo a7fa5bd0c7 ci: no need for interactive for docker image 2025-09-09 21:44:30 -04:00
Antoine Aflalo 9bde56d6c1 ci: have buildx setup 2025-09-09 21:38:11 -04:00
Antoine Aflalo 9c28923c35 ci: add verbosity 2025-09-09 21:25:28 -04:00
Antoine Aflalo b878390b46 ci: goreleaser fixes 2025-09-09 21:17:27 -04:00
Antoine Aflalo 41ff843a80 ci(docker-sign): remove signing docker image 2025-09-09 20:53:49 -04:00
Antoine Aflalo 221945cb66 fix(docker): fix docker image 2025-09-09 20:47:46 -04:00
Antoine Aflalo 35bba7c088 ci: improve goreleaser config for docker image 2025-09-09 20:46:08 -04:00
Antoine Aflalo b5a894deba fix(docker): fix issue with docker image not downloading the right webp converter 2025-09-09 20:36:42 -04:00
Antoine Aflalo 7ad0256b46 chore: cleanup deps 2025-09-09 20:15:35 -04:00
Antoine AflaloandGitHub f08e8dad7b fix: releasing app 2025-09-09 19:36:05 -04:00
Antoine Aflalo 54de9bcdeb perf: use default for unlock the mutex 2025-09-09 16:59:11 -04:00
Antoine Aflalo 0a7cc506fd fix: possible race condition 2025-09-09 16:58:42 -04:00
Antoine Aflalo fe8c5606fc chore: update deps 2025-09-09 16:49:24 -04:00
Antoine Aflalo 9a8a9693fb fix(webp): fix installing newer version of webp 2025-09-09 11:37:13 -04:00
Antoine Aflalo 7047710fdd test: no need for timeout for integration test 2025-09-09 10:13:56 -04:00
Antoine Aflalo 88786d4e53 fix(webp): fix using the right version 2025-09-09 10:12:38 -04:00
Antoine Aflalo e0c8bf340b ci: add test preparation 2025-09-09 09:58:50 -04:00
Antoine Aflalo 36b9ddc80f fix(webp): fix issue with concurrent preparation of encoder. 2025-09-09 09:31:31 -04:00
Antoine Aflalo a380de3fe5 ci: improve testing workflow 2025-09-09 09:19:07 -04:00
Antoine Aflalo e47e21386f tests: update testing suite with more use cases
Actually try to convert existing chapters.
2025-09-09 09:14:40 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1b1be3a83a fix(deps): update module github.com/spf13/viper to v1.21.0 (#112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 21:54:12 +00:00
Antoine Aflalo 44a919e4f3 test: add test with webp 2025-09-08 17:21:41 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1b9d83d2ff fix(deps): update module golang.org/x/image to v0.31.0 (#111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 16:27:05 +00:00
Antoine AflaloandGitHub ddc5121216 Merge pull request #110 from Belphemur/renovate/actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6
2025-09-04 08:12:50 -04:00
renovate[bot]andGitHub a361f22951 chore(deps): update actions/setup-go action to v6 2025-09-04 05:07:02 +00:00
Antoine Aflalo d245b80c65 fix: naming issue 2025-09-03 22:21:37 -04:00
Antoine AflaloandGitHub 011f7a7a7f Merge pull request #109 from Belphemur/feat/gif
Feat/gif
2025-09-03 22:19:07 -04:00
Antoine Aflalo f159d3d0d0 fix: keep the error 2025-09-03 22:17:41 -04:00
Antoine Aflalo ede8d62572 fix: Keep page as they are if we can't decode them and disable conversion 2025-09-03 22:15:10 -04:00
Antoine Aflalo a151a1d4f8 tests(corruption): add test for corrupt pages 2025-09-03 21:38:21 -04:00
Antoine Aflalo 30ea3d4583 test: add test for page type 2025-09-03 21:34:39 -04:00
Antoine Aflalo 6205e3ea28 feat(gif): support gif file
See .gif file extension support and more exception handling
Fixes #105
2025-09-03 21:04:51 -04:00
Antoine AflaloandGitHub f6bdc3cd86 Merge pull request #106 from Belphemur/dependabot/go_modules/go_modules-004c5295e3 2025-09-03 11:01:04 -04:00
Antoine AflaloandGitHub 70257a0439 Merge pull request #107 from Belphemur/renovate/actions-attest-build-provenance-3.x
chore(deps): update actions/attest-build-provenance action to v3
2025-09-03 08:40:01 -04:00
dependabot[bot]andGitHub 41108514d9 chore(deps): bump github.com/ulikunitz/xz
Bumps the go_modules group with 1 update in the / directory: [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz).


Updates `github.com/ulikunitz/xz` from 0.5.12 to 0.5.14
- [Commits](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.14)

---
updated-dependencies:
- dependency-name: github.com/ulikunitz/xz
  dependency-version: 0.5.14
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 23:13:21 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7e2bb7cf90 fix(deps): update module github.com/spf13/cobra to v1.10.1 (#108)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-01 23:10:00 +00:00
renovate[bot]andGitHub 8ab75421b1 chore(deps): update actions/attest-build-provenance action to v3 2025-08-31 08:55:43 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4894b14b90 fix(deps): update module github.com/stretchr/testify to v1.11.1 (#104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 13:34:53 +00:00
Antoine AflaloandGitHub 9a29b6b45c fix: rollback dockerfile changes 2025-08-27 09:27:11 -04:00
Antoine Aflalo fcc4ac57ca fix(ci): rollback to docker config instead of docker_v2 2025-08-26 23:08:08 -04:00
Antoine Aflalo 4cc33db553 fix(goreleaser): fix ci 2025-08-26 23:03:47 -04:00
Antoine Aflalo d36c5cf0f1 fix: ci issue with goreleaser 2025-08-26 23:00:53 -04:00
Antoine Aflalo ed70eb81cd ci: update to new setup for docker images 2025-08-26 22:59:13 -04:00
Antoine Aflalo 419edbce7b fix: ci config for goreleaser 2025-08-26 22:50:14 -04:00
Antoine Aflalo 4524e94b17 ci: fix goreleaser 2025-08-26 22:47:36 -04:00
Antoine Aflalo c6823168af fix: add attestations 2025-08-26 22:45:35 -04:00
Antoine Aflalo 9bca0ceaf4 fix: add autocomplete defintion for log level 2025-08-26 22:39:23 -04:00
Antoine Aflalo c2a6220fde fix(logging): fix logging parameter not taken into account 2025-08-26 22:36:23 -04:00
Antoine Aflalo e26cf7a26a fix: test 2025-08-26 21:37:51 -04:00
Antoine Aflalo 4e5180f658 feat: add timeout option for chapter conversion to prevent hanging on problematic files
fixes #102
2025-08-26 21:34:52 -04:00
Antoine Aflalo e7bbae1c25 chore: bump webp 2025-08-26 21:20:56 -04:00
Antoine Aflalo 32c009ed9b feat: integrate zerolog for enhanced logging across multiple components 2025-08-26 21:16:54 -04:00
Antoine Aflalo 94fb60c5c6 feat: enhance logging capabilities with zerolog integration and command-line support 2025-08-26 21:07:48 -04:00
Antoine Aflalo dfee46812d feat: use Zerolog for logging. 2025-08-26 20:55:34 -04:00
Antoine AflaloandGitHub d0e4037e15 Merge pull request #101 from Belphemur/dependabot/go_modules/go_modules-e1b2e84e8b 2025-08-26 20:38:53 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8539abe99e fix(deps): update module github.com/stretchr/testify to v1.11.0 (#103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-24 17:46:08 +00:00
dependabot[bot]andGitHub f1151435e1 chore(deps): bump github.com/go-viper/mapstructure/v2
Bumps the go_modules group with 1 update in the / directory: [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure).


Updates `github.com/go-viper/mapstructure/v2` from 2.3.0 to 2.4.0
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.4.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-21 15:25:11 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c6e00fda5d fix(deps): update golang.org/x/exp digest to 8b4c13b (#100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 23:02:35 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2f37936a72 chore(deps): update anchore/sbom-action action to v0.20.5 (#99)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 18:05:44 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f0d5c254a6 fix(deps): update golang.org/x/exp digest to 42675ad (#98)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 16:28:31 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Antoine Aflalo
e35b7b3ae8 chore(deps): update dependency go to v1.25.0 (#97)
* chore(deps): update dependency go to v1.25.0

* chore: move ci/cd to 1.25

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Antoine Aflalo <197810+Belphemur@users.noreply.github.com>
2025-08-13 01:04:05 +00:00
Antoine AflaloandGitHub 43d9550e6e Merge pull request #95 from Belphemur/renovate/actions-checkout-5.x 2025-08-11 20:33:18 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e7fa06f4d3 fix(deps): update golang.org/x/exp digest to 51f8813 (#96)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 22:47:50 +00:00
renovate[bot]andGitHub 8b48da1b25 chore(deps): update actions/checkout action to v5 2025-08-11 16:24:11 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
fdcc9bf076 fix(deps): update golang.org/x/exp digest to a408d31 (#94)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-08 17:00:38 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
38b9d4f1bd fix(deps): update module golang.org/x/image to v0.30.0 (#93)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 21:44:51 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
fbc1ec7d75 chore(deps): update dependency go to v1.24.6 (#92)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 20:48:02 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e7b566ff63 chore(deps): update anchore/sbom-action action to v0.20.4 (#91)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 03:11:33 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
d73d0347b1 fix(deps): update golang.org/x/exp digest to 645b1fa (#90)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-18 20:32:01 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
04b9dbb2dd chore(deps): update sigstore/cosign-installer action to v3.9.2 (#89)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 22:49:46 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5d767470a8 fix(deps): update golang.org/x/exp digest to 542afb5 (#88)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 22:49:33 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
473c6f40e8 fix(deps): update golang.org/x/exp digest to 6ae5c78 (#87)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 23:47:39 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
403f43a417 fix(deps): update module golang.org/x/image to v0.29.0 (#86)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 03:11:22 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1bfe755dd9 chore(deps): update dependency go to v1.24.5 (#85)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 22:53:26 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3cd6a4ab1f chore(deps): update anchore/sbom-action action to v0.20.2 (#84)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 22:27:30 +00:00
Antoine AflaloandGitHub 117206e0ee Merge pull request #83 from Belphemur/dependabot/go_modules/go_modules-3464edad9a 2025-06-27 15:48:52 -04:00
dependabot[bot]andGitHub 1e43f9d8a0 chore(deps): bump github.com/go-viper/mapstructure/v2
Bumps the go_modules group with 1 update in the / directory: [github.com/go-viper/mapstructure/v2](https://github.com/go-viper/mapstructure).


Updates `github.com/go-viper/mapstructure/v2` from 2.2.1 to 2.3.0
- [Release notes](https://github.com/go-viper/mapstructure/releases)
- [Changelog](https://github.com/go-viper/mapstructure/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-viper/mapstructure/compare/v2.2.1...v2.3.0)

---
updated-dependencies:
- dependency-name: github.com/go-viper/mapstructure/v2
  dependency-version: 2.3.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-27 16:49:44 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6f8b525a96 fix(deps): update module github.com/mholt/archives to v0.1.3 (#82)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-26 22:52:14 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9480cc0e36 chore(deps): update sigstore/cosign-installer action to v3.9.1 (#81)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 17:36:00 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a72cd3f84f fix(deps): update golang.org/x/exp digest to b7579e2 (#80)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-20 07:16:04 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a3424494cc chore(deps): update sigstore/cosign-installer action to v3.9.0 (#79)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-17 13:39:58 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
85d0b8bbca chore(deps): update anchore/sbom-action action to v0.20.1 (#78)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-13 21:55:26 +00:00
Antoine AflaloandGitHub 29f7fbbc0d Merge pull request #77 from Belphemur/Belphemur/issue75 2025-06-12 09:26:34 -04:00
Antoine Aflalo 1258b06210 docs: update README to include CBR file support and clarify features 2025-06-12 09:24:02 -04:00
Antoine Aflalo 8a6ddc668e feat: enhance optimization logic for CBR/CBZ file handling and add tests 2025-06-12 09:23:00 -04:00
Antoine Aflalo 989ca2450d feat: support CBR files in optimize and watch commands
Fixes #75
2025-06-12 09:18:06 -04:00
Antoine Aflalo 970b9019df feat: load CBR files 2025-06-12 09:11:22 -04:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a5f88fe0e9 fix(deps): update module github.com/samber/lo to v1.51.0 (#76)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 08:58:04 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c46700d0e5 fix(deps): update module golang.org/x/image to v0.28.0 (#74)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 00:59:04 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3d98fe036b chore(deps): update dependency go to v1.24.4 (#73)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 19:09:31 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
00d7ec0ba9 fix(deps): update golang.org/x/exp digest to dcc06ee (#72)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 19:08:57 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8c09db9a9e fix(deps): update golang.org/x/exp digest to b6e5de4 (#71)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-31 02:07:12 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0390f1119f fix(deps): update golang.org/x/exp digest to 65e9200 (#70)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-30 18:45:36 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b62485de3b chore(deps): update anchore/sbom-action action to v0.20.0 (#69)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-14 20:21:27 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8e11eca719 chore(deps): update dependency go to v1.24.3 (#68)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 19:38:28 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
841bdce097 fix(deps): update golang.org/x/exp digest to ce4c2cf (#67)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 03:55:49 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
74c0954118 fix(deps): update module golang.org/x/image to v0.27.0 (#66)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 22:49:16 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7478f0b71c fix(deps): update module github.com/samber/lo to v1.50.0 (#65)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-26 18:28:12 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a03eba5400 chore(deps): update anchore/sbom-action action to v0.19.0 (#64)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-24 23:25:23 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
7546e516cd chore(deps): update sigstore/cosign-installer action to v3.8.2 (#63)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-22 21:53:05 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
bef7052163 fix(deps): update golang.org/x/exp digest to 7e4ce0a (#62)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 18:03:02 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e04b213fa4 fix(deps): update module golang.org/x/image to v0.26.0 (#61)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-06 17:53:06 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
92fa3a54e7 chore(deps): update dependency go to v1.24.2 (#60)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 17:06:22 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
bc92d36df2 fix(deps): update module github.com/spf13/viper to v1.20.1 (#59)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-26 19:06:44 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9863dd5d98 fix(deps): update module github.com/spf13/viper to v1.20.0 (#58)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-15 17:12:41 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ddd19292d5 fix(deps): update golang.org/x/exp digest to 054e65f (#57)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-06 02:41:33 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6a7914bd83 fix(deps): update module golang.org/x/image to v0.25.0 (#56)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-05 19:37:10 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
005d2d35c3 chore(deps): update dependency go to v1.24.1 (#55)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 22:46:43 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
abcce332e5 fix(deps): update golang.org/x/exp digest to dead583 (#54)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 22:01:17 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
376656ba2c chore(deps): update sigstore/cosign-installer action to v3.8.1 (#53)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-20 15:55:10 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
34288e6bbe fix(deps): update golang.org/x/exp digest to aa4b98e (#52)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 18:49:14 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
d32ea3e8a9 fix(deps): update module github.com/spf13/cobra to v1.9.1 (#51)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 02:47:58 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
23256013f5 fix(deps): update golang.org/x/exp digest to eff6e97 (#50)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-15 21:41:43 +00:00
Antoine Aflalo c87fde31c4 chore: Removes unused import
Removes the `image/jpeg` import, as it is already imported via blank identifier.
2025-02-14 10:55:39 -05:00
Antoine Aflalo 23eb43c691 fix(chapter): fix chapter conversion.
Still need to figure out the memory issues

Consolidates image conversion logic into a dedicated method.

This change streamlines the conversion process by centralizing the
setting of converted image data, extension, and size. It also
introduces a flag to track whether an image has been converted.

The old resource cleanup has been removed since it is not needed anymore.
2025-02-14 10:03:35 -05:00
Antoine Aflalo 4d3391273c ci: Sets up QEMU for cross-platform builds
Configures QEMU to enable emulation of different architectures,
allowing for cross-platform builds and testing in the release workflow.
2025-02-13 20:18:14 -05:00
Antoine Aflalo 2da3bae04a Updates build configuration for multi-platform support
Configures the build process to support multiple platforms (Linux, Darwin, Windows) and architectures (amd64, arm64).

Disables CGO to simplify cross-compilation.

Updates Docker image creation to produce separate images for amd64 and arm64, and creates manifest lists for `latest` and versioned tags.
2025-02-13 20:12:49 -05:00
Antoine Aflalo a3dfec642c test: add webp converter test 2025-02-13 20:05:08 -05:00
Antoine Aflalo 0303c80feb test: fix path 2025-02-13 20:04:33 -05:00
Antoine Aflalo efe1696bfa fix(memory): fix possible memory leak and add better tests 2025-02-13 20:02:45 -05:00
Antoine Aflalo 25cd4585b7 feat: revert to use webp executable 2025-02-13 19:47:13 -05:00
Antoine Aflalo dd7b6a332c refactor: update import paths to use internal package 2025-02-13 19:43:18 -05:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5428134d15 chore(deps): update dependency go to v1.24.0 (#49)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 20:04:36 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8d59530234 fix(deps): update golang.org/x/exp digest to 939b2ce (#48)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-10 20:23:31 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
af30f34aa6 fix(deps): update golang.org/x/exp digest to f9890c6 (#47)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-07 05:00:13 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b3c412c09d chore(deps): update sigstore/cosign-installer action to v3.8.0 (#46)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-05 00:35:33 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
16ba484f28 fix(deps): update module golang.org/x/image to v0.24.0 (#45)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-04 20:39:34 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2de8a81137 chore(deps): update dependency go to v1.23.6 (#44)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-04 17:36:25 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c223c9dca6 fix(deps): update golang.org/x/exp digest to e0ece0d (#43)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-28 21:04:20 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
dcf57c7646 fix(deps): update golang.org/x/exp digest to 3edf0e9 (#42)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-28 17:46:34 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
77e7724de2 fix(deps): update module github.com/samber/lo to v1.49.1 (#41)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-28 13:22:17 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ea8fd55cc2 fix(deps): update module github.com/samber/lo to v1.49.0 (#40)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-27 04:30:54 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
709c53d647 fix(deps): update module github.com/pablodz/inotifywaitgo to v0.0.9 (#39)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-26 20:57:06 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
919a53fec7 fix(deps): update module github.com/samber/lo to v1.48.0 (#38)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-26 10:22:50 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
d3b3a73b8f chore(deps): update anchore/sbom-action action to v0.18.0 (#37)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-23 20:47:16 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
188211e26d fix(deps): update golang.org/x/exp digest to 7588d65 (#36)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-06 21:12:16 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f57a88eaf4 fix(deps): update module github.com/thediveo/enumflag/v2 to v2.0.7 (#35)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-05 19:29:38 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6e6b66b5eb fix(deps): update golang.org/x/exp digest to 7d7fa50 (#34)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-03 21:05:57 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1ff1bed3cc fix(deps): update golang.org/x/exp digest to dd03c70 (#33)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-03 18:34:17 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
196938718c fix(deps): update golang.org/x/exp digest to b2144cd (#32)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-17 20:15:57 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9972709d32 fix(deps): update golang.org/x/exp digest to 4a55095 (#31)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 18:36:01 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
152fa85577 chore(deps): update anchore/sbom-action action to v0.17.9 (#30)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-13 23:01:28 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
554fce5d1e fix(deps): update golang.org/x/exp digest to 1829a12 (#29)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-10 23:09:05 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
25357e9ec6 fix(deps): update golang.org/x/exp digest to 1443442 (#28)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-10 17:58:23 +00:00
66 changed files with 7001 additions and 1827 deletions
+319
View File
@@ -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
+69
View File
@@ -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"
-23
View File
@@ -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@v4
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 }}
+28 -12
View File
@@ -8,36 +8,52 @@ name: release
on: on:
push: push:
tags: tags:
- 'v*' - "v*"
permissions: permissions:
contents: write # needed to write releases contents: write # needed to write releases
id-token: write # needed for keyless signing id-token: write # needed for keyless signing
packages: write # needed for ghcr access packages: write # needed for ghcr access
attestations: write # needed for attestations
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 # this is important, otherwise it won't checkout the full tree (i.e. no previous tags) fetch-depth: 0 # this is important, otherwise it won't checkout the full tree (i.e. no previous tags)
- name: Install libwebp-dev - name: Set up Go
run: sudo apt-get install -y libwebp-dev uses: actions/setup-go@v7
- uses: actions/setup-go@v5
with: with:
go-version: 1.23 go-version-file: go.mod
cache: true cache: true
- uses: sigstore/cosign-installer@v3.7.0 # installs cosign - name: Install Syft
- uses: anchore/sbom-action/download-syft@v0.17.8 # installs syft uses: anchore/sbom-action/download-syft@v0.24.0 # installs syft
- uses: docker/login-action@v3 # login to ghcr - name: Set up QEMU
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: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: goreleaser/goreleaser-action@v6 # run goreleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7 # run goreleaser
with: with:
version: latest version: nightly
args: release --clean args: release --clean --verbose
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# After GoReleaser runs, attest all the files in ./dist/checksums.txt:
- 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:
- name: Attest Build Provenance for Docker Images
uses: actions/attest-build-provenance@v4
with:
subject-checksums: ./dist/digests.txt
+22 -28
View File
@@ -6,52 +6,46 @@ on:
jobs: jobs:
test: test:
name: Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version: '1.23' go-version-file: go.mod
cache: true
- name: Install gotestsum
run: go install gotest.tools/gotestsum@latest
- name: Install libwebp-dev - name: Setup test environment
run: sudo apt-get install -y libwebp-dev
- name: Install dependencies
run: go mod tidy
- name: Install Junit reporter
run: | run: |
wget https://github.com/jstemmer/go-junit-report/releases/download/v2.1.0/go-junit-report-v2.1.0-linux-amd64.tar.gz && \ go build -tags encoder_setup -o encoder-setup ./cmd/encoder-setup
tar -xzf go-junit-report-v2.1.0-linux-amd64.tar.gz && \ ./encoder-setup
chmod +x go-junit-report && \
mv go-junit-report /usr/local/bin/
- name: Run tests - name: Run tests
run: | run: |
set -o pipefail mkdir -p test-results
go test -v 2>&1 ./... -coverprofile=coverage.txt | tee test-results.txt gotestsum --junitfile test-results/junit.xml --format testname -- -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Analyse test results
if: ${{ !cancelled() }}
run: go-junit-report < test-results.txt > junit.xml
- name: Upload test result artifact - name: Upload test result artifact
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: test-results name: test-results
path: | path: |
test-results.txt test-results/junit.xml
junit.xml test-results/coverage.txt
retention-days: 7 retention-days: 7
- name: Upload results to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1 uses: codecov/test-results-action@v1
with: 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 }}
+5
View File
@@ -11,6 +11,10 @@
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
# Build artifacts
/cbzoptimizer
/encoder-setup
test/ test/
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
@@ -103,3 +107,4 @@ fabric.properties
# Android studio 3.1+ serialized cache file # Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser .idea/caches/build_file_checksums.ser
*__debug_bin*
+113 -57
View File
@@ -1,17 +1,40 @@
# .goreleaser.yml # .goreleaser.yml
version: 2 version: 2
project_name: CBZOptimizer project_name: CBZOptimizer
# Configures the release process on GitHub
# https://goreleaser.com/customization/release/
release: release:
github: github:
owner: belphemur owner: belphemur
name: CBZOptimizer 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: changelog:
use: github
format: "{{.SHA}}: {{.Message}} (@{{.AuthorUsername}})"
sort: asc sort: asc
filters: filters:
exclude: exclude:
- '^docs:' - "^docs:"
- '^test:' - "^test:"
- '^chore:' - "^chore:"
groups: groups:
- title: Features - title: Features
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
@@ -22,13 +45,29 @@ changelog:
- title: "Performance" - title: "Performance"
regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$'
order: 2 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: builds:
- id: cbzoptimizer - id: cbzoptimizer
main: main.go main: cmd/cbzoptimizer/main.go
goos: goos:
- linux - linux
- darwin
- windows
goarch: goarch:
- amd64 - amd64
- arm64
ignore:
- goos: windows
goarch: arm64
# ensures mod timestamp to be the commit timestamp # ensures mod timestamp to be the commit timestamp
mod_timestamp: "{{ .CommitTimestamp }}" mod_timestamp: "{{ .CommitTimestamp }}"
flags: flags:
@@ -37,64 +76,81 @@ builds:
ldflags: ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{ .CommitDate }} - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{ .CommitDate }}
env: env:
- CGO_ENABLED=1 - CGO_ENABLED=0
# config the checksum filename - id: encoder-setup
# https://goreleaser.com/customization/checksum 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: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
# create a source tarball
# Change the digests filename for attestation
# https://goreleaser.com/customization/docker_digest/
docker_digest:
name_template: "digests.txt"
# Creates a source code archive (tar.gz and zip)
# https://goreleaser.com/customization/source/ # https://goreleaser.com/customization/source/
source: source:
enabled: true 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: gomod:
proxy: true 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: sboms:
- artifacts: archive - id: archive # Default ID for archive SBOMs
- id: source # Two different sbom configurations need two different IDs artifacts: archive # Generate SBOMs for binary archives using Syft
artifacts: source - id: source # Unique ID for source SBOM
# create a docker image artifacts: source # Generate SBOM for the source code archive
# https://goreleaser.com/customization/docker
dockers: # Creates Docker images and pushes them to registries using Docker v2 API
- image_templates: # https://goreleaser.com/customization/docker/
- "ghcr.io/belphemur/cbzoptimizer:latest" dockers_v2:
- "ghcr.io/belphemur/cbzoptimizer:{{ .Version }}" - id: cbzoptimizer-image
dockerfile: Dockerfile ids:
build_flag_templates: - cbzoptimizer
- "--pull" - encoder-setup
- "--label=org.opencontainers.image.created={{.Date}}" platforms:
- "--label=org.opencontainers.image.name={{.ProjectName}}" - linux/amd64
- "--label=org.opencontainers.image.revision={{.FullCommit}}" - linux/arm64
- "--label=org.opencontainers.image.version={{.Version}}" images:
- "--label=org.opencontainers.image.source={{.GitURL}}" - "ghcr.io/belphemur/cbzoptimizer"
# signs the checksum file tags:
# 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 - "{{ .Version }}"
# https://goreleaser.com/customization/sign - latest
signs: annotations:
- cmd: cosign "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."
env: "org.opencontainers.image.created": "{{.Date}}"
- COSIGN_EXPERIMENTAL=1 "org.opencontainers.image.name": "{{.ProjectName}}"
certificate: "${artifact}.pem" "org.opencontainers.image.revision": "{{.FullCommit}}"
args: "org.opencontainers.image.version": "{{.Version}}"
- sign-blob "org.opencontainers.image.source": "{{.GitURL}}"
- "--output-certificate=${certificate}" labels:
- "--output-signature=${signature}" "org.opencontainers.image.created": "{{.Date}}"
- "${artifact}" "org.opencontainers.image.name": "{{.ProjectName}}"
- "--yes" # needed on cosign 2.0.0+ "org.opencontainers.image.revision": "{{.FullCommit}}"
artifacts: checksum "org.opencontainers.image.version": "{{.Version}}"
output: true "org.opencontainers.image.source": "{{.GitURL}}"
# signs our docker image "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."
# 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+
+10
View File
@@ -0,0 +1,10 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="GoDfaErrorMayBeNotNil" enabled="true" level="WARNING" enabled_by_default="true">
<methods>
<method importPath="github.com/belphemur/CBZOptimizer/converter" receiver="Converter" name="ConvertChapter" />
</methods>
</inspection_tool>
</profile>
</component>
+24
View File
@@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// 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",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/cbzoptimizer",
"args": ["optimize", "${workspaceFolder}/testdata", "-l", "debug"],
"cwd": "${workspaceFolder}"
}
]
}
+23
View File
@@ -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/`
+28 -18
View File
@@ -1,30 +1,40 @@
FROM debian:sid-slim FROM alpine:latest
LABEL authors="Belphemur" LABEL authors="Belphemur"
ARG TARGETPLATFORM
ARG APP_PATH=/usr/local/bin/CBZOptimizer ARG APP_PATH=/usr/local/bin/CBZOptimizer
ENV USER=abc ENV USER=abc
ENV CONFIG_FOLDER=/config ENV CONFIG_FOLDER=/config
ENV PUID=99 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}" && \ RUN adduser \
useradd \ -S \
--system \ -D \
--no-create-home \ -h "${CONFIG_FOLDER}" \
--home-dir "${CONFIG_FOLDER}" \ -u "${PUID}" \
--gid "users" \ -G users \
--uid "${PUID}" \ -s /bin/bash \
"${USER}" && \ "${USER}"
chown ${PUID}:users "${CONFIG_FOLDER}"
COPY CBZOptimizer ${APP_PATH} COPY ${TARGETPLATFORM}/CBZOptimizer ${APP_PATH}
RUN apt-get update && \ RUN apk add --no-cache \
apt-get full-upgrade -y && \ bash \
apt-get install -y inotify-tools bash-completion libwebp7 && \ ca-certificates \
apt-get clean && \ bash-completion \
rm -rf /var/lib/apt/lists/* && \ libwebp-tools && \
chmod +x ${APP_PATH} && \ 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} USER ${USER}
# 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"] ENTRYPOINT ["/usr/local/bin/CBZOptimizer"]
+220 -37
View File
@@ -1,84 +1,267 @@
# CBZOptimizer # CBZOptimizer
CBZOptimizer is a Go-based tool designed to optimize CBZ (Comic Book Zip) 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. 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.
**Note**: CBR files are supported as input but are always converted to CBZ format for output.
## Features ## Features
- Convert images within CBZ files to different formats (e.g., WebP). - Convert images within CBZ and CBR files to different formats (e.g., WebP).
- Support for multiple archive formats including CBZ and CBR (CBR files are converted to CBZ format).
- Adjust the quality of the converted images. - Adjust the quality of the converted images.
- Process multiple chapters in parallel. - Process multiple chapters in parallel.
- Option to override the original CBZ files. - Option to override the original files (CBR files are converted to CBZ and original CBR is deleted).
- Watch a folder for new CBZ files and optimize them automatically. - Watch a folder for new CBZ/CBR files and optimize them automatically.
- Set time limits for chapter conversion to avoid hanging on problematic files.
## Installation ## Installation
1. Clone the repository: ### Download Binary
```sh
git clone https://github.com/belphemur/CBZOptimizer.git
cd CBZOptimizer
```
2. Install dependencies: Download the latest release from [GitHub Releases](https://github.com/belphemur/CBZOptimizer/releases).
```sh
go mod tidy ### Docker
```
Pull the Docker image:
```sh
docker pull ghcr.io/belphemur/cbzoptimizer:latest
```
## Usage ## Usage
### Command Line Interface ### Command Line Interface
The tool provides CLI commands to optimize and watch CBZ files. Below are examples of how to use them: The tool provides CLI commands to optimize and watch CBZ/CBR files. Below are examples of how to use them:
#### Optimize Command #### Optimize Command
Optimize all CBZ files in a folder recursively: Optimize all CBZ/CBR files in a folder recursively:
```sh ```sh
go run main.go optimize [folder] --quality 85 --parallelism 2 --override --format webp --split 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
```
Preserve original page filenames in the output CBZ instead of sequential renumbering:
```sh
cbzconverter optimize [folder] --keep-filenames --quality 85 --format webp
```
With timeout to avoid hanging on problematic chapters:
```sh
cbzconverter optimize [folder] --timeout 10m --quality 85
```
Or with Docker:
```sh
docker run -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest optimize /comics --quality 85 --parallelism 2 --override --format webp --split
``` ```
#### Watch Command #### Watch Command
Watch a folder for new CBZ files and optimize them automatically: Watch a folder for new CBZ/CBR files and optimize them automatically:
```sh ```sh
go run main.go watch [folder] --quality 85 --override --format webp --split 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
docker run -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest watch /comics --quality 85 --override --format webp --split
``` ```
### Flags ### Flags
- `--quality`, `-q`: Quality for conversion (0-100). Default is 85. - `--quality`, `-q`: Quality for conversion (0-100). Default is 85.
- `--parallelism`, `-n`: Number of chapters to convert in parallel. Default is 2. - `--parallelism`, `-n`: Number of chapters to convert in parallel. Default is 2. Regardless of this value, the total number of pages converted at the same time (i.e. concurrent `cwebp` processes) is capped to the number of CPU cores, so increasing parallelism spreads that budget across more chapters rather than multiplying resource usage.
- `--override`, `-o`: Override the original CBZ files. Default is false. - `--override`, `-o`: Override the original files. For CBZ files, overwrites the original. For CBR files, deletes the original CBR and creates a new CBZ. Default is false.
- `--split`, `-s`: Split long pages into smaller chunks. Default is false. - `--split`, `-s`: Split long pages into smaller chunks. Default is false.
- `--format`, `-f`: Format to convert the images to (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
- `--keep-filenames`: Preserve each page's original base filename in the output CBZ (with the extension swapped for format conversion) instead of renumbering pages to `0000`, `0001`, ... Default is false. When two source pages share the same base filename, the second one falls back to the indexed form so the output stays a valid zip.
- `--timeout`, `-t`: Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout. Default is 0.
- `--backfill`: *(`watch` only)* Optimize CBZ/CBR files that already exist in the watched folder at startup, before watching for new changes. Default is false.
- `--log`, `-l`: Set log level; can be 'panic', 'fatal', 'error', 'warn', 'info', 'debug', or 'trace'. Default is info.
## Testing ## Logging
To run the tests, use the following command: CBZOptimizer uses structured logging with [zerolog](https://github.com/rs/zerolog) for consistent and performant logging output.
### Log Levels
You can control the verbosity of logging using either command-line flags or environment variables:
**Command Line:**
```sh ```sh
go test ./... -v # Set log level to debug for detailed output
cbzconverter --log debug optimize [folder]
# Set log level to error for minimal output
cbzconverter --log error optimize [folder]
``` ```
## Requirement **Environment Variable:**
Needs to have libwep installed on the machine if you're not using the docker image
## Docker ```sh
`ghcr.io/belphemur/cbzoptimizer:latest` # Set log level via environment variable
LOG_LEVEL=debug cbzconverter optimize [folder]
```
## GitHub Actions **Docker:**
The project includes a GitHub Actions workflow to run tests on every push and pull request to the `main` branch. The workflow is defined in `.github/workflows/go.yml`. ```sh
# Set log level via environment variable in Docker
docker run -e LOG_LEVEL=debug -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest optimize /comics
```
## Contributing ### Available Log Levels
1. Fork the repository. - `panic`: Logs panic level messages and above
2. Create a new branch (`git checkout -b feature-branch`). - `fatal`: Logs fatal level messages and above
3. Commit your changes (`git commit -am 'Add new feature'`). - `error`: Logs error level messages and above
4. Push to the branch (`git push origin feature-branch`). - `warn`: Logs warning level messages and above
5. Create a new Pull Request. - `info`: Logs info level messages and above (default)
- `debug`: Logs debug level messages and above
- `trace`: Logs all messages including trace level
### Examples
```sh
# Default info level logging
cbzconverter optimize comics/
# Debug level for troubleshooting
cbzconverter --log debug optimize comics/
# Quiet operation (only errors and above)
cbzconverter --log error optimize comics/
# Using environment variable
LOG_LEVEL=warn cbzconverter optimize comics/
# Docker with debug logging
docker run -e LOG_LEVEL=debug -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest optimize /comics
```
## 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:
1. Use `--log debug` for detailed logging output
2. Check that all required dependencies are installed
3. Ensure proper file permissions for input/output directories
4. For Docker usage, verify volume mounts are correct
## Support
For issues and questions, please use [GitHub Issues](https://github.com/belphemur/CBZOptimizer/issues).
## License ## License
-83
View File
@@ -1,83 +0,0 @@
package cbz
import (
"archive/zip"
"fmt"
"github.com/belphemur/CBZOptimizer/v2/manga"
"github.com/belphemur/CBZOptimizer/v2/utils/errs"
"os"
"time"
)
func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) error {
// Create a new ZIP file
zipFile, err := os.Create(outputFilePath)
if err != nil {
return fmt.Errorf("failed to create .cbz file: %w", err)
}
defer errs.Capture(&err, zipFile.Close, "failed to close .cbz file")
// Create a new ZIP writer
zipWriter := zip.NewWriter(zipFile)
if err != nil {
return err
}
defer errs.Capture(&err, zipWriter.Close, "failed to close .cbz writer")
// Write each page to the ZIP archive
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)
}
// Create a new file in the ZIP archive
fileWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: fileName,
Method: zip.Store,
Modified: time.Now(),
})
if err != nil {
return fmt.Errorf("failed to create file in .cbz: %w", err)
}
// Write the page contents to the file
_, err = fileWriter.Write(page.Contents.Bytes())
if err != nil {
return fmt.Errorf("failed to write page contents: %w", err)
}
}
// Optionally, write the ComicInfo.xml file if present
if chapter.ComicInfoXml != "" {
comicInfoWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "ComicInfo.xml",
Method: zip.Deflate,
Modified: time.Now(),
})
if err != nil {
return fmt.Errorf("failed to create ComicInfo.xml in .cbz: %w", err)
}
_, err = comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
if err != nil {
return fmt.Errorf("failed to write ComicInfo.xml contents: %w", err)
}
}
if chapter.IsConverted {
convertedString := fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", chapter.ConvertedTime)
err = zipWriter.SetComment(convertedString)
if err != nil {
return fmt.Errorf("failed to write comment: %w", err)
}
}
return nil
}
-144
View File
@@ -1,144 +0,0 @@
package cbz
import (
"archive/zip"
"bytes"
"fmt"
"github.com/belphemur/CBZOptimizer/v2/manga"
"github.com/belphemur/CBZOptimizer/v2/utils/errs"
"os"
"testing"
"time"
)
func TestWriteChapterToCBZ(t *testing.T) {
currentTime := time.Now()
// Define test cases
testCases := []struct {
name string
chapter *manga.Chapter
expectedFiles []string
expectedComment string
}{
//test case where there is only one page and ComicInfo and the chapter is converted
{
name: "Single page, ComicInfo, converted",
chapter: &manga.Chapter{
Pages: []*manga.Page{
{
Index: 0,
Extension: ".jpg",
Contents: bytes.NewBuffer([]byte("image data")),
},
},
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
IsConverted: true,
ConvertedTime: currentTime,
},
expectedFiles: []string{"0000.jpg", "ComicInfo.xml"},
expectedComment: fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", currentTime),
},
//test case where there is only one page and no
{
name: "Single page, no ComicInfo",
chapter: &manga.Chapter{
Pages: []*manga.Page{
{
Index: 0,
Extension: ".jpg",
Contents: bytes.NewBuffer([]byte("image data")),
},
},
},
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")),
},
{
Index: 1,
Extension: ".jpg",
Contents: bytes.NewBuffer([]byte("image data 2")),
},
},
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
},
expectedFiles: []string{"0000.jpg", "0001.jpg", "ComicInfo.xml"},
},
{
name: "Split page",
chapter: &manga.Chapter{
Pages: []*manga.Page{
{
Index: 0,
Extension: ".jpg",
Contents: bytes.NewBuffer([]byte("split image data")),
IsSplitted: true,
SplitPartIndex: 1,
},
},
},
expectedFiles: []string{"0000-01.jpg"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create a temporary file for the .cbz output
tempFile, err := os.CreateTemp("", "*.cbz")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
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())
if err != nil {
t.Fatalf("Failed to write chapter to CBZ: %v", err)
}
// Open the .cbz file as a zip archive
r, err := zip.OpenReader(tempFile.Name())
if err != nil {
t.Fatalf("Failed to open CBZ file: %v", err)
}
defer errs.Capture(&err, r.Close, "failed to close CBZ file")
// Collect the names of the files in the archive
var filesInArchive []string
for _, f := range r.File {
filesInArchive = append(filesInArchive, f.Name)
}
// Check if all expected files are present
for _, expectedFile := range tc.expectedFiles {
found := false
for _, actualFile := range filesInArchive {
if actualFile == expectedFile {
found = true
break
}
}
if !found {
t.Errorf("Expected file %s not found in archive", expectedFile)
}
}
if tc.expectedComment != "" && r.Comment != tc.expectedComment {
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))
}
})
}
}
-104
View File
@@ -1,104 +0,0 @@
package cbz
import (
"archive/zip"
"bufio"
"bytes"
"fmt"
"github.com/araddon/dateparse"
"github.com/belphemur/CBZOptimizer/v2/manga"
"github.com/belphemur/CBZOptimizer/v2/utils/errs"
"io"
"path/filepath"
"strings"
)
func LoadChapter(filePath string) (*manga.Chapter, error) {
// Open the .cbz file
r, err := zip.OpenReader(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open .cbz file: %w", err)
}
defer errs.Capture(&err, r.Close, "failed to close opened .cbz file")
chapter := &manga.Chapter{
FilePath: filePath,
}
// Check for comment
if r.Comment != "" {
scanner := bufio.NewScanner(strings.NewReader(r.Comment))
if scanner.Scan() {
convertedTime := scanner.Text()
chapter.ConvertedTime, err = dateparse.ParseAny(convertedTime)
if err == nil {
chapter.IsConverted = true
}
}
}
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
err := func() error {
// Open the file inside the zip
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open file inside .cbz: %w", err)
}
defer errs.Capture(&err, rc.Close, "failed to close file inside .cbz")
// Determine the file extension
ext := strings.ToLower(filepath.Ext(f.Name))
if ext == ".xml" && strings.ToLower(filepath.Base(f.Name)) == "comicinfo.xml" {
// Read the ComicInfo.xml file content
xmlContent, err := io.ReadAll(rc)
if err != nil {
return fmt.Errorf("failed to read ComicInfo.xml content: %w", err)
}
chapter.ComicInfoXml = string(xmlContent)
} else if !chapter.IsConverted && ext == ".txt" && strings.ToLower(filepath.Base(f.Name)) == "converted.txt" {
textContent, err := io.ReadAll(rc)
if err != nil {
return fmt.Errorf("failed to read Converted.xml content: %w", err)
}
scanner := bufio.NewScanner(bytes.NewReader(textContent))
if scanner.Scan() {
convertedTime := scanner.Text()
chapter.ConvertedTime, err = dateparse.ParseAny(convertedTime)
if err != nil {
return fmt.Errorf("failed to parse converted time: %w", err)
}
chapter.IsConverted = true
}
} else {
// Read the file contents for page
buf := new(bytes.Buffer)
_, err = io.Copy(buf, rc)
if err != nil {
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)
}
return nil
}()
if err != nil {
return nil, err
}
}
return chapter, nil
}
-56
View File
@@ -1,56 +0,0 @@
package cbz
import (
"strings"
"testing"
)
func TestLoadChapter(t *testing.T) {
type testCase struct {
name string
filePath string
expectedPages int
expectedSeries string
expectedConversion bool
}
testCases := []testCase{
{
name: "Original Chapter",
filePath: "../testdata/Chapter 1.cbz",
expectedPages: 16,
expectedSeries: "<Series>Boundless Necromancer</Series>",
expectedConversion: false,
},
{
name: "Converted Chapter",
filePath: "../testdata/Chapter 10_converted.cbz",
expectedPages: 107,
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)
}
actualPages := len(chapter.Pages)
if actualPages != tc.expectedPages {
t.Errorf("Expected %d pages, but got %d", tc.expectedPages, actualPages)
}
if !strings.Contains(chapter.ComicInfoXml, tc.expectedSeries) {
t.Errorf("ComicInfoXml does not contain the expected series: %s", tc.expectedSeries)
}
if chapter.IsConverted != tc.expectedConversion {
t.Errorf("Expected chapter to be converted: %t, but got %t", tc.expectedConversion, chapter.IsConverted)
}
})
}
}
+114
View File
@@ -0,0 +1,114 @@
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"))
}
}
// setupKeepFilenamesFlag sets up the keep-filenames flag for a command.
//
// Parameters:
// - cmd: The Cobra command to add the keep-filenames flag to
// - defaultValue: The default keep-filenames value
// - bindViper: If true, binds the flag to viper for configuration file support
func setupKeepFilenamesFlag(cmd *cobra.Command, defaultValue bool, bindViper bool) {
cmd.Flags().Bool("keep-filenames", defaultValue, "Preserve original page filenames instead of renumbering to sequential indices")
if bindViper {
_ = viper.BindPFlag("keep-filenames", cmd.Flags().Lookup("keep-filenames"))
}
}
// setupTimeoutFlag sets up the timeout flag for a command.
//
// Parameters:
// - 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)
setupKeepFilenamesFlag(cmd, false, bindViper)
setupTimeoutFlag(cmd, bindViper)
}
@@ -0,0 +1,191 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
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/rs/zerolog/log"
"github.com/spf13/cobra"
)
var converterType constant.ConversionFormat
func init() {
command := &cobra.Command{
Use: "optimize [folder]",
Short: "Optimize all CBZ/CBR files in a folder recursively",
Long: "Optimize all CBZ/CBR files in a folder recursively.\nIt will take all the different pages in the CBZ/CBR files and convert them to the given format.\nThe original CBZ/CBR files will be kept intact depending if you choose to override or not.",
RunE: ConvertCbzCommand,
Args: cobra.ExactArgs(1),
}
// 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")
AddCommand(command)
}
func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
log.Info().Str("command", "optimize").Msg("Starting optimize command")
path := args[0]
if path == "" {
log.Error().Msg("Path argument is required but empty")
return fmt.Errorf("path is required")
}
log.Debug().Str("input_path", path).Msg("Validating input path")
if !utils2.IsValidFolder(path) {
log.Error().Str("input_path", path).Msg("Path validation failed - not a valid folder")
return fmt.Errorf("the path needs to be a folder")
}
log.Debug().Str("input_path", path).Msg("Input path validated successfully")
log.Debug().Msg("Parsing command-line flags")
quality, err := cmd.Flags().GetUint8("quality")
if err != nil || quality <= 0 || quality > 100 {
log.Error().Err(err).Uint8("quality", quality).Msg("Invalid quality value")
return fmt.Errorf("invalid quality value")
}
log.Debug().Uint8("quality", quality).Msg("Quality parameter validated")
override, err := cmd.Flags().GetBool("override")
if err != nil {
log.Error().Err(err).Msg("Failed to parse override flag")
return fmt.Errorf("invalid quality value")
}
log.Debug().Bool("override", override).Msg("Override parameter parsed")
split, err := cmd.Flags().GetBool("split")
if err != nil {
log.Error().Err(err).Msg("Failed to parse split flag")
return fmt.Errorf("invalid split value")
}
log.Debug().Bool("split", split).Msg("Split parameter parsed")
keepFilenames, err := cmd.Flags().GetBool("keep-filenames")
if err != nil {
log.Error().Err(err).Msg("Failed to parse keep-filenames flag")
return fmt.Errorf("invalid keep-filenames value")
}
log.Debug().Bool("keep-filenames", keepFilenames).Msg("Keep-filenames parameter parsed")
timeout, err := cmd.Flags().GetDuration("timeout")
if err != nil {
log.Error().Err(err).Msg("Failed to parse timeout flag")
return fmt.Errorf("invalid timeout value")
}
log.Debug().Dur("timeout", timeout).Msg("Timeout parameter parsed")
parallelism, err := cmd.Flags().GetInt("parallelism")
if err != nil || parallelism < 1 {
log.Error().Err(err).Int("parallelism", parallelism).Msg("Invalid parallelism value")
return fmt.Errorf("invalid parallelism value")
}
log.Debug().Int("parallelism", parallelism).Msg("Parallelism parameter validated")
log.Debug().Str("converter_format", converterType.String()).Msg("Initializing converter")
chapterConverter, err := converter.Get(converterType)
if err != nil {
log.Error().Str("converter_format", converterType.String()).Err(err).Msg("Failed to get chapter converter")
return fmt.Errorf("failed to get chapterConverter: %v", err)
}
log.Debug().Str("converter_format", converterType.String()).Msg("Converter initialized successfully")
log.Debug().Msg("Preparing converter")
err = chapterConverter.PrepareConverter()
if err != nil {
log.Error().Err(err).Msg("Failed to prepare converter")
return fmt.Errorf("failed to prepare converter: %v", err)
}
log.Debug().Msg("Converter prepared successfully")
// Channel to manage the files to process
fileChan := make(chan string)
// 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
// Start worker goroutines
log.Debug().Int("worker_count", parallelism).Msg("Starting worker goroutines")
for i := 0; i < parallelism; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
log.Debug().Int("worker_id", workerID).Msg("Worker started")
for path := range fileChan {
log.Debug().Int("worker_id", workerID).Str("file_path", path).Msg("Worker processing file")
err := utils2.Optimize(&utils2.OptimizeOptions{
ChapterConverter: chapterConverter,
Path: path,
Quality: quality,
Override: override,
Split: split,
KeepFilenames: keepFilenames,
Timeout: timeout,
})
if err != nil {
log.Error().Int("worker_id", workerID).Str("file_path", path).Err(err).Msg("Worker encountered error")
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")
}
}
log.Debug().Int("worker_id", workerID).Msg("Worker finished")
}(i)
}
log.Debug().Int("worker_count", parallelism).Msg("All worker goroutines started")
// Walk the path and send files to the channel
log.Debug().Str("search_path", path).Msg("Starting filesystem walk for CBZ/CBR files")
err = filepath.WalkDir(path, func(filePath string, info os.DirEntry, err error) error {
if err != nil {
log.Error().Str("file_path", filePath).Err(err).Msg("Error during filesystem walk")
return err
}
if !info.IsDir() {
fileName := strings.ToLower(info.Name())
if strings.HasSuffix(fileName, ".cbz") || strings.HasSuffix(fileName, ".cbr") {
log.Debug().Str("file_path", filePath).Str("file_name", fileName).Msg("Found CBZ/CBR file")
fileChan <- filePath
}
}
return nil
})
if err != nil {
log.Error().Str("search_path", path).Err(err).Msg("Filesystem walk failed")
return fmt.Errorf("error walking the path: %w", err)
}
log.Debug().Str("search_path", path).Msg("Filesystem walk completed")
close(fileChan) // Close the channel to signal workers to stop
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")
if len(errs) > 0 {
log.Error().Int("error_count", len(errs)).Msg("Command completed with errors")
return fmt.Errorf("encountered errors: %v", errs)
}
log.Info().Str("search_path", path).Msg("Optimize command completed successfully")
return nil
}
@@ -0,0 +1,560 @@
package commands
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/cbz"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/spf13/cobra"
)
// MockConverter is a mock implementation of the Converter interface
type MockConverter struct{}
func (m *MockConverter) ConvertChapter(ctx context.Context, chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
chapter.IsConverted = true
chapter.ConvertedTime = time.Now()
return chapter, nil
}
func (m *MockConverter) Format() constant.ConversionFormat {
return constant.WebP
}
func (m *MockConverter) PrepareConverter() error {
return nil
}
func TestConvertCbzCommand(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "test_cbz")
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")
}
// Copy sample CBZ/CBR files from testdata to the temporary directory
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") {
destPath := filepath.Join(tempDir, info.Name())
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(destPath, data, info.Mode())
}
}
return nil
})
if err != nil {
t.Fatalf("Failed to copy sample files: %v", err)
}
// 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
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().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout")
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
if err != nil {
t.Fatalf("Command execution failed: %v", err)
}
// Track expected converted files for verification
expectedFiles := make(map[string]bool)
convertedFiles := make(map[string]bool)
// First pass: identify original files and expected converted filenames
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
fileName := strings.ToLower(info.Name())
if strings.HasSuffix(fileName, ".cbz") || strings.HasSuffix(fileName, ".cbr") {
if !strings.Contains(fileName, "_converted") {
// This is an original file, determine expected converted filename
baseName := strings.TrimSuffix(info.Name(), filepath.Ext(info.Name()))
expectedConverted := baseName + "_converted.cbz"
expectedFiles[expectedConverted] = false // false means not yet found
}
}
return nil
})
if err != nil {
t.Fatalf("Error identifying original files: %v", err)
}
// Second pass: verify converted files exist and are properly converted
err = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
fileName := info.Name()
// Check if this is a converted file (should only be .cbz, never .cbr)
if strings.HasSuffix(fileName, "_converted.cbz") {
convertedFiles[fileName] = true
expectedFiles[fileName] = true // Mark as found
t.Logf("Archive file found: %s", path)
// Load the converted chapter
chapter, err := cbz.LoadChapter(path)
if err != nil {
return err
}
// Check if the chapter is marked as converted
if !chapter.IsConverted {
t.Errorf("Chapter is not marked as converted: %s", path)
}
// Check if the ConvertedTime is set
if chapter.ConvertedTime.IsZero() {
t.Errorf("ConvertedTime is not set for chapter: %s", path)
}
t.Logf("Archive file [%s] is converted: %s", path, chapter.ConvertedTime)
} else if strings.HasSuffix(fileName, "_converted.cbr") {
t.Errorf("Found incorrectly named converted file: %s (should be .cbz, not .cbr)", fileName)
}
return nil
})
if err != nil {
t.Fatalf("Error verifying converted files: %v", err)
}
// Verify all expected files were found
for expectedFile, found := range expectedFiles {
if !found {
t.Errorf("Expected converted file not found: %s", expectedFile)
}
}
// 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().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
// Reset converterType to default before test for consistency
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().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
converterType = constant.DefaultConversion
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().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
converterType = constant.DefaultConversion
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")
}
}
+141
View File
@@ -0,0 +1,141 @@
package commands
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/thediveo/enumflag/v2"
)
// Map zerolog levels to their textual representations
var LogLevelIds = map[zerolog.Level][]string{
zerolog.PanicLevel: {"panic"},
zerolog.FatalLevel: {"fatal"},
zerolog.ErrorLevel: {"error"},
zerolog.WarnLevel: {"warn", "warning"},
zerolog.InfoLevel: {"info"},
zerolog.DebugLevel: {"debug"},
zerolog.TraceLevel: {"trace"},
}
// Global log level variable with default
var logLevel zerolog.Level = zerolog.InfoLevel
var rootCmd = &cobra.Command{
Use: "cbzconverter",
Short: "Convert CBZ files using a specified converter",
}
func SetVersionInfo(version, commit, date string) {
rootCmd.Version = fmt.Sprintf("%s (Built on %s from Git SHA %s)", version, date, commit)
}
func getPath() string {
return filepath.Join(map[string]string{
"windows": filepath.Join(os.Getenv("APPDATA")),
"darwin": filepath.Join(os.Getenv("HOME"), ".config"),
"linux": filepath.Join(os.Getenv("HOME"), ".config"),
}[runtime.GOOS], "CBZOptimizer")
}
func init() {
configFolder := getPath()
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(configFolder)
viper.SetEnvPrefix("CBZ")
viper.AutomaticEnv()
// Add log level flag (accepts zerolog levels: panic, fatal, error, warn, info, debug, trace)
ef := enumflag.New(&logLevel, "log", LogLevelIds, enumflag.EnumCaseInsensitive)
rootCmd.PersistentFlags().VarP(
ef,
"log", "l",
"Set log level; can be 'panic', 'fatal', 'error', 'warn', 'info', 'debug', or 'trace'")
if err := ef.RegisterCompletion(rootCmd, "log", enumflag.Help[zerolog.Level]{
zerolog.PanicLevel: "Only log panic messages",
zerolog.FatalLevel: "Log fatal and panic messages",
zerolog.ErrorLevel: "Log error, fatal, and panic messages",
zerolog.WarnLevel: "Log warn, error, fatal, and panic messages",
zerolog.InfoLevel: "Log info, warn, error, fatal, and panic messages",
zerolog.DebugLevel: "Log debug, info, warn, error, fatal, and panic messages",
zerolog.TraceLevel: "Log all messages including trace",
}); err != nil {
panic(fmt.Errorf("failed to register log completion: %w", err))
}
// Add log level environment variable support
if err := viper.BindEnv("log", "LOG_LEVEL"); err != nil {
panic(fmt.Errorf("failed to bind LOG_LEVEL env: %w", err))
}
if err := viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log")); err != nil {
panic(fmt.Errorf("failed to bind log flag: %w", err))
}
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
ConfigureLogging()
}
// Ensure the configuration directory exists
err := os.MkdirAll(configFolder, os.ModePerm)
if err != nil {
panic(fmt.Errorf("fatal error config file: %w", err))
}
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
err := viper.SafeWriteConfig()
if err != nil {
panic(fmt.Errorf("fatal error config file: %w", err))
}
} else {
panic(fmt.Errorf("fatal error config file: %w", err))
}
}
}
// Execute executes the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatal().Err(err).Msg("Command execution failed")
}
}
func AddCommand(cmd *cobra.Command) {
rootCmd.AddCommand(cmd)
}
// ConfigureLogging sets up zerolog based on command-line flags and environment variables
func ConfigureLogging() {
// Start with default log level (info)
level := zerolog.InfoLevel
// Check LOG_LEVEL environment variable first
envLogLevel := viper.GetString("log")
if envLogLevel != "" {
if parsedLevel, err := zerolog.ParseLevel(envLogLevel); err == nil {
level = parsedLevel
}
}
// Command-line log flag takes precedence over environment variable
// The logLevel variable will be set by the flag parsing, so if it's different from default, use it
if logLevel != zerolog.InfoLevel {
level = logLevel
}
// Set the global log level
zerolog.SetGlobalLevel(level)
// Configure console writer for readable output
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
NoColor: false,
})
}
+386
View File
@@ -0,0 +1,386 @@
package commands
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
utils2 "github.com/belphemur/CBZOptimizer/v2/internal/utils"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// debounceDelay is the quiet period we wait, per path, after the last fsnotify
// event before triggering an optimization. This coalesces the bursts of
// Write/Create/Rename events fsnotify emits while a file is still being
// written (e.g. copied or downloaded into the watched folder).
const debounceDelay = 2 * time.Second
func init() {
if runtime.GOOS != "linux" {
return
}
command := &cobra.Command{
Use: "watch [folder]",
Short: "Watch a folder for new CBZ/CBR files",
Long: "Watch a folder for new CBZ/CBR files.\nIt will watch a folder for new CBZ/CBR files and optimize them.",
RunE: WatchCommand,
Args: cobra.ExactArgs(1),
}
// Setup common flags (format, quality, override, split, timeout) with viper binding
setupCommonFlags(command, &converterType, 85, true, false, true)
command.Flags().Bool("backfill", false, "Optimize CBZ/CBR files that already exist in the watched folder at startup, before watching for new changes")
_ = viper.BindPFlag("backfill", command.Flags().Lookup("backfill"))
AddCommand(command)
}
func WatchCommand(_ *cobra.Command, args []string) error {
path := args[0]
if path == "" {
return fmt.Errorf("path is required")
}
if !utils2.IsValidFolder(path) {
return fmt.Errorf("the path needs to be a folder")
}
quality := uint8(viper.GetUint16("quality"))
if quality <= 0 || quality > 100 {
return fmt.Errorf("invalid quality value")
}
override := viper.GetBool("override")
split := viper.GetBool("split")
keepFilenames := viper.GetBool("keep-filenames")
timeout := viper.GetDuration("timeout")
backfill := viper.GetBool("backfill")
converterType := constant.FindConversionFormat(viper.GetString("format"))
chapterConverter, err := converter.Get(converterType)
if err != nil {
return fmt.Errorf("failed to get chapterConverter: %w", err)
}
err = chapterConverter.PrepareConverter()
if err != nil {
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).Bool("keep_filenames", keepFilenames).Bool("backfill", backfill).Msg("Watching directory")
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create file watcher: %w", err)
}
defer func() {
if closeErr := watcher.Close(); closeErr != nil {
log.Error().Err(closeErr).Msg("Failed to close file watcher")
}
}()
// 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,
KeepFilenames: keepFilenames,
Timeout: timeout,
})
defer queue.Stop()
debouncer := newEventDebouncer(debounceDelay, queue.Enqueue)
defer debouncer.Stop()
// Note: existing archives already present under path when the watch
// starts are left untouched unless --backfill is set. Watch mode only
// reacts to filesystem events going forward by default; use the
// `optimize` command (or pass --backfill) to process a library's
// existing contents. Archives inside a directory that is created/moved
// into the watched tree *after* startup are always back-filled below,
// since only the directory itself generates an fsnotify event.
if err := addRecursiveWatch(watcher, path); err != nil {
return fmt.Errorf("failed to watch path %s: %w", path, err)
}
maybeBackfillExistingArchives(backfill, path, debouncer.Trigger)
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return nil
}
log.Debug().Str("file", event.Name).Str("event", event.Op.String()).Msg("File event")
if event.Has(fsnotify.Create) {
fileInfo, err := os.Stat(event.Name)
if err == nil && fileInfo.IsDir() {
if err := addRecursiveWatch(watcher, event.Name); err != nil {
log.Error().Err(err).Str("path", event.Name).Msg("Failed to watch created directory")
}
// The newly discovered directory may already contain
// archives (e.g. a folder moved/copied in); back-fill them
// since no further fsnotify event will target them.
backfillExistingArchives(event.Name, debouncer.Trigger)
continue
}
}
if !shouldProcessWatchEvent(event) {
continue
}
if !isComicArchive(event.Name) {
continue
}
debouncer.Trigger(event.Name)
case err, ok := <-watcher.Errors:
if !ok {
return nil
}
log.Error().Err(err).Msg("Watch error")
}
}
}
// addRecursiveWatch registers a watch on rootPath and every subdirectory
// beneath it. Directories that can't be read (e.g. permission errors) are
// logged and skipped instead of aborting the whole walk, so a single bad
// subtree doesn't prevent the rest of the folder from being watched.
func addRecursiveWatch(watcher *fsnotify.Watcher, rootPath string) error {
return filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
log.Warn().Err(err).Str("path", path).Msg("Skipping path while setting up watch")
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")
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"github.com/belphemur/CBZOptimizer/v2/cmd/cbzoptimizer/commands"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
commands.SetVersionInfo(version, commit, date)
commands.Execute()
}
+19
View File
@@ -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.")
}
-140
View File
@@ -1,140 +0,0 @@
package cmd
import (
"fmt"
"github.com/belphemur/CBZOptimizer/v2/converter"
"github.com/belphemur/CBZOptimizer/v2/converter/constant"
"github.com/belphemur/CBZOptimizer/v2/utils"
"github.com/spf13/cobra"
"github.com/thediveo/enumflag/v2"
"os"
"path/filepath"
"strings"
"sync"
)
var converterType constant.ConversionFormat
func init() {
command := &cobra.Command{
Use: "optimize [folder]",
Short: "Optimize all CBZ files in a folder recursively",
Long: "Optimize all CBZ files in a folder recursively.\nIt will take all the different pages in the CBZ files and convert them to the given format.\nThe original CBZ files will be kept intact depending if you choose to override or not.",
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)")
command.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel")
command.Flags().BoolP("override", "o", false, "Override the original CBZ files")
command.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
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)
}
func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
path := args[0]
if path == "" {
return fmt.Errorf("path is required")
}
if !utils.IsValidFolder(path) {
return fmt.Errorf("the path needs to be a folder")
}
quality, err := cmd.Flags().GetUint8("quality")
if err != nil || quality <= 0 || quality > 100 {
return fmt.Errorf("invalid quality value")
}
override, err := cmd.Flags().GetBool("override")
if err != nil {
return fmt.Errorf("invalid quality value")
}
split, err := cmd.Flags().GetBool("split")
if err != nil {
return fmt.Errorf("invalid split value")
}
parallelism, err := cmd.Flags().GetInt("parallelism")
if err != nil || parallelism < 1 {
return fmt.Errorf("invalid parallelism value")
}
chapterConverter, err := converter.Get(converterType)
if err != nil {
return fmt.Errorf("failed to get chapterConverter: %v", err)
}
err = chapterConverter.PrepareConverter()
if err != nil {
return fmt.Errorf("failed to prepare converter: %v", err)
}
// Channel to manage the files to process
fileChan := make(chan string)
// Channel to collect errors
errorChan := make(chan error, parallelism)
// WaitGroup to wait for all goroutines to finish
var wg sync.WaitGroup
// Start worker goroutines
for i := 0; i < parallelism; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for path := range fileChan {
err := utils.Optimize(&utils.OptimizeOptions{
ChapterConverter: chapterConverter,
Path: path,
Quality: quality,
Override: override,
Split: split,
})
if err != nil {
errorChan <- fmt.Errorf("error processing file %s: %w", path, err)
}
}
}()
}
// Walk the path and send files to the channel
err = filepath.WalkDir(path, func(path string, info os.DirEntry, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".cbz") {
fileChan <- path
}
return nil
})
if err != nil {
return fmt.Errorf("error walking the path: %w", err)
}
close(fileChan) // Close the channel to signal workers to stop
wg.Wait() // Wait for all workers to finish
close(errorChan) // Close the error channel
var errs []error
for err := range errorChan {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("encountered errors: %v", errs)
}
return nil
}
-122
View File
@@ -1,122 +0,0 @@
package cmd
import (
"github.com/belphemur/CBZOptimizer/v2/cbz"
"github.com/belphemur/CBZOptimizer/v2/converter"
"github.com/belphemur/CBZOptimizer/v2/converter/constant"
"github.com/belphemur/CBZOptimizer/v2/manga"
"github.com/belphemur/CBZOptimizer/v2/utils/errs"
"github.com/spf13/cobra"
"log"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// MockConverter is a mock implementation of the Converter interface
type MockConverter struct{}
func (m *MockConverter) ConvertChapter(chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
chapter.IsConverted = true
chapter.ConvertedTime = time.Now()
return chapter, nil
}
func (m *MockConverter) Format() constant.ConversionFormat {
return constant.WebP
}
func (m *MockConverter) PrepareConverter() error {
return nil
}
func TestConvertCbzCommand(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "test_cbz")
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")
}
// Copy sample CBZ files from testdata to the temporary directory
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") {
destPath := filepath.Join(tempDir, info.Name())
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(destPath, data, info.Mode())
}
return nil
})
if err != nil {
t.Fatalf("Failed to copy sample files: %v", err)
}
// 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
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 files")
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
if err != nil {
t.Fatalf("Command execution failed: %v", err)
}
// Verify the results
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") {
return nil
}
t.Logf("CBZ file found: %s", path)
// Load the converted chapter
chapter, err := cbz.LoadChapter(path)
if err != nil {
return err
}
// Check if the chapter is marked as converted
if !chapter.IsConverted {
t.Errorf("Chapter is not marked as converted: %s", path)
}
// Check if the ConvertedTime is set
if chapter.ConvertedTime.IsZero() {
t.Errorf("ConvertedTime is not set for chapter: %s", path)
}
t.Logf("CBZ file [%s] is converted: %s", path, chapter.ConvertedTime)
return nil
})
if err != nil {
t.Fatalf("Error verifying converted files: %v", err)
}
}
-62
View File
@@ -1,62 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"os"
"path/filepath"
"runtime"
)
var rootCmd = &cobra.Command{
Use: "cbzconverter",
Short: "Convert CBZ files using a specified converter",
}
func SetVersionInfo(version, commit, date string) {
rootCmd.Version = fmt.Sprintf("%s (Built on %s from Git SHA %s)", version, date, commit)
}
func getPath() string {
return filepath.Join(map[string]string{
"windows": filepath.Join(os.Getenv("APPDATA")),
"darwin": filepath.Join(os.Getenv("HOME"), ".config"),
"linux": filepath.Join(os.Getenv("HOME"), ".config"),
}[runtime.GOOS], "CBZOptimizer")
}
func init() {
configFolder := getPath()
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(configFolder)
viper.SetEnvPrefix("CBZ")
viper.AutomaticEnv()
err := os.MkdirAll(configFolder, os.ModePerm)
if err != nil {
panic(fmt.Errorf("fatal error config file: %w", err))
}
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
err := viper.SafeWriteConfig()
if err != nil {
panic(fmt.Errorf("fatal error config file: %w", err))
}
} else {
panic(fmt.Errorf("fatal error config file: %w", err))
}
}
}
// Execute executes the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func AddCommand(cmd *cobra.Command) {
rootCmd.AddCommand(cmd)
}
-144
View File
@@ -1,144 +0,0 @@
package cmd
import (
"fmt"
"github.com/belphemur/CBZOptimizer/v2/converter"
"github.com/belphemur/CBZOptimizer/v2/converter/constant"
"github.com/belphemur/CBZOptimizer/v2/utils"
"github.com/pablodz/inotifywaitgo/inotifywaitgo"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/thediveo/enumflag/v2"
"log"
"runtime"
"strings"
"sync"
)
func init() {
if runtime.GOOS != "linux" {
return
}
command := &cobra.Command{
Use: "watch [folder]",
Short: "Watch a folder for new CBZ files",
Long: "Watch a folder for new CBZ files.\nIt will watch a folder for new CBZ files and optimize them.",
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"))
command.Flags().BoolP("override", "o", true, "Override the original CBZ 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.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"))
AddCommand(command)
}
func WatchCommand(_ *cobra.Command, args []string) error {
path := args[0]
if path == "" {
return fmt.Errorf("path is required")
}
if !utils.IsValidFolder(path) {
return fmt.Errorf("the path needs to be a folder")
}
quality := uint8(viper.GetUint16("quality"))
if quality <= 0 || quality > 100 {
return fmt.Errorf("invalid quality value")
}
override := viper.GetBool("override")
split := viper.GetBool("split")
converterType := constant.FindConversionFormat(viper.GetString("format"))
chapterConverter, err := converter.Get(converterType)
if err != nil {
return fmt.Errorf("failed to get chapterConverter: %v", err)
}
err = chapterConverter.PrepareConverter()
if err != nil {
return fmt.Errorf("failed to prepare converter: %v", err)
}
log.Printf("Watching [%s] with [override: %t, quality: %d, format: %s, split: %t]", path, override, quality, converterType.String(), split)
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,
})
}()
wg.Add(1)
go func() {
defer wg.Done()
for event := range events {
log.Printf("[Event]%s, %v\n", event.Filename, event.Events)
if !strings.HasSuffix(strings.ToLower(event.Filename), ".cbz") {
continue
}
for _, e := range event.Events {
switch e {
case inotifywaitgo.CLOSE_WRITE, inotifywaitgo.MOVE:
err := utils.Optimize(&utils.OptimizeOptions{
ChapterConverter: chapterConverter,
Path: event.Filename,
Quality: quality,
Override: override,
Split: split,
})
if err != nil {
errors <- fmt.Errorf("error processing file %s: %w", event.Filename, err)
}
default:
// ignored
}
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for err := range errors {
log.Printf("Error: %v\n", err)
}
}()
wg.Wait()
return nil
}
-43
View File
@@ -1,43 +0,0 @@
package converter
import (
"fmt"
"github.com/belphemur/CBZOptimizer/v2/converter/constant"
"github.com/belphemur/CBZOptimizer/v2/converter/webp"
"github.com/belphemur/CBZOptimizer/v2/manga"
"github.com/samber/lo"
"strings"
)
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.
ConvertChapter(chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error)
PrepareConverter() error
}
var converters = map[constant.ConversionFormat]Converter{
constant.WebP: webp.New(),
}
// Available returns a list of available converters.
func Available() []constant.ConversionFormat {
return lo.Keys(converters)
}
// Get returns a packer by name.
// If the packer is not available, an error is returned.
var Get = getConverter
func getConverter(name constant.ConversionFormat) (Converter, error) {
if converter, ok := converters[name]; ok {
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 item.String()
}), ", "))
}
-234
View File
@@ -1,234 +0,0 @@
package converter
import (
"bytes"
"github.com/belphemur/CBZOptimizer/v2/converter/constant"
"github.com/belphemur/CBZOptimizer/v2/manga"
"github.com/belphemur/CBZOptimizer/v2/utils/errs"
"golang.org/x/exp/slices"
"image"
"image/jpeg"
"os"
"testing"
)
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: "All split pages",
genTestChapter: genHugePage,
split: true,
expectFailure: []constant.ConversionFormat{},
expectPartialSuccess: []constant.ConversionFormat{},
},
{
name: "Big Pages, no split",
genTestChapter: genHugePage,
split: false,
expectFailure: []constant.ConversionFormat{constant.WebP},
expectPartialSuccess: []constant.ConversionFormat{},
},
{
name: "No split pages",
genTestChapter: genSmallPages,
split: false,
expectFailure: []constant.ConversionFormat{},
expectPartialSuccess: []constant.ConversionFormat{},
},
{
name: "Mix of split and no split pages",
genTestChapter: genMixSmallBig,
split: true,
expectFailure: []constant.ConversionFormat{},
expectPartialSuccess: []constant.ConversionFormat{},
},
{
name: "Mix of Huge and small page",
genTestChapter: genMixSmallHuge,
split: false,
expectFailure: []constant.ConversionFormat{},
expectPartialSuccess: []constant.ConversionFormat{constant.WebP},
},
}
// 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)
if err != nil {
t.Fatalf("failed to get converter: %v", err)
}
t.Run(converter.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)
}
quality := uint8(80)
progress := func(msg string, current uint32, total uint32) {
t.Log(msg)
}
convertedChapter, err := converter.ConvertChapter(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")
}
if len(convertedChapter.Pages) == 0 {
t.Fatalf("no pages were converted")
}
if len(convertedChapter.Pages) != len(chapter.Pages) {
t.Fatalf("converted chapter has different number of pages")
}
for _, page := range convertedChapter.Pages {
if page.Extension != ".webp" {
t.Errorf("page %d was not converted to webp format", page.Index)
}
}
})
}
})
}
}
func genHugePage(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")
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)
}
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
}
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)
}
return &manga.Chapter{
FilePath: path,
Pages: pages,
}, nil
}
func genMixSmallBig(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")
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)
}
return &manga.Chapter{
FilePath: path,
Pages: pages,
}, nil
}
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")
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{
Index: uint16(i),
Contents: buf,
Extension: ".jpg",
}
pages = append(pages, page)
}
return &manga.Chapter{
FilePath: path,
Pages: pages,
}, nil
}
-247
View File
@@ -1,247 +0,0 @@
package webp
import (
"bytes"
"errors"
"fmt"
"github.com/belphemur/CBZOptimizer/v2/converter/constant"
converterrors "github.com/belphemur/CBZOptimizer/v2/converter/errors"
"github.com/belphemur/CBZOptimizer/v2/manga"
"github.com/oliamb/cutter"
"golang.org/x/exp/slices"
_ "golang.org/x/image/webp"
"image"
_ "image/jpeg"
"image/png"
"io"
"runtime"
"sync"
"sync/atomic"
)
const webpMaxHeight = 16383
type Converter struct {
maxHeight int
cropHeight int
isPrepared bool
}
func (converter *Converter) Format() (format constant.ConversionFormat) {
return constant.WebP
}
func New() *Converter {
return &Converter{
//maxHeight: 16383 / 2,
maxHeight: 4000,
cropHeight: 2000,
isPrepared: false,
}
}
func (converter *Converter) PrepareConverter() error {
if converter.isPrepared {
return nil
}
err := PrepareEncoder()
if err != nil {
return err
}
converter.isPrepared = true
return nil
}
func (converter *Converter) ConvertChapter(chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
err := converter.PrepareConverter()
if err != nil {
return nil, err
}
var wgConvertedPages sync.WaitGroup
maxGoroutines := runtime.NumCPU()
pagesChan := make(chan *manga.PageContainer, maxGoroutines)
errChan := make(chan error, maxGoroutines)
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))
go func() {
for page := range pagesChan {
guard <- struct{}{} // would block if guard channel is already filled
go func(pageToConvert *manga.PageContainer) {
defer wgConvertedPages.Done()
convertedPage, err := converter.convertPage(pageToConvert, quality)
if err != nil {
if convertedPage == nil {
errChan <- err
<-guard
return
}
buffer := new(bytes.Buffer)
err := png.Encode(buffer, convertedPage.Image)
if err != nil {
errChan <- err
<-guard
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()
<-guard
}(page)
}
}()
for _, page := range chapter.Pages {
go func(page *manga.Page) {
defer wgPages.Done()
splitNeeded, img, format, err := converter.checkPageNeedsSplit(page, split)
if err != nil {
errChan <- err
// Partial error in this case, we want the page, but not converting it
if img != nil {
wgConvertedPages.Add(1)
pagesChan <- manga.NewContainer(page, img, format, false)
}
return
}
if !splitNeeded {
wgConvertedPages.Add(1)
pagesChan <- manga.NewContainer(page, img, format, true)
return
}
images, err := converter.cropImage(img)
if err != nil {
errChan <- err
return
}
atomic.AddUint32(&totalPages, uint32(len(images)-1))
for i, img := range images {
page := &manga.Page{Index: page.Index, IsSplitted: true, SplitPartIndex: uint16(i)}
wgConvertedPages.Add(1)
pagesChan <- manga.NewContainer(page, img, "N/A", true)
}
}(page)
}
wgPages.Wait()
wgConvertedPages.Wait()
close(pagesChan)
close(errChan)
var errList []error
for err := range errChan {
errList = append(errList, err)
}
var aggregatedError error = nil
if len(errList) > 0 {
aggregatedError = errors.Join(errList...)
}
slices.SortFunc(pages, func(a, b *manga.Page) int {
if a.Index == b.Index {
return int(b.SplitPartIndex - a.SplitPartIndex)
}
return int(b.Index - a.Index)
})
chapter.Pages = pages
runtime.GC()
return chapter, aggregatedError
}
func (converter *Converter) cropImage(img image.Image) ([]image.Image, error) {
bounds := img.Bounds()
height := bounds.Dy()
numParts := height / converter.cropHeight
if height%converter.cropHeight != 0 {
numParts++
}
parts := make([]image.Image, numParts)
for i := 0; i < numParts; i++ {
partHeight := converter.cropHeight
if i == numParts-1 {
partHeight = height - i*converter.cropHeight
}
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 {
return nil, fmt.Errorf("error cropping part %d: %v", i+1, err)
}
parts[i] = part
}
return parts, nil
}
func (converter *Converter) checkPageNeedsSplit(page *manga.Page, splitRequested bool) (bool, image.Image, string, error) {
reader := io.Reader(bytes.NewBuffer(page.Contents.Bytes()))
img, format, err := image.Decode(reader)
if err != nil {
return false, nil, format, err
}
bounds := img.Bounds()
height := bounds.Dy()
if height >= webpMaxHeight && !splitRequested {
return false, img, format, converterrors.NewPageIgnored(fmt.Sprintf("page %d is too tall [max: %dpx] to be converted to webp format", page.Index, webpMaxHeight))
}
return height >= converter.maxHeight && splitRequested, img, format, nil
}
func (converter *Converter) convertPage(container *manga.PageContainer, quality uint8) (*manga.PageContainer, error) {
if container.Format == "webp" {
return container, nil
}
if !container.IsToBeConverted {
return container, nil
}
converted, err := converter.convert(container.Image, uint(quality))
if err != nil {
return nil, err
}
container.Page.Contents = converted
container.Page.Extension = ".webp"
container.Page.Size = uint64(converted.Len())
return container, nil
}
// convert converts an image to the WebP format. It decodes the image from the input buffer,
// encodes it as a WebP file using the webp.Encode() function, and returns the resulting WebP
// file as a bytes.Buffer.
func (converter *Converter) convert(image image.Image, quality uint) (*bytes.Buffer, error) {
var buf bytes.Buffer
err := Encode(&buf, image, quality)
if err != nil {
return nil, err
}
return &buf, nil
}
-19
View File
@@ -1,19 +0,0 @@
package webp
import (
"github.com/kolesa-team/go-webp/encoder"
"github.com/kolesa-team/go-webp/webp"
"image"
"io"
)
func PrepareEncoder() error {
return nil
}
func Encode(w io.Writer, m image.Image, quality uint) error {
options, err := encoder.NewLossyEncoderOptions(encoder.PresetDefault, float32(quality))
if err != nil {
return err
}
return webp.Encode(w, m, options)
}
+35
View File
@@ -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
+33
View File
@@ -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.
+27
View File
@@ -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.
+44 -27
View File
@@ -1,39 +1,56 @@
module github.com/belphemur/CBZOptimizer/v2 module github.com/belphemur/CBZOptimizer/v2
go 1.23.0 go 1.26
require ( require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/kolesa-team/go-webp v1.0.4 github.com/belphemur/go-webpbin/v2 v2.1.0
github.com/oliamb/cutter v0.2.2 github.com/fsnotify/fsnotify v1.10.1
github.com/pablodz/inotifywaitgo v0.0.7 github.com/mholt/archives v0.1.5
github.com/samber/lo v1.47.0 github.com/rs/zerolog v1.35.1
github.com/spf13/cobra v1.8.1 github.com/samber/lo v1.53.0
github.com/spf13/viper v1.19.0 github.com/spf13/cobra v1.10.2
github.com/thediveo/enumflag/v2 v2.0.5 github.com/spf13/viper v1.21.0
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d github.com/stretchr/testify v1.11.1
golang.org/x/image v0.23.0 github.com/thediveo/enumflag/v2 v2.2.1
golang.org/x/image v0.44.0
) )
require ( require (
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect
github.com/hashicorp/hcl v1.0.0 // 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.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/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/minio/minlz v1.0.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect
go.uber.org/multierr v1.9.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.18.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/net v0.56.0 // indirect
gopkg.in/ini.v1 v1.67.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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+349 -77
View File
@@ -1,116 +1,388 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
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 h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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.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=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
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-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
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/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=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
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.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-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/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=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kolesa-team/go-webp v1.0.4 h1:wQvU4PLG/X7RS0vAeyhiivhLRoxfLVRlDq4I3frdxIQ= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/kolesa-team/go-webp v1.0.4/go.mod h1:oMvdivD6K+Q5qIIkVC2w4k2ZUnI1H+MyP7inwgWq9aA= 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.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=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/oliamb/cutter v0.2.2 h1:Lfwkya0HHNU1YLnGv2hTkzHfasrSMkgv4Dn+5rmlk3k= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/oliamb/cutter v0.2.2/go.mod h1:4BenG2/4GuRBDbVm/OPahDVqbrOemzpPiG5mi1iryBU= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/onsi/gomega v1.28.1 h1:MijcGUbfYuznzK/5R4CPNoUP/9Xvuo20sXfEm6XxoTA= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
github.com/onsi/gomega v1.28.1/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/pablodz/inotifywaitgo v0.0.7 h1:1ii49dGBnRn0t1Sz7RGZS6/NberPEDQprwKHN49Bv6U= github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag=
github.com/pablodz/inotifywaitgo v0.0.7/go.mod h1:OtzRCsYTJlIr+vAzlOtauTkfQ1c25ebFuXq8tbbf8cw= github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= 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/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.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.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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/thediveo/enumflag/v2 v2.0.5 h1:VJjvlAqUb6m6mxOrB/0tfBJI0Kvi9wJ8ulh38xK87i8= github.com/thediveo/enumflag/v2 v2.2.1 h1:sB6zJBA7G5Qk5tcCK5f25H8Rfern7idwTASdBxY4inQ=
github.com/thediveo/enumflag/v2 v2.0.5/go.mod h1:0NcG67nYgwwFsAvoQCmezG0J0KaIxZ0f7skg9eLq1DA= github.com/thediveo/enumflag/v2 v2.2.1/go.mod h1:Fa35DiSMi7oIXNc1VJPktJVlsk4NPW8dYY3Zjvhx+S4=
github.com/thediveo/success v1.0.1 h1:NVwUOwKUwaN8szjkJ+vsiM2L3sNBFscldoDJ2g2tAPg= github.com/thediveo/success v1.3.1 h1:SQ/ICN55yYxyEpgh0XGQwG+A4mMLGa2MYp+fxnIKoYs=
github.com/thediveo/success v1.0.1/go.mod h1:AZ8oUArgbIsCuDEWrzWNQHdKnPbDOLQsWOFj9ynwLt0= github.com/thediveo/success v1.3.1/go.mod h1:Wlj+S4i3x4pLZEO/OY/cEDfpChUoxw02BRYjcGjH+Zw=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
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/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/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=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
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=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/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=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/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=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/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=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/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=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+169
View File
@@ -0,0 +1,169 @@
package cbz
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
"github.com/rs/zerolog/log"
)
// resolvePageName picks the final archive entry name for a page.
//
// Order of resolution:
// 1. When page.OriginalName is set (keep-filenames mode), use its stem with
// the current page.Extension. Split pages append the -NN suffix so all
// parts of the same source still land in the archive.
// 2. If that final name has already been used in this archive, fall back to
// the indexed form (stem + "_%04d" + extension) so the zip stays valid.
// 3. Otherwise (OriginalName is empty), use the historical %04d / %04d-NN
// sequential naming.
//
// usedNames tracks every name already chosen for this archive and is mutated
// in place so the caller can keep a single map across the whole chapter.
func resolvePageName(page *manga.PageFile, usedNames map[string]struct{}) string {
if page.OriginalName != "" {
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
var candidate string
if page.IsSplitted {
candidate = fmt.Sprintf("%s-%02d%s", stem, page.SplitPartIndex, page.Extension)
} else {
candidate = stem + page.Extension
}
if _, taken := usedNames[candidate]; !taken {
usedNames[candidate] = struct{}{}
return candidate
}
// Collision: fall back to the indexed form so the entry still gets
// written, but make it visibly distinct from the preserved one.
// Loop in case the fallback name is itself already taken (e.g. a
// source file literally named "cover_0001.png" colliding with a
// page-index 1 fallback "cover_0001.webp").
for suffix := page.Index; ; suffix++ {
fallback := fmt.Sprintf("%s_%04d%s", stem, suffix, page.Extension)
if _, taken := usedNames[fallback]; !taken {
usedNames[fallback] = struct{}{}
return fallback
}
}
}
if page.IsSplitted {
name := fmt.Sprintf("%04d-%02d%s", page.Index, page.SplitPartIndex, page.Extension)
usedNames[name] = struct{}{}
return name
}
name := fmt.Sprintf("%04d%s", page.Index, page.Extension)
usedNames[name] = struct{}{}
return name
}
// WriteChapterToCBZ creates a CBZ file from a Chapter by streaming page files
// from disk directly into the zip archive. No image data is held in memory.
func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) (err error) {
log.Debug().
Str("chapter_file", chapter.FilePath).
Str("output_path", outputFilePath).
Int("page_count", len(chapter.Pages)).
Bool("is_converted", chapter.IsConverted).
Msg("Starting CBZ file creation")
// 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")
return fmt.Errorf("failed to create .cbz file: %w", err)
}
defer errs.Capture(&err, zipFile.Close, "failed to close .cbz file")
// Create ZIP writer
zipWriter := zip.NewWriter(zipFile)
defer errs.Capture(&err, zipWriter.Close, "failed to close .cbz writer")
// Write each page to the archive by streaming from disk.
// Final name resolution: when a page carries an OriginalName (recorded by
// ExtractChapter when --keep-filenames is on), preserve its stem and only
// swap the extension to the current page.Extension. Duplicates in the
// archive fall back to the indexed naming so the output stays a valid zip.
usedNames := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
fileName := resolvePageName(page, usedNames)
log.Debug().
Str("output_path", outputFilePath).
Uint16("page_index", page.Index).
Str("filename", fileName).
Str("source", page.FilePath).
Msg("Writing page to CBZ 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("filename", fileName).Err(err).Msg("Failed to create file in CBZ archive")
return fmt.Errorf("failed to create file in .cbz: %w", err)
}
// Stream the page file from disk into the archive
pageFile, err := os.Open(page.FilePath)
if err != nil {
log.Error().Str("filename", fileName).Str("source", page.FilePath).Err(err).Msg("Failed to open page file")
return fmt.Errorf("failed to open page file: %w", err)
}
bytesWritten, err := io.Copy(fileWriter, pageFile)
closeErr := pageFile.Close()
if err != nil {
log.Error().Str("filename", fileName).Err(err).Msg("Failed to write page contents")
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("filename", fileName).
Int64("bytes_written", bytesWritten).
Msg("Page written successfully")
}
// Write ComicInfo.xml if present
if chapter.ComicInfoXml != "" {
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 {
return fmt.Errorf("failed to create ComicInfo.xml in .cbz: %w", err)
}
_, err = comicInfoWriter.Write([]byte(chapter.ComicInfoXml))
if err != nil {
return fmt.Errorf("failed to write ComicInfo.xml: %w", err)
}
}
// Set zip comment for converted chapters
if chapter.IsConverted {
comment := fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", chapter.ConvertedTime)
err = zipWriter.SetComment(comment)
if err != nil {
return fmt.Errorf("failed to write comment: %w", err)
}
}
log.Debug().Str("output_path", outputFilePath).Msg("CBZ file creation completed")
return nil
}
+276
View File
@@ -0,0 +1,276 @@
package cbz
import (
"archive/zip"
"fmt"
"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()
// 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 func(t *testing.T, dir string) *manga.Chapter
expectedFiles []string
expectedComment string
}{
{
name: "Single page, ComicInfo, converted",
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,
}
},
expectedFiles: []string{"0000.jpg", "ComicInfo.xml"},
expectedComment: fmt.Sprintf("%s\nThis chapter has been converted by CBZOptimizer.", currentTime),
},
{
name: "Single page, no ComicInfo",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".jpg",
FilePath: createTempPage(t, dir, "image data", ".jpg"),
},
},
}
},
expectedFiles: []string{"0000.jpg"},
},
{
name: "Multiple pages with ComicInfo",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: createTempPage(t, dir, "image data 1", ".jpg")},
{Index: 1, Extension: ".jpg", FilePath: createTempPage(t, dir, "image data 2", ".jpg")},
},
ComicInfoXml: "<Series>Boundless Necromancer</Series>",
}
},
expectedFiles: []string{"0000.jpg", "0001.jpg", "ComicInfo.xml"},
},
{
name: "Split page",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".jpg",
FilePath: createTempPage(t, dir, "split image data", ".jpg"),
IsSplitted: true,
SplitPartIndex: 1,
},
},
}
},
expectedFiles: []string{"0000-01.jpg"},
},
{
// --keep-filenames: the page stem is reused as-is with the
// current Extension, regardless of the source extension.
name: "Preserve original filename, extension swap",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".webp",
FilePath: createTempPage(t, dir, "image data", ".webp"),
OriginalName: "page01.png",
},
},
}
},
expectedFiles: []string{"page01.webp"},
},
{
// --keep-filenames for a split page: stem is preserved, the
// split part index still gets appended to keep parts distinct.
name: "Split page with OriginalName",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".webp",
FilePath: createTempPage(t, dir, "split data", ".webp"),
IsSplitted: true,
SplitPartIndex: 2,
OriginalName: "tall.png",
},
},
}
},
expectedFiles: []string{"tall-02.webp"},
},
{
// Two pages share the same OriginalName (rare but possible
// with subdirectories): the first keeps the stem, the second
// falls back to the indexed form to avoid duplicate zip
// entries.
name: "Duplicate OriginalName falls back to indexed form",
chapter: func(t *testing.T, dir string) *manga.Chapter {
return &manga.Chapter{
Pages: []*manga.PageFile{
{
Index: 0,
Extension: ".webp",
FilePath: createTempPage(t, dir, "first", ".webp"),
OriginalName: "cover.png",
},
{
Index: 1,
Extension: ".webp",
FilePath: createTempPage(t, dir, "second", ".webp"),
OriginalName: "cover.png",
},
},
}
},
expectedFiles: []string{"cover.webp", "cover_0001.webp"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create temp dir for page files
pageDir := t.TempDir()
chapter := tc.chapter(t, pageDir)
// Create temp file for output CBZ
tempFile, err := os.CreateTemp("", "*.cbz")
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
_ = tempFile.Close()
defer errs.CaptureGeneric(&err, os.Remove, tempFile.Name(), "failed to remove temporary file")
// Write chapter
err = WriteChapterToCBZ(chapter, tempFile.Name())
if err != nil {
t.Fatalf("Failed to write chapter to CBZ: %v", err)
}
// Verify the archive
r, err := zip.OpenReader(tempFile.Name())
if err != nil {
t.Fatalf("Failed to open CBZ file: %v", err)
}
defer func() { _ = r.Close() }()
var filesInArchive []string
for _, f := range r.File {
filesInArchive = append(filesInArchive, f.Name)
}
for _, expectedFile := range tc.expectedFiles {
found := false
for _, actualFile := range filesInArchive {
if actualFile == expectedFile {
found = true
break
}
}
if !found {
t.Errorf("Expected file %s not found in archive", expectedFile)
}
}
if tc.expectedComment != "" && r.Comment != tc.expectedComment {
t.Errorf("Expected comment %s, but found %s", tc.expectedComment, r.Comment)
}
if len(filesInArchive) != len(tc.expectedFiles) {
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)
}
}
+419
View File
@@ -0,0 +1,419 @@
package cbz
import (
"archive/zip"
"bufio"
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/araddon/dateparse"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
"github.com/mholt/archives"
"github.com/rs/zerolog/log"
)
// supportedImageExtensions contains file extensions considered valid image pages.
var supportedImageExtensions = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
".bmp": true,
".tiff": true,
".tif": true,
}
// parseConvertedComment checks if a zip comment's first line is a parseable date,
// indicating the archive was already converted. Returns true and the parsed time if so.
func parseConvertedComment(comment string) bool {
if comment == "" {
return false
}
scanner := bufio.NewScanner(strings.NewReader(comment))
if scanner.Scan() {
_, err := dateparse.ParseAny(scanner.Text())
return err == nil
}
return false
}
// parseConvertedCommentTime parses the converted timestamp from a zip comment.
// Returns the time and true if the comment indicates conversion, otherwise zero time and false.
func parseConvertedCommentTime(comment string) (time.Time, bool) {
if comment == "" {
return time.Time{}, false
}
scanner := bufio.NewScanner(strings.NewReader(comment))
if scanner.Scan() {
t, err := dateparse.ParseAny(scanner.Text())
if err == nil {
return t, true
}
}
return time.Time{}, false
}
// IsAlreadyConverted performs a fast check to see if the archive is already
// converted without extracting any image data. It reads only the zip comment
// and metadata files (converted.txt) to determine conversion status.
func IsAlreadyConverted(ctx context.Context, filePath string) (converted bool, err error) {
log.Debug().Str("file_path", filePath).Msg("Checking if already converted")
pathLower := strings.ToLower(filepath.Ext(filePath))
if pathLower == ".cbz" {
r, err := zip.OpenReader(filePath)
if err != nil {
return false, fmt.Errorf("failed to open CBZ for conversion check: %w", err)
}
defer errs.Capture(&err, r.Close, "failed to close zip reader")
// Check zip comment
if parseConvertedComment(r.Comment) {
log.Debug().Str("file_path", filePath).Msg("Already converted (zip comment)")
return true, nil
}
// Check for converted.txt inside the archive
for _, f := range r.File {
if strings.ToLower(filepath.Base(f.Name)) == "converted.txt" {
rc, err := f.Open()
if err != nil {
continue
}
scanner := bufio.NewScanner(rc)
if scanner.Scan() {
_, parseErr := dateparse.ParseAny(scanner.Text())
_ = rc.Close()
if parseErr == nil {
log.Debug().Str("file_path", filePath).Msg("Already converted (converted.txt)")
return true, nil
}
} else {
_ = rc.Close()
}
}
}
}
// For CBR files, we need to use the archives library to check
if pathLower == ".cbr" {
fsys, err := archives.FileSystem(ctx, filePath, nil)
if err != nil {
return false, fmt.Errorf("failed to open archive: %w", err)
}
var converted bool
_ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
if strings.ToLower(filepath.Base(path)) == "converted.txt" {
file, err := fsys.Open(path)
if err != nil {
return nil
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
if scanner.Scan() {
_, err := dateparse.ParseAny(scanner.Text())
if err == nil {
converted = true
return fs.SkipAll
}
}
}
return nil
})
return converted, nil
}
return false, nil
}
// ExtractChapter extracts an archive (CBZ/CBR) to a temp directory on disk.
// Pages are streamed directly to files — no image data is held in memory.
// Returns a Chapter with PageFile entries pointing to extracted files.
//
// When keepFilenames is true, each PageFile has its OriginalName set to the
// base filename of the entry inside the archive. Downstream code uses that
// name to preserve the original page identity in the output CBZ (with the
// extension swapped for format conversion). When false, OriginalName stays
// empty and the sequential %04d naming convention is used instead.
func ExtractChapter(ctx context.Context, filePath string, keepFilenames bool) (*manga.Chapter, error) {
log.Debug().Str("file_path", filePath).Msg("Extracting chapter to disk")
// Create temp directory for extraction
tempDir, err := os.MkdirTemp("", "cbzoptimizer-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
inputDir := filepath.Join(tempDir, "input")
if err := os.MkdirAll(inputDir, 0755); err != nil {
_ = os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to create input directory: %w", err)
}
chapter := &manga.Chapter{
FilePath: filePath,
TempDir: tempDir,
}
// usedOriginalStems tracks the STEM (filename without extension) of every
// OriginalName handed out in this chapter so stems stay unique across
// pages. Tracking stems (not full names) prevents a downstream race in
// pkg/converter/webp: the converter strips the OriginalName extension
// and appends a target-format suffix (e.g. ".webp"), so two pages that
// share a stem but differ in extension — e.g. a/page.png and b/page.jpg
// (legal in zip) — would otherwise race on a single shared intermediate
// output path. The map covers both same-stem-same-ext collisions (e.g.
// a/page.png + b/page.png) and same-stem-different-ext collisions (e.g.
// a/page.png + b/page.jpg). Allocated lazily so the keepFilenames=false
// path stays allocation-free.
var usedOriginalStems map[string]struct{}
if keepFilenames {
usedOriginalStems = make(map[string]struct{})
}
// For CBZ files, read metadata from zip comment
pathLower := strings.ToLower(filepath.Ext(filePath))
if pathLower == ".cbz" {
r, err := zip.OpenReader(filePath)
if err == nil {
if t, ok := parseConvertedCommentTime(r.Comment); ok {
chapter.IsConverted = true
chapter.ConvertedTime = t
}
_ = r.Close()
}
}
// Extract files using the archives library (supports both CBZ and CBR)
fsys, err := archives.FileSystem(ctx, filePath, nil)
if err != nil {
_ = os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to open archive: %w", err)
}
err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
// Check for context cancellation during extraction
select {
case <-ctx.Done():
return ctx.Err()
default:
}
ext := strings.ToLower(filepath.Ext(path))
fileName := strings.ToLower(filepath.Base(path))
// Skip OS-specific metadata files and junk
if isJunkFile(path) {
log.Debug().Str("file_path", filePath).Str("skipped", path).Msg("Skipping junk file")
return nil
}
// Handle ComicInfo.xml
if ext == ".xml" && fileName == "comicinfo.xml" {
file, err := fsys.Open(path)
if err != nil {
return fmt.Errorf("failed to open ComicInfo.xml: %w", err)
}
defer func() { _ = file.Close() }()
xmlContent, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read ComicInfo.xml: %w", err)
}
chapter.ComicInfoXml = string(xmlContent)
log.Debug().Str("file_path", filePath).Int("xml_size", len(xmlContent)).Msg("ComicInfo.xml loaded")
return nil
}
// Handle converted.txt (check conversion status)
if !chapter.IsConverted && ext == ".txt" && fileName == "converted.txt" {
file, err := fsys.Open(path)
if err != nil {
return fmt.Errorf("failed to open converted.txt: %w", err)
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
if scanner.Scan() {
t, err := dateparse.ParseAny(scanner.Text())
if err == nil {
chapter.IsConverted = true
chapter.ConvertedTime = t
}
}
return nil
}
// Only extract supported image files
if !supportedImageExtensions[ext] {
log.Debug().Str("file_path", filePath).Str("skipped", path).Str("ext", ext).Msg("Skipping non-image file")
return nil
}
// Extract image file to disk
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,
}
if keepFilenames {
page.OriginalName = allocateUniqueBaseName(archiveBaseName(path), pageIndex, usedOriginalStems)
}
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 {
_ = os.RemoveAll(tempDir)
return nil, fmt.Errorf("failed to extract archive: %w", err)
}
log.Debug().
Str("file_path", filePath).
Int("pages_extracted", len(chapter.Pages)).
Bool("is_converted", chapter.IsConverted).
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
}
// archiveBaseName returns the bare base name of an archive entry, with
// Windows-style backslash separators normalized to forward slashes before
// the last-segment split. The archives library surfaces zip entry names
// verbatim, so a name like "..\evil.png" (common from Windows-created
// archives) would otherwise pass through filepath.Base unchanged on non-
// Windows hosts and be written into the output CBZ as a path-traversal
// shape. Normalizing the separators first and then taking the segment
// after the final one guarantees the returned name is a bare base name
// with no directory components, no backslashes, and no forward slashes —
// which is what the downstream writer expects as a safe ZIP entry name.
//
// This intentionally does NOT touch filepath.Base calls in isJunkFile or
// the ComicInfo.xml / converted.txt detection: those comparisons just
// fail to match, and the entry then falls through to the non-image
// extension filter, so no traversal-shaped data escapes the loader.
func archiveBaseName(path string) string {
normalized := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(normalized, "/"); idx >= 0 {
return normalized[idx+1:]
}
return normalized
}
// allocateUniqueBaseName returns baseName when its stem is not already taken
// by an earlier page in this chapter, or a collision-resolved variant
// otherwise.
//
// Stem-uniqueness contract: the function guarantees that the STEM
// (filename without extension) of the returned OriginalName is unique
// among all names previously handed out from this chapter. That contract
// matches what downstream code relies on —
// pkg/converter/webp.intermediatePageName strips the OriginalName's
// extension and appends a target-format suffix (e.g. ".webp"), so two
// OriginalNames that share a stem would otherwise race on a single
// intermediate output path. The stem is computed the same way downstream
// does it (strings.TrimSuffix(name, filepath.Ext(name))) to keep both
// definitions in lock-step.
//
// On collision the resolved name matches the existing fallback style used
// by cbz_creator's resolvePageName: stem + "_%04d" + extension. The loop
// checks the CANDIDATE's stem (not the full generated name) so two files
// that share a stem but differ in extension — e.g. "page.png" and
// "page.jpg" — resolve to, e.g., "page.png" and "page_0001.jpg", each
// preserving its original extension. The starting suffix is pageIndex so
// the resolved name stays in the same neighborhood as the page's archive
// position. If the candidate's stem is itself already taken
// (astronomically rare: the source archive would need both the original
// name and a matching indexed name), the suffix is incremented until a
// free stem is found. The chosen name's stem is recorded in usedStems so
// subsequent calls cannot pick it again.
func allocateUniqueBaseName(baseName string, pageIndex uint16, usedStems map[string]struct{}) string {
ext := filepath.Ext(baseName)
stem := strings.TrimSuffix(baseName, ext)
if _, taken := usedStems[stem]; !taken {
usedStems[stem] = struct{}{}
return baseName
}
for suffix := int(pageIndex); ; suffix++ {
candidateStem := fmt.Sprintf("%s_%04d", stem, suffix)
if _, taken := usedStems[candidateStem]; !taken {
usedStems[candidateStem] = struct{}{}
return candidateStem + ext
}
}
}
// LoadChapter extracts the chapter from a CBZ/CBR file to disk.
// It delegates to ExtractChapter with keepFilenames=false and always
// extracts all pages. Use IsAlreadyConverted for a fast conversion status
// check without extraction.
func LoadChapter(filePath string) (*manga.Chapter, error) {
return ExtractChapter(context.Background(), filePath, false)
}
+671
View File
@@ -0,0 +1,671 @@
package cbz
import (
"archive/zip"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadChapter(t *testing.T) {
type testCase struct {
name string
filePath string
expectedPages int
expectedSeries string
expectedConversion bool
}
testCases := []testCase{
{
name: "Original Chapter CBZ",
filePath: "../../testdata/Chapter 128.cbz",
expectedPages: 14,
expectedSeries: "<Series>The Knight King Who Returned with a God</Series>",
expectedConversion: false,
},
{
name: "Original Chapter CBR",
filePath: "../../testdata/Chapter 1.cbr",
expectedPages: 16,
expectedSeries: "<Series>Boundless Necromancer</Series>",
expectedConversion: false,
},
{
name: "Converted Chapter",
filePath: "../../testdata/Chapter 10_converted.cbz",
expectedPages: 107,
expectedSeries: "<Series>Boundless Necromancer</Series>",
expectedConversion: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
chapter, err := LoadChapter(tc.filePath)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
assert.Equal(t, tc.expectedPages, len(chapter.Pages))
assert.Contains(t, chapter.ComicInfoXml, tc.expectedSeries)
assert.Equal(t, tc.expectedConversion, chapter.IsConverted)
// Verify pages are on disk
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.FilePath, "Page %d has no file path", page.Index)
}
})
}
}
func TestIsAlreadyConverted(t *testing.T) {
testCases := []struct {
name string
filePath string
expected bool
}{
{
name: "Converted CBZ",
filePath: "../../testdata/Chapter 10_converted.cbz",
expected: true,
},
{
name: "Original CBZ",
filePath: "../../testdata/Chapter 128.cbz",
expected: false,
},
{
name: "Original CBR",
filePath: "../../testdata/Chapter 1.cbr",
expected: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := IsAlreadyConverted(context.Background(), tc.filePath)
require.NoError(t, err)
assert.Equal(t, tc.expected, result)
})
}
}
func TestIsAlreadyConverted_NonexistentFile(t *testing.T) {
_, err := IsAlreadyConverted(context.Background(), "/nonexistent/file.cbz")
require.Error(t, err)
}
func TestIsAlreadyConverted_InvalidExtension(t *testing.T) {
// Create a temp file with unsupported extension
tmpFile, err := os.CreateTemp("", "test-*.txt")
require.NoError(t, err)
defer func() { _ = os.Remove(tmpFile.Name()) }()
_ = tmpFile.Close()
result, err := IsAlreadyConverted(context.Background(), tmpFile.Name())
require.NoError(t, err)
assert.False(t, result, "Expected false for unsupported extension")
}
func TestIsAlreadyConverted_CBZWithConvertedComment(t *testing.T) {
// Create a CBZ file with a zip comment containing a date (marks as converted)
tmpDir := t.TempDir()
cbzPath := filepath.Join(tmpDir, "test.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
_ = w.SetComment(time.Now().Format(time.RFC3339))
// Add a dummy file
fw, err := w.Create("page.webp")
require.NoError(t, err)
_, _ = fw.Write([]byte("dummy"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.True(t, result, "Expected CBZ with date comment to be detected as converted")
}
func TestIsAlreadyConverted_CBZWithNonDateComment(t *testing.T) {
tmpDir := t.TempDir()
cbzPath := filepath.Join(tmpDir, "test.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
_ = w.SetComment("this is not a date")
fw, err := w.Create("page.jpg")
require.NoError(t, err)
_, _ = fw.Write([]byte("dummy"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.False(t, result, "Expected CBZ with non-date comment to NOT be detected as converted")
}
func TestExtractChapter_NonexistentFile(t *testing.T) {
_, err := ExtractChapter(context.Background(), "/nonexistent/file.cbz", false)
require.Error(t, err)
}
func TestExtractChapter_PageExtensions(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
// All pages should have valid image extensions
validExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true}
for _, page := range chapter.Pages {
assert.True(t, validExts[page.Extension], "Page %d has unexpected extension: %s", page.Index, page.Extension)
}
}
func TestExtractChapter_PagesHaveSequentialIndices(t *testing.T) {
t.Run("default sequential naming", func(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
for i, page := range chapter.Pages {
assert.Equal(t, uint16(i), page.Index)
assert.Empty(t, page.OriginalName, "keep-filenames disabled: OriginalName must stay empty")
}
})
t.Run("keep-filenames records OriginalName", func(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.NotEmpty(t, chapter.Pages)
for i, page := range chapter.Pages {
assert.Equal(t, uint16(i), page.Index)
assert.NotEmpty(t, page.OriginalName, "keep-filenames enabled: OriginalName must be set for every page")
// OriginalName must be a bare base name (no directory prefix), even
// when the source archive nests pages in subdirectories.
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"OriginalName should be a base filename with no path components")
}
})
}
// writeCollisionCBZ builds a CBZ containing the same image base name in two
// different subdirectories plus a normal unique page. The test fixture is
// specifically crafted to reproduce the race fixed in ExtractChapter: when
// keep-filenames naively copied filepath.Base(path) into OriginalName, both
// a/page.png and b/page.png would have ended up with the same stem and the
// WebP converter would have raced on a single shared intermediate path.
func writeCollisionCBZ(t *testing.T, dir string) string {
t.Helper()
cbzPath := filepath.Join(dir, "collisions.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
for _, entry := range []string{"a/page.png", "b/page.png", "cover.png"} {
fw, err := w.Create(entry)
require.NoError(t, err)
_, err = fw.Write([]byte("dummy image bytes for " + entry))
require.NoError(t, err)
}
require.NoError(t, w.Close())
require.NoError(t, f.Close())
return cbzPath
}
func TestExtractChapter_KeepFilenames_DeduplicatesCollidingNames(t *testing.T) {
tmpDir := t.TempDir()
cbzPath := writeCollisionCBZ(t, tmpDir)
t.Run("keep-filenames resolves colliding base names", func(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), cbzPath, true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3, "expected 3 pages: two colliding + one unique")
// All OriginalNames must be non-empty bare base names and unique.
seen := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.OriginalName, "page %d must have OriginalName set", page.Index)
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"page %d OriginalName should be a bare base filename", page.Index)
if _, dup := seen[page.OriginalName]; dup {
t.Errorf("duplicate OriginalName across pages: %q", page.OriginalName)
}
seen[page.OriginalName] = struct{}{}
}
// The unique page keeps its original name; the two colliding pages
// must come out as one bare "page.png" and one indexed variant.
assert.Contains(t, seen, "page.png", "one colliding page should keep the bare page.png name")
assert.Contains(t, seen, "cover.png", "the unique page should keep its original name")
indexedPattern := regexp.MustCompile(`^page_\d{4}\.png$`)
indexedCount := 0
for name := range seen {
if indexedPattern.MatchString(name) {
indexedCount++
}
}
assert.Equal(t, 1, indexedCount,
"exactly one colliding page should be resolved to the page_NNNN.png pattern, got %v", seen)
})
t.Run("keep-filenames off leaves OriginalName empty (regression guard)", func(t *testing.T) {
// Same collision-prone archive, but with keep-filenames off: the
// dedup logic must not run, so OriginalName stays empty for every
// page. This is the historical default and must not regress.
chapter, err := ExtractChapter(context.Background(), cbzPath, false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3)
for _, page := range chapter.Pages {
assert.Empty(t, page.OriginalName, "page %d must have empty OriginalName when keep-filenames is off", page.Index)
}
})
}
// writeBackslashCBZ builds a CBZ whose entry names mix Windows-style
// backslash separators with a normal forward-slash page. The fixture
// reproduces the CodeRabbit finding for PR #217: archive entries like
// "subdir\page.png" and "..\evil.png" must not surface verbatim as the
// OriginalName written into the output CBZ, since Windows ZIP consumers
// can interpret backslashes as path traversal.
func writeBackslashCBZ(t *testing.T, dir string) string {
t.Helper()
cbzPath := filepath.Join(dir, "backslash.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
// archive/zip stores entry names verbatim, so the bytes on the wire
// carry the backslashes through to fs.WalkDir in ExtractChapter.
for _, entry := range []string{`subdir\page.png`, `..\evil.png`, "cover.png"} {
fw, err := w.Create(entry)
require.NoError(t, err)
_, err = fw.Write([]byte("dummy image bytes for " + entry))
require.NoError(t, err)
}
require.NoError(t, w.Close())
require.NoError(t, f.Close())
return cbzPath
}
func TestExtractChapter_KeepFilenames_NormalizesWindowsSeparators(t *testing.T) {
tmpDir := t.TempDir()
cbzPath := writeBackslashCBZ(t, tmpDir)
chapter, err := ExtractChapter(context.Background(), cbzPath, true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3, "expected 3 pages: two backslash entries + one normal")
// Every OriginalName must be a bare base name: no backslashes, no
// forward slashes, and unique across the chapter.
seen := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.OriginalName, "page %d must have OriginalName set", page.Index)
assert.NotContains(t, page.OriginalName, `\`, "page %d OriginalName must not contain backslash, got %q", page.Index, page.OriginalName)
assert.NotContains(t, page.OriginalName, "/", "page %d OriginalName must not contain forward slash, got %q", page.Index, page.OriginalName)
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"page %d OriginalName should be a bare base filename", page.Index)
if _, dup := seen[page.OriginalName]; dup {
t.Errorf("duplicate OriginalName across pages: %q", page.OriginalName)
}
seen[page.OriginalName] = struct{}{}
}
// The two backslash entries must collapse to their bare bases; the
// "..\evil.png" entry's leading "..\evil" must not be carried through.
assert.Contains(t, seen, "page.png", "subdir\\page.png should normalize to bare page.png")
assert.Contains(t, seen, "evil.png", "..\\evil.png should normalize to bare evil.png")
assert.Contains(t, seen, "cover.png", "cover.png should pass through unchanged")
}
// writeSameStemDifferentExtCBZ builds a CBZ containing two same-stem pages
// with different extensions plus a distinct unique page. The fixture
// reproduces the CodeRabbit finding for PR #217: a/page.png and b/page.jpg
// share a stem but do NOT share a full filename, so naive full-name
// deduplication lets both OriginalNames through and the WebP converter then
// races on a single shared intermediate output path (outputDir/page.webp).
// The fix flips the dedup tracking to STEM-uniqueness, so one of the
// colliding pair must come out as a bare "page.<ext>" and the other as
// "page_NNNN.<ext>" — each preserving its original extension.
func writeSameStemDifferentExtCBZ(t *testing.T, dir string) string {
t.Helper()
cbzPath := filepath.Join(dir, "same_stem.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
for _, entry := range []string{"a/page.png", "b/page.jpg", "cover.png"} {
fw, err := w.Create(entry)
require.NoError(t, err)
_, err = fw.Write([]byte("dummy image bytes for " + entry))
require.NoError(t, err)
}
require.NoError(t, w.Close())
require.NoError(t, f.Close())
return cbzPath
}
func TestExtractChapter_KeepFilenames_DeduplicatesSameStemDifferentExtensions(t *testing.T) {
tmpDir := t.TempDir()
cbzPath := writeSameStemDifferentExtCBZ(t, tmpDir)
chapter, err := ExtractChapter(context.Background(), cbzPath, true)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
require.Len(t, chapter.Pages, 3, "expected 3 pages: two same-stem + one unique")
// Every OriginalName must be a bare base name; full names AND stems
// must each be unique across the chapter. The stem-uniqueness check is
// the contract the WebP converter relies on (it strips the extension
// and appends ".webp" when computing intermediatePageName).
seenNames := make(map[string]struct{}, len(chapter.Pages))
seenStems := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
assert.NotEmpty(t, page.OriginalName, "page %d must have OriginalName set", page.Index)
assert.Equal(t, filepath.Base(page.OriginalName), page.OriginalName,
"page %d OriginalName should be a bare base filename", page.Index)
if _, dup := seenNames[page.OriginalName]; dup {
t.Errorf("duplicate OriginalName full name across pages: %q", page.OriginalName)
}
seenNames[page.OriginalName] = struct{}{}
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
if _, dup := seenStems[stem]; dup {
t.Errorf("duplicate OriginalName stem across pages: %q (from %q)", stem, page.OriginalName)
}
seenStems[stem] = struct{}{}
}
// The unique page keeps its original name untouched.
assert.Contains(t, seenNames, "cover.png", "the unique page should keep its original name")
// The colliding pair must split into one bare "page.<ext>" and one
// "page_NNNN.<ext>". The extensions must differ — each must keep the
// extension of the archive entry it came from. Walk order is not
// guaranteed, so the test checks both shapes regardless of which
// entry comes first.
barePattern := regexp.MustCompile(`^page\.(png|jpg)$`)
indexedPattern := regexp.MustCompile(`^page_\d{4}\.(png|jpg)$`)
var bareExt, indexedExt string
for name := range seenNames {
if barePattern.MatchString(name) {
bareExt = filepath.Ext(name)
}
if indexedPattern.MatchString(name) {
indexedExt = filepath.Ext(name)
}
}
assert.NotEmpty(t, bareExt,
"expected one bare page.<ext> OriginalName from the colliding pair, got %v", seenNames)
assert.NotEmpty(t, indexedExt,
"expected one page_NNNN.<ext> OriginalName from the colliding pair, got %v", seenNames)
assert.NotEqual(t, bareExt, indexedExt,
"the bare and indexed variants must keep their original different extensions, got bare=%q indexed=%q",
bareExt, indexedExt)
}
func TestExtractChapter_Cleanup(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
require.NoError(t, err)
tempDir := chapter.TempDir
// Verify temp dir exists
assert.DirExists(t, tempDir)
// Cleanup should remove temp dir
err = chapter.Cleanup()
require.NoError(t, err)
assert.NoDirExists(t, tempDir)
}
func TestExtractChapter_CBR(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 1.cbr", false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
assert.Len(t, chapter.Pages, 16)
// All page files should exist on disk
for _, page := range chapter.Pages {
assert.FileExists(t, page.FilePath)
}
}
func TestExtractChapter_ConvertedStatus(t *testing.T) {
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 10_converted.cbz", false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
assert.True(t, chapter.IsConverted, "Expected converted chapter to have IsConverted = true")
assert.False(t, chapter.ConvertedTime.IsZero(), "Expected non-zero ConvertedTime for converted chapter")
}
func TestWriteChapterToCBZ_EmptyChapter(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "empty.cbz")
chapter := &manga.Chapter{
FilePath: filepath.Join(tmpDir, "source.cbz"),
TempDir: tmpDir,
Pages: []*manga.PageFile{},
}
err := WriteChapterToCBZ(chapter, outputPath)
require.NoError(t, err)
// Verify the file is a valid zip
r, err := zip.OpenReader(outputPath)
require.NoError(t, err)
_ = r.Close()
}
func TestWriteChapterToCBZ_WithComicInfo(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "output.cbz")
inputDir := filepath.Join(tmpDir, "input")
_ = os.MkdirAll(inputDir, 0755)
// Create a dummy page file
pagePath := filepath.Join(inputDir, "0000.jpg")
_ = os.WriteFile(pagePath, []byte("fake image data"), 0644)
chapter := &manga.Chapter{
FilePath: filepath.Join(tmpDir, "source.cbz"),
TempDir: tmpDir,
ComicInfoXml: `<?xml version="1.0"?><ComicInfo><Series>Test</Series></ComicInfo>`,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: pagePath},
},
}
chapter.SetConverted()
err := WriteChapterToCBZ(chapter, outputPath)
require.NoError(t, err)
// Verify ComicInfo.xml is present
r, err := zip.OpenReader(outputPath)
require.NoError(t, err)
defer func() { _ = r.Close() }()
foundComicInfo := false
for _, f := range r.File {
if f.Name == "ComicInfo.xml" {
foundComicInfo = true
}
}
assert.True(t, foundComicInfo, "ComicInfo.xml not found in output CBZ")
// Verify zip comment has converted timestamp
assert.NotEmpty(t, r.Comment, "Expected zip comment with conversion timestamp")
}
func TestWriteChapterToCBZ_InvalidPagePath(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "output.cbz")
chapter := &manga.Chapter{
FilePath: filepath.Join(tmpDir, "source.cbz"),
TempDir: tmpDir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: "/nonexistent/page.jpg"},
},
}
err := WriteChapterToCBZ(chapter, outputPath)
require.Error(t, err)
}
func TestIsAlreadyConverted_CBZWithConvertedTxt(t *testing.T) {
// Create a CBZ with converted.txt inside (no zip comment)
tmpDir := t.TempDir()
cbzPath := filepath.Join(tmpDir, "test.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
// Add converted.txt with a date
fw, err := w.Create("converted.txt")
require.NoError(t, err)
_, _ = fw.Write([]byte(time.Now().Format(time.RFC3339)))
// Add a dummy page
fw, err = w.Create("page.webp")
require.NoError(t, err)
_, _ = fw.Write([]byte("dummy"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.True(t, result, "Expected CBZ with converted.txt to be detected as converted")
}
func TestIsAlreadyConverted_CBZWithInvalidConvertedTxt(t *testing.T) {
// Create a CBZ with converted.txt that has invalid date
tmpDir := t.TempDir()
cbzPath := filepath.Join(tmpDir, "test.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
fw, err := w.Create("converted.txt")
require.NoError(t, err)
_, _ = fw.Write([]byte("not a valid date"))
_ = w.Close()
_ = f.Close()
result, err := IsAlreadyConverted(context.Background(), cbzPath)
require.NoError(t, err)
assert.False(t, result, "Expected CBZ with invalid date in converted.txt to NOT be detected as converted")
}
func TestExtractChapter_WithConvertedTxt(t *testing.T) {
// Create a CBZ with converted.txt inside
tmpDir := t.TempDir()
cbzPath := filepath.Join(tmpDir, "test.cbz")
f, err := os.Create(cbzPath)
require.NoError(t, err)
w := zip.NewWriter(f)
fw, err := w.Create("converted.txt")
require.NoError(t, err)
convertedTime := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)
_, _ = fw.Write([]byte(convertedTime.Format(time.RFC3339)))
fw, err = w.Create("page001.jpg")
require.NoError(t, err)
_, _ = fw.Write([]byte("fake image"))
_ = w.Close()
_ = f.Close()
chapter, err := ExtractChapter(context.Background(), cbzPath, false)
require.NoError(t, err)
defer func() { _ = chapter.Cleanup() }()
assert.True(t, chapter.IsConverted, "Expected chapter with converted.txt to be marked as converted")
assert.False(t, chapter.ConvertedTime.IsZero(), "Expected non-zero ConvertedTime")
}
func TestWriteChapterToCBZ_MultiplePages(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "output.cbz")
inputDir := filepath.Join(tmpDir, "input")
_ = os.MkdirAll(inputDir, 0755)
// Create multiple dummy page files
var pages []*manga.PageFile
for i := 0; i < 5; i++ {
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.webp", i))
_ = os.WriteFile(pagePath, []byte(fmt.Sprintf("page %d content", i)), 0644)
pages = append(pages, &manga.PageFile{
Index: uint16(i),
Extension: ".webp",
FilePath: pagePath,
})
}
chapter := &manga.Chapter{
FilePath: filepath.Join(tmpDir, "source.cbz"),
TempDir: tmpDir,
Pages: pages,
}
chapter.SetConverted()
err := WriteChapterToCBZ(chapter, outputPath)
require.NoError(t, err)
// Verify archive contents
r, err := zip.OpenReader(outputPath)
require.NoError(t, err)
defer func() { _ = r.Close() }()
// Should have 5 pages = 5 files (conversion status stored in zip comment, not as file)
expectedFiles := 5
assert.Len(t, r.File, expectedFiles)
// Verify zip comment is set
assert.NotEmpty(t, r.Comment, "Expected zip comment for converted chapter")
}
func TestWriteChapterToCBZ_InvalidOutputPath(t *testing.T) {
tmpDir := t.TempDir()
inputDir := filepath.Join(tmpDir, "input")
_ = os.MkdirAll(inputDir, 0755)
pagePath := filepath.Join(inputDir, "0000.webp")
_ = os.WriteFile(pagePath, []byte("content"), 0644)
chapter := &manga.Chapter{
FilePath: filepath.Join(tmpDir, "source.cbz"),
TempDir: tmpDir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".webp", FilePath: pagePath},
},
}
err := WriteChapterToCBZ(chapter, "/nonexistent/dir/output.cbz")
require.Error(t, err)
}
+42
View File
@@ -0,0 +1,42 @@
package manga
import (
"fmt"
"os"
"time"
)
// Chapter represents a comic book chapter with pages stored on disk.
type Chapter struct {
// FilePath is the path to the original archive file.
FilePath string
// Pages is a slice of page files on disk.
Pages []*PageFile
// ComicInfoXml holds the ComicInfo.xml content (small, kept in memory).
ComicInfoXml string
// IsConverted indicates whether the chapter has already been converted.
IsConverted bool
// 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 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
}
+102
View File
@@ -0,0 +1,102 @@
package manga
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestChapter_SetConverted(t *testing.T) {
chapter := &Chapter{}
if chapter.IsConverted {
t.Error("New chapter should not be converted")
}
if !chapter.ConvertedTime.IsZero() {
t.Error("New chapter should have zero ConvertedTime")
}
before := time.Now()
chapter.SetConverted()
after := time.Now()
if !chapter.IsConverted {
t.Error("Chapter should be converted after SetConverted()")
}
if chapter.ConvertedTime.Before(before) || chapter.ConvertedTime.After(after) {
t.Error("ConvertedTime should be between before and after")
}
}
func TestChapter_Cleanup(t *testing.T) {
// Create a temp dir with some files
tmpDir := t.TempDir()
chapterDir := filepath.Join(tmpDir, "chapter-cleanup-test")
if err := os.MkdirAll(filepath.Join(chapterDir, "input"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(chapterDir, "input", "page.jpg"), []byte("test"), 0644); err != nil {
t.Fatal(err)
}
chapter := &Chapter{TempDir: chapterDir}
err := chapter.Cleanup()
if err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
if chapter.TempDir != "" {
t.Error("TempDir should be empty after cleanup")
}
if _, err := os.Stat(chapterDir); !os.IsNotExist(err) {
t.Error("Directory should not exist after cleanup")
}
}
func TestChapter_Cleanup_EmptyTempDir(t *testing.T) {
chapter := &Chapter{TempDir: ""}
err := chapter.Cleanup()
if err != nil {
t.Errorf("Cleanup with empty TempDir should not error, got: %v", err)
}
}
func TestChapter_Cleanup_NonexistentDir(t *testing.T) {
chapter := &Chapter{TempDir: "/nonexistent/path/that/does/not/exist"}
// os.RemoveAll on a nonexistent path returns nil
err := chapter.Cleanup()
if err != nil {
t.Errorf("Cleanup of nonexistent dir should not error, got: %v", err)
}
}
func TestPageFile_Struct(t *testing.T) {
page := &PageFile{
Index: 5,
Extension: ".webp",
FilePath: "/tmp/test/0005.webp",
IsSplitted: true,
SplitPartIndex: 2,
}
if page.Index != 5 {
t.Errorf("Expected Index 5, got %d", page.Index)
}
if page.Extension != ".webp" {
t.Errorf("Expected Extension .webp, got %s", page.Extension)
}
if page.FilePath != "/tmp/test/0005.webp" {
t.Errorf("Expected FilePath /tmp/test/0005.webp, got %s", page.FilePath)
}
if !page.IsSplitted {
t.Error("Expected IsSplitted true")
}
if page.SplitPartIndex != 2 {
t.Errorf("Expected SplitPartIndex 2, got %d", page.SplitPartIndex)
}
}
+22
View File
@@ -0,0 +1,22 @@
package manga
// PageFile represents a single page image stored on disk.
// No image data is held in memory — only metadata and a file path.
type PageFile struct {
// Index of the page in the chapter (original ordering).
Index uint16
// Extension of the page image file (e.g., ".webp", ".jpg").
Extension string
// FilePath is the absolute path to the image file on disk.
FilePath string
// IsSplitted indicates whether this page was split from a larger image.
IsSplitted bool
// SplitPartIndex is the part index when the page was split.
SplitPartIndex uint16
// OriginalName is the base filename (e.g. "page01.png") of the page as
// it appeared in the source archive, recorded when the --keep-filenames
// flag is enabled. Empty when the flag is off or the source name is
// unknown. When set, downstream code uses this stem (with the final
// Extension swapped in) instead of the default %04d sequential name.
OriginalName string
}
@@ -46,7 +46,7 @@ func TestCapture(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var err error = tt.initial var err = tt.initial
Capture(&err, tt.errFunc, tt.msg) Capture(&err, tt.errFunc, tt.msg)
if err != nil && err.Error() != tt.expected { if err != nil && err.Error() != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, err.Error()) t.Errorf("expected %q, got %q", tt.expected, err.Error())
@@ -110,7 +110,7 @@ func TestCaptureGeneric(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) CaptureGeneric(&err, tt.errFunc, tt.value, tt.msg)
if err != nil && err.Error() != tt.expected { if err != nil && err.Error() != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, err.Error()) t.Errorf("expected %q, got %q", tt.expected, err.Error())
+59
View File
@@ -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)
})
}
}
+166
View File
@@ -0,0 +1,166 @@
package utils
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/cbz"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
errors2 "github.com/belphemur/CBZOptimizer/v2/pkg/converter/errors"
"github.com/rs/zerolog/log"
)
type OptimizeOptions struct {
ChapterConverter converter.Converter
Path string
Quality uint8
Override bool
Split bool
// KeepFilenames preserves the original base filename of each page inside
// the output CBZ (with the extension swapped for format conversion)
// instead of the historical %04d sequential naming. Off by default so
// existing behavior is unchanged.
KeepFilenames bool
Timeout time.Duration
}
// Optimize optimizes a CBZ/CBR file using the specified converter.
// The new pipeline is disk-first:
// 1. Fast check if already converted (no extraction)
// 2. Extract archive to temp directory on disk
// 3. Convert pages file-to-file (no image data in memory)
// 4. Create output CBZ by streaming from disk
// 5. Cleanup temp files
func Optimize(options *OptimizeOptions) error {
log.Info().Str("file", options.Path).Msg("Processing file")
log.Debug().
Str("file", options.Path).
Uint8("quality", options.Quality).
Bool("override", options.Override).
Bool("split", options.Split).
Bool("keep_filenames", options.KeepFilenames).
Msg("Optimization parameters")
// Step 1: Fast conversion check before extracting (new requirement)
alreadyConverted, err := cbz.IsAlreadyConverted(context.Background(), options.Path)
if err != nil {
log.Debug().Str("file", options.Path).Err(err).Msg("Conversion check failed, proceeding with extraction")
}
if alreadyConverted {
log.Info().Str("file", options.Path).Msg("Chapter already converted")
return nil
}
// Step 2: Extract chapter to disk
log.Debug().Str("file", options.Path).Msg("Extracting chapter")
// Create context for extraction (use timeout if configured)
var extractCtx context.Context
if options.Timeout > 0 {
var cancel context.CancelFunc
extractCtx, cancel = context.WithTimeout(context.Background(), options.Timeout)
defer cancel()
log.Debug().Str("file", options.Path).Dur("timeout", options.Timeout).Msg("Applying timeout")
} else {
extractCtx = context.Background()
}
chapter, err := cbz.ExtractChapter(extractCtx, options.Path, options.KeepFilenames)
if err != nil {
log.Error().Str("file", options.Path).Err(err).Msg("Failed to extract chapter")
return fmt.Errorf("failed to extract chapter: %w", err)
}
defer func() {
if cleanupErr := chapter.Cleanup(); cleanupErr != nil {
log.Warn().Str("file", options.Path).Err(cleanupErr).Msg("Failed to cleanup temp directory")
}
}()
// Double-check conversion status from extracted metadata
if chapter.IsConverted {
log.Info().Str("file", options.Path).Msg("Chapter already converted")
return nil
}
log.Debug().
Str("file", options.Path).
Int("pages", len(chapter.Pages)).
Msg("Chapter extracted successfully")
// 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 {
log.Debug().Str("file", chapter.FilePath).Uint32("current", current).Uint32("total", total).Msg("Converting page")
}
})
if err != nil {
var pageIgnoredError *errors2.PageIgnoredError
if errors.As(err, &pageIgnoredError) {
log.Debug().Str("file", chapter.FilePath).Err(err).Msg("Page conversion error (non-fatal)")
} else {
log.Error().Str("file", chapter.FilePath).Err(err).Msg("Chapter conversion failed")
return fmt.Errorf("failed to convert chapter: %w", err)
}
}
if convertedChapter == nil {
log.Error().Str("file", chapter.FilePath).Msg("Conversion returned nil chapter")
return fmt.Errorf("failed to convert chapter")
}
log.Debug().
Str("file", chapter.FilePath).
Int("converted_pages", len(convertedChapter.Pages)).
Msg("Chapter conversion completed")
convertedChapter.SetConverted()
// Step 4: Determine output path
outputPath := options.Path
originalPath := options.Path
isCbrOverride := false
if options.Override {
pathLower := strings.ToLower(options.Path)
if strings.HasSuffix(pathLower, ".cbr") {
outputPath = strings.TrimSuffix(options.Path, filepath.Ext(options.Path)) + ".cbz"
isCbrOverride = true
}
} else {
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 {
outputPath = options.Path + "_converted.cbz"
}
}
// 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: %w", err)
}
// If overriding a CBR file, delete the original
if isCbrOverride {
err = os.Remove(originalPath)
if err != nil {
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")
}
}
log.Info().Str("output", outputPath).Msg("Converted file written")
return nil
}
+402
View File
@@ -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)
}
}
+424
View File
@@ -0,0 +1,424 @@
package utils
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/cbz"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/internal/utils/errs"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
)
// MockConverter for testing
type MockConverter struct {
shouldFail bool
}
func (m *MockConverter) ConvertChapter(ctx context.Context, chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
if m.shouldFail {
return nil, &MockError{message: "mock conversion error"}
}
// Check if context is already cancelled
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Simulate some work that can be interrupted by context cancellation
for i := 0; i < len(chapter.Pages); i++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
// Simulate processing time
time.Sleep(100 * time.Microsecond)
if progress != nil {
progress(fmt.Sprintf("Converting page %d/%d", i+1, len(chapter.Pages)), uint32(i+1), uint32(len(chapter.Pages)))
}
}
}
// Create a copy of the chapter to simulate conversion
converted := &manga.Chapter{
FilePath: chapter.FilePath,
Pages: chapter.Pages,
ComicInfoXml: chapter.ComicInfoXml,
IsConverted: true,
ConvertedTime: time.Now(),
}
return converted, nil
}
func (m *MockConverter) Format() constant.ConversionFormat {
return constant.WebP
}
func (m *MockConverter) PrepareConverter() error {
if m.shouldFail {
return &MockError{message: "mock prepare error"}
}
return nil
}
type MockError struct {
message string
}
func (e *MockError) Error() string {
return e.message
}
func TestOptimize(t *testing.T) {
// Create temporary directory for tests
tempDir, err := os.MkdirTemp("", "test_optimize")
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 tests")
}
// Copy sample files
var cbzFile, cbrFile 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.Contains(fileName, "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
} else if strings.HasSuffix(fileName, ".cbr") {
destPath := filepath.Join(tempDir, "test.cbr")
data, err := os.ReadFile(path)
if err != nil {
return err
}
err = os.WriteFile(destPath, data, info.Mode())
if err != nil {
return err
}
cbrFile = destPath
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if cbzFile == "" {
t.Skip("No CBZ test file found")
}
// Create a CBR file by copying the CBZ file if no CBR exists
if cbrFile == "" {
cbrFile = filepath.Join(tempDir, "test.cbr")
data, err := os.ReadFile(cbzFile)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(cbrFile, data, 0644)
if err != nil {
t.Fatal(err)
}
}
tests := []struct {
name string
inputFile string
override bool
expectedOutput string
shouldDelete bool
expectError bool
mockFail bool
}{
{
name: "CBZ file without override",
inputFile: cbzFile,
override: false,
expectedOutput: strings.TrimSuffix(cbzFile, ".cbz") + "_converted.cbz",
shouldDelete: false,
expectError: false,
},
{
name: "CBZ file with override",
inputFile: cbzFile,
override: true,
expectedOutput: cbzFile,
shouldDelete: false,
expectError: false,
},
{
name: "CBR file without override",
inputFile: cbrFile,
override: false,
expectedOutput: strings.TrimSuffix(cbrFile, ".cbr") + "_converted.cbz",
shouldDelete: false,
expectError: false,
},
{
name: "CBR file with override",
inputFile: cbrFile,
override: true,
expectedOutput: strings.TrimSuffix(cbrFile, ".cbr") + ".cbz",
shouldDelete: true,
expectError: false,
},
{
name: "Converter failure",
inputFile: cbzFile,
override: false,
expectError: true,
mockFail: true,
},
}
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
options := &OptimizeOptions{
ChapterConverter: &MockConverter{shouldFail: tt.mockFail},
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
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 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 TestOptimize_AlreadyConverted(t *testing.T) {
// Create temporary directory
tempDir, err := os.MkdirTemp("", "test_optimize_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 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: &MockConverter{},
Path: convertedFile,
Quality: 85,
Override: false,
Split: false,
Timeout: 0,
}
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 TestOptimize_InvalidFile(t *testing.T) {
options := &OptimizeOptions{
ChapterConverter: &MockConverter{},
Path: "/nonexistent/file.cbz",
Quality: 85,
Override: false,
Split: false,
Timeout: 0,
}
err := Optimize(options)
if err == nil {
t.Error("Expected error for nonexistent file")
}
}
func TestOptimize_Timeout(t *testing.T) {
// Create temporary directory
tempDir, err := os.MkdirTemp("", "test_optimize_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 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 (500 microseconds) to force timeout during conversion
options := &OptimizeOptions{
ChapterConverter: &MockConverter{},
Path: cbzFile,
Quality: 85,
Override: false,
Split: false,
Timeout: 500 * time.Microsecond, // 500 microseconds - should timeout during page processing
}
err = Optimize(options)
if err == nil {
t.Error("Expected timeout error but got none")
}
// Check that the error contains timeout information
if !strings.Contains(err.Error(), "context deadline exceeded") {
t.Errorf("Expected timeout error message, got: %v", err)
}
}
-16
View File
@@ -1,16 +0,0 @@
package main
import (
"github.com/belphemur/CBZOptimizer/v2/cmd"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
cmd.SetVersionInfo(version, commit, date)
cmd.Execute()
}
-22
View File
@@ -1,22 +0,0 @@
package manga
import "time"
type Chapter struct {
// FilePath is the path to the chapter's directory.
FilePath string
// Pages is a slice of pointers to Page objects.
Pages []*Page
// ComicInfo is a string containing information about the chapter.
ComicInfoXml string
// IsConverted is a boolean that indicates whether the chapter has 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 time.Time
}
// SetConverted sets the IsConverted field to true and sets the ConvertedTime field to the current time.
func (chapter *Chapter) SetConverted() {
chapter.IsConverted = true
chapter.ConvertedTime = time.Now()
}
-18
View File
@@ -1,18 +0,0 @@
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."`
}
-19
View File
@@ -1,19 +0,0 @@
package manga
import "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
}
func NewContainer(Page *Page, img image.Image, format string, isToBeConverted bool) *PageContainer {
return &PageContainer{Page: Page, Image: img, Format: format, IsToBeConverted: isToBeConverted}
}
+51
View File
@@ -0,0 +1,51 @@
package converter
import (
"context"
"fmt"
"strings"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/webp"
"github.com/samber/lo"
)
// Converter defines the interface for image format converters.
// All operations are file-to-file: no image data is held in memory in the happy path.
type Converter interface {
// Format returns the output format of this converter.
Format() constant.ConversionFormat
// ConvertChapter converts all pages in a chapter from their source files to
// the target format. Pages are processed in parallel (bounded by CPU count).
// On success, chapter.Pages is updated with converted PageFile entries.
// Returns partial success (non-fatal errors) via errors.PageIgnoredError.
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
}
var converters = map[constant.ConversionFormat]Converter{
constant.WebP: webp.New(),
}
// Available returns a list of available converters.
func Available() []constant.ConversionFormat {
return lo.Keys(converters)
}
// Get returns a converter by format name.
// If the converter is not available, an error is returned.
var Get = getConverter
func getConverter(name constant.ConversionFormat) (Converter, error) {
if converter, ok := converters[name]; ok {
return converter, nil
}
return nil, fmt.Errorf("unknown converter \"%s\", available options are %s", name, strings.Join(lo.Map(Available(), func(item constant.ConversionFormat, index int) string {
return item.String()
}), ", "))
}
+244
View File
@@ -0,0 +1,244 @@
package converter
import (
"context"
"fmt"
"image"
"image/jpeg"
"os"
"path/filepath"
"testing"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
)
func TestConvertChapter(t *testing.T) {
testCases := []struct {
name string
genTestChapter func(t *testing.T, dir string) (*manga.Chapter, []string)
split bool
expectError bool
}{
{
name: "All split pages",
genTestChapter: genHugePage,
split: true,
},
{
name: "Big Pages, no split",
genTestChapter: genHugePageNoSplit,
split: false,
expectError: true,
},
{
name: "No split pages",
genTestChapter: genSmallPages,
split: false,
},
{
name: "Mix of split and no split pages",
genTestChapter: genMixSmallBig,
split: true,
},
{
name: "Mix of Huge and small page",
genTestChapter: genMixSmallHuge,
split: false,
expectError: true,
},
}
for _, converterFormat := range Available() {
conv, err := Get(converterFormat)
if err != nil {
t.Fatalf("failed to get converter: %v", err)
}
t.Run(conv.Format().String(), func(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tempDir := t.TempDir()
chapter, expectedExtensions := tc.genTestChapter(t, tempDir)
quality := uint8(80)
progress := func(msg string, current uint32, total uint32) {
t.Log(msg)
}
convertedChapter, err := conv.ConvertChapter(context.Background(), chapter, quality, tc.split, progress)
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.Fatal("no pages were converted")
}
if len(convertedChapter.Pages) != len(expectedExtensions) {
t.Fatalf("converted chapter has %d pages but expected %d", len(convertedChapter.Pages), len(expectedExtensions))
}
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)
}
}
})
}
})
}
}
// 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 {
t.Fatal(err)
}
err = jpeg.Encode(f, img, nil)
_ = f.Close()
if err != nil {
t.Fatal(err)
}
}
func genHugePage(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
if err := os.MkdirAll(inputDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "output"), 0755); err != nil {
t.Fatal(err)
}
pagePath := filepath.Join(inputDir, "0000.jpg")
createTestImageFile(t, pagePath, 1, 17000)
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: pagePath},
},
}
// With split: 17000/2000 = 9 parts (8*2000 + 1*1000)
// Without split: page > webpMaxHeight → kept as .jpg with error
// The caller decides split=true or split=false and we return expectations
// for the split=true case (this test case is always called with split=true)
expectedExtensions := []string{".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp", ".webp"}
return chapter, expectedExtensions
}
func genHugePageNoSplit(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
if err := os.MkdirAll(inputDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "output"), 0755); err != nil {
t.Fatal(err)
}
pagePath := filepath.Join(inputDir, "0000.jpg")
createTestImageFile(t, pagePath, 1, 17000)
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: []*manga.PageFile{
{Index: 0, Extension: ".jpg", FilePath: pagePath},
},
}
// Without split: page > webpMaxHeight → kept as .jpg with error
return chapter, []string{".jpg"}
}
func genSmallPages(t *testing.T, dir string) (*manga.Chapter, []string) {
inputDir := filepath.Join(dir, "input")
_ = os.MkdirAll(inputDir, 0755)
_ = os.MkdirAll(filepath.Join(dir, "output"), 0755)
var pages []*manga.PageFile
for i := 0; i < 5; i++ {
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.jpg", i))
createTestImageFile(t, pagePath, 300, 1000)
pages = append(pages, &manga.PageFile{
Index: uint16(i),
Extension: ".jpg",
FilePath: pagePath,
})
}
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
Pages: pages,
}
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"}
}
+388
View File
@@ -0,0 +1,388 @@
package webp
import (
"context"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "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/rs/zerolog/log"
_ "golang.org/x/image/webp"
)
const webpMaxHeight = 16383
// intermediatePageName returns the on-disk filename used for a page's WebP
// intermediate during conversion. When the page carries an OriginalName
// (recorded by --keep-filenames), its stem is reused so the temp file line
// up with the name the archive writer will pick. Otherwise the historical
// %04d indexed naming is kept. The splitSuffix argument is appended verbatim
// after the stem (e.g. "-00", "-01") for split parts and is empty for the
// happy-path single output. A leading dash is only added when both the
// split suffix and the original-name stem are present, so the indexed form
// stays as %04d-%02d.
func intermediatePageName(page *manga.PageFile, splitSuffix string) string {
if page.OriginalName != "" {
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
return stem + splitSuffix + ".webp"
}
if splitSuffix == "" {
return fmt.Sprintf("%04d.webp", page.Index)
}
return fmt.Sprintf("%04d%s.webp", page.Index, splitSuffix)
}
type Converter struct {
maxHeight int
cropHeight int
isPrepared bool
// pageWorkerGuard limits concurrent cwebp processes across all chapters.
pageWorkerGuard chan struct{}
}
func (converter *Converter) Format() constant.ConversionFormat {
return constant.WebP
}
func New() *Converter {
return &Converter{
maxHeight: 4000,
cropHeight: 2000,
isPrepared: false,
pageWorkerGuard: make(chan struct{}, runtime.NumCPU()),
}
}
func (converter *Converter) PrepareConverter() error {
if converter.isPrepared {
return nil
}
err := PrepareEncoder()
if err != nil {
return err
}
converter.isPrepared = true
return nil
}
// ConvertChapter converts all pages in a chapter using file-to-file cwebp operations.
// In the happy path, no image data is loaded into Go memory.
// Splitting is attempted only if direct conversion fails due to dimension limits.
func (converter *Converter) ConvertChapter(ctx context.Context, chapter *manga.Chapter, quality uint8, split bool, progress func(message string, current uint32, total uint32)) (*manga.Chapter, error) {
log.Debug().
Str("chapter", chapter.FilePath).
Int("pages", len(chapter.Pages)).
Uint8("quality", quality).
Bool("split", split).
Msg("Starting file-to-file chapter conversion")
err := converter.PrepareConverter()
if err != nil {
return nil, err
}
// Validate TempDir is set to prevent writing to cwd
if chapter.TempDir == "" {
return nil, fmt.Errorf("chapter TempDir is empty, cannot create output directory")
}
// 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)
}
// Check for early context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
guard := converter.pageWorkerGuard
var totalPages atomic.Uint32
totalPages.Store(uint32(len(chapter.Pages)))
type pageResult struct {
pages []*manga.PageFile
err error
}
results := make([]pageResult, len(chapter.Pages))
var wg sync.WaitGroup
var convertedCount atomic.Uint32
for i, page := range chapter.Pages {
wg.Add(1)
go func(idx int, p *manga.PageFile) {
defer wg.Done()
// Check context before acquiring worker slot
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() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
// Wait for in-flight goroutines to finish
<-done
return nil, ctx.Err()
}
// Collect results — separate fatal errors from page-ignored errors
var convertedPages []*manga.PageFile
var fatalErrors []error
var ignoredErrors []error
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)
}
}
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 = convertedPages
var aggregatedError error
if len(ignoredErrors) > 0 {
aggregatedError = errors.Join(ignoredErrors...)
}
log.Debug().
Str("chapter", chapter.FilePath).
Int("converted_pages", len(convertedPages)).
Msg("Chapter conversion completed")
return chapter, aggregatedError
}
// convertPageFile converts a single page file to WebP format.
// Returns the converted page(s) — multiple if splitting was needed.
func (converter *Converter) convertPageFile(ctx context.Context, page *manga.PageFile, outputDir string, quality uint8, split bool) ([]*manga.PageFile, error) {
log.Debug().
Uint16("page_index", page.Index).
Str("input", page.FilePath).
Msg("Converting page file")
// If the page is already WebP, just return it as-is. The returned page
// keeps any OriginalName set during extraction so the archive writer can
// honor --keep-filenames for the final entry name.
if strings.ToLower(page.Extension) == ".webp" {
log.Debug().Uint16("page_index", page.Index).Msg("Page already WebP, skipping")
return []*manga.PageFile{page}, nil
}
// Try direct file-to-file conversion first (happy path — no memory allocation)
outputPath := filepath.Join(outputDir, intermediatePageName(page, ""))
err := EncodeFile(page.FilePath, outputPath, uint(quality))
if err == nil {
// Success! No image decoding needed. Preserve OriginalName so
// --keep-filenames carries through to the final zip entry name.
return []*manga.PageFile{{
Index: page.Index,
Extension: ".webp",
FilePath: outputPath,
OriginalName: page.OriginalName,
}}, nil
}
// Direct conversion failed. Check if it's a dimension issue.
log.Debug().
Uint16("page_index", page.Index).
Err(err).
Msg("Direct conversion failed, checking dimensions")
// Read just the image header to get dimensions (no full decode)
width, height, decodeErr := getImageDimensions(page.FilePath)
if decodeErr != nil {
// Can't even read the image header — keep the original file
log.Info().
Uint16("page_index", page.Index).
Err(decodeErr).
Msg("Cannot decode image, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d: failed to decode image (%s)", page.Index, decodeErr.Error()))
}
log.Debug().
Uint16("page_index", page.Index).
Int("width", width).
Int("height", height).
Msg("Image dimensions read")
// If height exceeds WebP max and split is not enabled, keep original
if height >= webpMaxHeight && !split {
log.Info().
Uint16("page_index", page.Index).
Int("height", height).
Msg("Page too tall for WebP, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d is too tall [max: %dpx] to be converted to webp format", page.Index, webpMaxHeight))
}
// If height exceeds our split threshold and split is enabled, use cwebp -crop
if height >= converter.maxHeight && split {
return converter.splitAndConvert(ctx, page, outputDir, quality, width, height)
}
// Height is within limits but conversion still failed for another reason.
// Keep the original file.
log.Warn().
Uint16("page_index", page.Index).
Err(err).
Msg("Conversion failed for non-dimension reason, keeping original")
return []*manga.PageFile{page}, converterrors.NewPageIgnored(
fmt.Sprintf("page %d: conversion failed (%s)", page.Index, err.Error()))
}
// splitAndConvert splits a tall image into multiple parts using cwebp -crop
// and converts each part. No Go-side image decode is needed.
func (converter *Converter) splitAndConvert(ctx context.Context, page *manga.PageFile, outputDir string, quality uint8, width, height int) ([]*manga.PageFile, error) {
log.Debug().
Uint16("page_index", page.Index).
Int("width", width).
Int("height", height).
Int("crop_height", converter.cropHeight).
Msg("Splitting and converting page using cwebp -crop")
numParts := height / converter.cropHeight
if height%converter.cropHeight != 0 {
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 - yOffset
}
outputPath := filepath.Join(outputDir, intermediatePageName(page, fmt.Sprintf("-%02d", i)))
err := EncodeFileWithCrop(page.FilePath, outputPath, uint(quality), 0, yOffset, width, partHeight)
if err != nil {
log.Error().
Uint16("page_index", page.Index).
Int("part", i).
Err(err).
Msg("Failed to convert split part")
return nil, fmt.Errorf("failed to convert split part %d of page %d: %w", i, page.Index, err)
}
pages = append(pages, &manga.PageFile{
Index: page.Index,
Extension: ".webp",
FilePath: outputPath,
IsSplitted: true,
SplitPartIndex: uint16(i),
OriginalName: page.OriginalName,
})
}
log.Debug().
Uint16("page_index", page.Index).
Int("parts", len(pages)).
Msg("Split conversion completed")
return pages, nil
}
// getImageDimensions reads only the image header to determine dimensions.
// This is much cheaper than a full image.Decode — only a few bytes are read.
func getImageDimensions(filePath string) (width, height int, err error) {
f, err := os.Open(filePath)
if err != nil {
return 0, 0, err
}
defer func() { _ = f.Close() }()
config, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
}
return config.Width, config.Height, nil
}
+520
View File
@@ -0,0 +1,520 @@
package webp
import (
"context"
"fmt"
"image"
"image/jpeg"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTestImageFile(t *testing.T, path string, width, height int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
f, err := os.Create(path)
require.NoError(t, err)
err = jpeg.Encode(f, img, nil)
require.NoError(t, err)
_ = f.Close()
}
func createTestChapter(t *testing.T, pages []struct{ w, h int }) (*manga.Chapter, string) {
t.Helper()
dir := t.TempDir()
inputDir := filepath.Join(dir, "input")
require.NoError(t, os.MkdirAll(inputDir, 0755))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "output"), 0755))
chapter := &manga.Chapter{
FilePath: filepath.Join(dir, "test.cbz"),
TempDir: dir,
}
for i, p := range pages {
pagePath := filepath.Join(inputDir, fmt.Sprintf("%04d.jpg", i))
createTestImageFile(t, pagePath, p.w, p.h)
chapter.Pages = append(chapter.Pages, &manga.PageFile{
Index: uint16(i),
Extension: ".jpg",
FilePath: pagePath,
})
}
return chapter, dir
}
func TestConverter_ConvertChapter(t *testing.T) {
tests := []struct {
name string
pages []struct{ w, h int }
split bool
expectSplit bool
expectError bool
numExpected int
}{
{
name: "Single normal image",
pages: []struct{ w, h int }{{800, 1200}},
split: false,
numExpected: 1,
},
{
name: "Multiple normal images",
pages: []struct{ w, h int }{
{800, 1200},
{800, 1200},
{800, 1200},
},
split: false,
numExpected: 3,
},
{
name: "Tall image with split enabled",
pages: []struct{ w, h int }{{800, 5000}},
split: true,
expectSplit: false, // cwebp handles 5000px fine (< 16383 webp max), no split needed
numExpected: 1,
},
{
name: "Tall image without split",
pages: []struct{ w, h int }{{800, webpMaxHeight + 100}},
split: false,
expectError: true,
numExpected: 1, // kept as-is
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
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)
lastProgress = current
assert.LessOrEqual(t, current, total)
}
convertedChapter, err := converter.ConvertChapter(context.Background(), chapter, 80, tt.split, progress)
if tt.expectError {
assert.Error(t, err)
if convertedChapter != nil {
assert.LessOrEqual(t, len(convertedChapter.Pages), tt.numExpected)
}
return
}
require.NoError(t, err)
require.NotNil(t, convertedChapter)
assert.Len(t, convertedChapter.Pages, tt.numExpected)
// Verify page order
for i := 1; i < len(convertedChapter.Pages); i++ {
prev := convertedChapter.Pages[i-1]
curr := convertedChapter.Pages[i]
if prev.Index == curr.Index {
assert.Less(t, prev.SplitPartIndex, curr.SplitPartIndex)
} else {
assert.Less(t, prev.Index, curr.Index)
}
}
if tt.expectSplit {
splitFound := false
for _, page := range convertedChapter.Pages {
if page.IsSplitted {
splitFound = true
break
}
}
assert.True(t, splitFound, "Expected split pages")
}
// Verify all output files exist
for _, page := range convertedChapter.Pages {
assert.FileExists(t, page.FilePath)
}
})
}
}
func TestConverter_Format(t *testing.T) {
converter := New()
assert.Equal(t, constant.WebP, converter.Format())
}
func TestConverter_ConvertChapter_Timeout(t *testing.T) {
converter := New()
err := converter.PrepareConverter()
require.NoError(t, err)
chapter, _ := createTestChapter(t, []struct{ w, h int }{
{800, 1200},
{800, 1200},
{800, 1200},
})
progress := func(message string, current uint32, total uint32) {}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
convertedChapter, err := converter.ConvertChapter(ctx, chapter, 80, false, progress)
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))
}
// TestIntermediatePageName_StemsAreUnique pins the contract that
// intermediatePageName must produce a different on-disk filename for two
// pages whose OriginalNames share a stem via the original archive entry
// name. ExtractChapter guarantees stem-unique OriginalNames per chapter
// (so, e.g., a/page.png and b/page.jpg come out as "page.png" and
// "page_0001.jpg"), and the converter must reflect that: stripping the
// extension and appending ".webp" must not collapse two distinct stems
// onto the same intermediate path.
func TestIntermediatePageName_StemsAreUnique(t *testing.T) {
tests := []struct {
name string
originalName string
splitSuffix string
}{
{name: "bare stem", originalName: "page.png", splitSuffix: ""},
{name: "indexed stem (post-fix)", originalName: "page_0001.jpg", splitSuffix: ""},
{name: "bare stem + split suffix", originalName: "page.png", splitSuffix: "-00"},
{name: "indexed stem + split suffix", originalName: "page_0001.jpg", splitSuffix: "-00"},
}
names := make(map[string]struct{}, len(tests))
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := &manga.PageFile{Index: 0, OriginalName: tt.originalName}
got := intermediatePageName(page, tt.splitSuffix)
if _, dup := names[got]; dup {
t.Errorf("intermediate name %q collides with an earlier page's intermediate name", got)
}
names[got] = struct{}{}
})
}
}
+57
View File
@@ -0,0 +1,57 @@
package webp
import (
"fmt"
"strings"
"sync"
"github.com/belphemur/go-webpbin/v2"
)
const libwebpVersion = "1.6.0"
var config = webpbin.NewConfig()
var prepareMutex sync.Mutex
func init() {
config.SetLibVersion(libwebpVersion)
}
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).
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()
}
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
-67
View File
@@ -1,67 +0,0 @@
package utils
import (
"errors"
"fmt"
"github.com/belphemur/CBZOptimizer/v2/cbz"
"github.com/belphemur/CBZOptimizer/v2/converter"
errors2 "github.com/belphemur/CBZOptimizer/v2/converter/errors"
"log"
"strings"
)
type OptimizeOptions struct {
ChapterConverter converter.Converter
Path string
Quality uint8
Override bool
Split bool
}
// Optimize optimizes a CBZ file using the specified converter.
func Optimize(options *OptimizeOptions) error {
log.Printf("Processing file: %s\n", options.Path)
// Load the chapter
chapter, err := cbz.LoadChapter(options.Path)
if err != nil {
return fmt.Errorf("failed to load chapter: %v", err)
}
if chapter.IsConverted {
log.Printf("Chapter already converted: %s", options.Path)
return nil
}
// Convert the chapter
convertedChapter, err := options.ChapterConverter.ConvertChapter(chapter, options.Quality, options.Split, func(msg string, current uint32, total uint32) {
if current%10 == 0 || current == total {
log.Printf("[%s] Converting: %d/%d", chapter.FilePath, current, total)
}
})
if err != nil {
var pageIgnoredError *errors2.PageIgnoredError
if !errors.As(err, &pageIgnoredError) {
return fmt.Errorf("failed to convert chapter: %v", err)
}
}
if convertedChapter == nil {
return fmt.Errorf("failed to convert chapter")
}
convertedChapter.SetConverted()
// Write the converted chapter back to a CBZ file
outputPath := options.Path
if !options.Override {
outputPath = strings.TrimSuffix(options.Path, ".cbz") + "_converted.cbz"
}
err = cbz.WriteChapterToCBZ(convertedChapter, outputPath)
if err != nil {
return fmt.Errorf("failed to write converted chapter: %v", err)
}
log.Printf("Converted file written to: %s\n", outputPath)
return nil
}