commit 1c6dfc880d6f3d52f012b3230baf86b6d3606c00
Author: bakonpancakz
Date: Sat May 23 17:16:14 2026 -0700
Initial Release
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dfd6d15
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.vestige
+dependencies
+bin
+lib
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8762813
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 bakonpancakz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7da3a35
--- /dev/null
+++ b/README.md
@@ -0,0 +1,93 @@
+
+# `>_` clitools
+
+A collection of simple CLI tools which I use ocasionally.
+They're really simple, mostly single-file, and should theoretically "just work" across platforms.
+
+- [`(imageconvert)` Mass Image Converter using ImageMagick](#imageconvert-mass-image-converter-using-imagemagick)
+- [`(mediaconvert)` Mass Media Converter using FFMPEG](#mediaconvert-mass-media-converter-using-ffmpeg)
+- [`(crunchy)` Turn Image into a Crunchy JPEG](#crunchy-turn-image-into-a-crunchy-jpeg)
+- [`(mangapub)` Converts CBZs into EPUBs](#mangapub-converts-cbzs-into-epubs)
+
+
+
+## `(imageconvert)` Mass Image Converter using ImageMagick
+Ever have a directory full of images in different formats? Use this tool to
+quickly convert them to a normal extension. Outputs to a `convert` folder
+in the current working directory.
+
+> **Requires:** ImageMagick
+
+```
+imageconvert
+ --skip-errors - Skip on conversion error
+ --skip-resume - Skip Resume Checking
+ --multithread - Use Multiple Threads
+ --recursive - Scan Directories Recursively
+ - File Extension(s) to convert from, delimited with comma
+ - File Extension to convert into
+ [Arguments] - Arguments to pass onto ImageMagick
+```
+
+
+
+## `(mediaconvert)` Mass Media Converter using FFMPEG
+Ever have a directory full of videos in different formats? Use this tool to
+quickly convert them to a normal extension. Outputs to a `convert` folder
+in the current working directory.
+
+> **Requires:** FFMPEG
+
+```
+mediaconvert
+ --skip-resume - Skip Resume Checking
+ --multithread - Use Multiple Threads
+ --recursive - Scan Directories Recursively
+ - File Extension(s) to convert from, delimited with comma
+ - File Extension to convert into
+ [Arguments] - Arguments to pass onto FFMPEG
+Templates:
+ {filename} - Full Filename (e.g. myfile.txt)
+ {basename} - Base Filename (e.g. myfile
+ {directory} - Source Directory (e.g. /path/to/file)
+```
+
+
+
+## `(crunchy)` Turn Image into a Crunchy JPEG
+Applies random noise and rounding errors to the colorspace to make an image look **"crunchy"**
+
+```
+crunchy
+ --noise= - Noise Level (Default: 25, Range: 0-100)
+ --quality= - JPEG Quality (Default: 0, Range: 0-100)
+ --generations= - Iterations (Default: 5)
+ - Input Filename
+```
+
+
+
+
Another satisfied customer!
+
+
+
+
+## `(mangapub)` Converts CBZs into EPUBs
+Converts a directory of CBZ files into EPUBs, designed for copying mass amounts
+of manga onto a Kindle 8th gen. It's default settings are very crunchy!
+
+**Note:** This doesn't properly split large images into two, and it will never,
+because it doesn't bother me :P
+
+```
+mangapub
+ --extract - Extract Images to Directory
+ --recursive - Scan Directories Recursively
+ --height= - Image Height (Default: 800)
+ --width= - Image Width (Default: 600)
+ --quality= - JPEG Quality (Default: 25, Range: 0-100)
+```
+
+> Highly modified version of this repo: https://github.com/DimazzzZ/cbz2epub
+
+
diff --git a/crunchy/example.png b/crunchy/example.png
new file mode 100644
index 0000000..fe135b6
Binary files /dev/null and b/crunchy/example.png differ
diff --git a/crunchy/go.mod b/crunchy/go.mod
new file mode 100644
index 0000000..a917958
--- /dev/null
+++ b/crunchy/go.mod
@@ -0,0 +1,5 @@
+module github.com/bakonpancakz/clitools/crunchy
+
+go 1.25.2
+
+require golang.org/x/image v0.33.0
diff --git a/crunchy/go.sum b/crunchy/go.sum
new file mode 100644
index 0000000..ba0ff29
--- /dev/null
+++ b/crunchy/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
diff --git a/crunchy/main.go b/crunchy/main.go
new file mode 100644
index 0000000..6dda63f
--- /dev/null
+++ b/crunchy/main.go
@@ -0,0 +1,193 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "math"
+ "math/rand"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+
+ "golang.org/x/image/webp"
+)
+
+func main() {
+
+ // ----- Parse Arguments -----
+ var optionQuality = 0
+ var optionNoise = 25
+ var optionGenerations = 5
+ var optionFilename string
+
+ flags := make([]string, 0, len(os.Args))
+ for i := 1; i < len(os.Args); i++ {
+ segments := strings.SplitN(os.Args[i], "=", 2)
+ if len(segments) == 2 {
+ n := segments[0]
+ s := segments[1]
+ switch {
+ case strings.EqualFold(n, "--generations"):
+ v := parseInteger(n, s, 0, math.MaxInt)
+ fmt.Printf("Flag: Generation(s) %d\n", v)
+ optionGenerations = v
+
+ case strings.EqualFold(n, "--quality"):
+ v := parseInteger(n, s, 0, 100)
+ fmt.Printf("Flag: Quality %d\n", v)
+ optionQuality = v
+
+ case strings.EqualFold(n, "--noise"):
+ v := parseInteger(n, s, 0, 100)
+ fmt.Printf("Flag: Noise Level %d\n", v)
+ optionQuality = v
+
+ default:
+ fmt.Printf("%s: Unknown Argument", n)
+ os.Exit(1)
+ }
+
+ } else {
+ flags = append(flags, segments[0])
+ }
+ }
+ if len(flags) < 1 {
+ fmt.Println("crunchy")
+ fmt.Println(" --noise= - Noise Level (Default: 25, Range: 0-100)")
+ fmt.Println(" --quality= - JPEG Quality (Default: 0, Range: 0-100)")
+ fmt.Println(" --generations= - Iterations (Default: 5)")
+ fmt.Println(" - Input Filename")
+ os.Exit(0)
+ }
+ optionFilename = flags[0]
+ noiseInteger := int(float32(optionNoise)*2.56) + 1
+ noiseHalved := noiseInteger / 2
+
+ // ----- Decode Image Contents -----
+ content := bytes.Buffer{}
+ f, err := os.Open(optionFilename)
+ if err != nil {
+ fmt.Printf("Failed to open file: %s\n", err.Error())
+ os.Exit(1)
+ }
+ if _, err := io.Copy(&content, f); err != nil {
+ fmt.Printf("Failed to read file: %s\n", err.Error())
+ os.Exit(1)
+ }
+
+ img, err := decodeImage(content.Bytes())
+ if err != nil {
+ fmt.Printf("Decoding Error: %s\n", err.Error())
+ os.Exit(1)
+ }
+ bounds := img.Bounds()
+ ycc := image.NewYCbCr(bounds, image.YCbCrSubsampleRatio444)
+ rgb := image.NewRGBA(bounds)
+ for y := 0; y < bounds.Dy(); y++ {
+ for x := 0; x < bounds.Dx(); x++ {
+ rgb.Set(x, y, img.At(x, y)) // copy generic img to rgba
+ }
+ }
+
+ // ----- Apply Generation Loss -----
+ for i := 0; i < optionGenerations; i++ {
+ for y := 0; y < bounds.Dy(); y++ {
+ for x := 0; x < bounds.Dx(); x++ {
+ // Rounding Error via Colorspace Conversion
+ r, g, b, _ := rgb.At(x, y).RGBA()
+ cy, cb, cr := color.RGBToYCbCr(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+
+ // Random Noise
+ noise := rand.Intn(noiseInteger) - noiseHalved
+ cb = uint8(int(cb) + noise)
+ cr = uint8(int(cr) + noise)
+
+ // Apply Changes
+ ycc.Y[ycc.YOffset(x, y)] = cy
+ ycc.Cb[ycc.COffset(x, y)] = cb
+ ycc.Cr[ycc.COffset(x, y)] = cr
+ }
+ }
+ for y := 0; y < bounds.Dy(); y++ {
+ for x := 0; x < bounds.Dx(); x++ {
+ rgb.Set(x, y, ycc.At(x, y))
+ }
+ }
+ }
+
+ // ----- Write Output -----
+ content.Reset()
+ if err := jpeg.Encode(&content, rgb, &jpeg.Options{Quality: optionQuality}); err != nil {
+ fmt.Printf("Encoding Error: %s\n", err.Error())
+ os.Exit(1)
+ }
+ cleanname := path.Base(optionFilename)
+ emptyname := strings.TrimSuffix(cleanname, path.Ext(cleanname))
+ finalname := fmt.Sprintf("%s_n%d_g%d_q%d.jpeg", emptyname, optionNoise, optionGenerations, optionQuality)
+ if err := os.WriteFile(finalname, content.Bytes(), 0660); err != nil {
+ fmt.Printf("Failed to write file '%s': %s\n", finalname, err.Error())
+ os.Exit(1)
+ }
+}
+
+// Parse Integer for CLI Arguments
+func parseInteger(n string, s string, min int, max int) int {
+ v, err := strconv.Atoi(s)
+ if err != nil {
+ fmt.Printf("%s: Not A Number\n", n)
+ os.Exit(1)
+ }
+ if v < min {
+ fmt.Printf("%s: Value cannot be less than %d\n", n, min)
+ os.Exit(1)
+ }
+ if v > max {
+ fmt.Printf("%s: Value cannot be more than %d\n", n, max)
+ os.Exit(1)
+ }
+ return v
+}
+
+// Decode Image with the appropriate decoder based on it's starting bytes
+// https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files)
+func decodeImage(d []byte) (image.Image, error) {
+ var (
+ decoderImage image.Image
+ decoderError error
+ )
+ switch {
+ case len(d) > 3 && // JPEG
+ d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF:
+ decoderImage, decoderError = jpeg.Decode(bytes.NewReader(d))
+
+ case len(d) > 8 && // PNG
+ d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47 &&
+ d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A:
+ decoderImage, decoderError = png.Decode(bytes.NewReader(d))
+
+ case len(d) > 4 && // GIF
+ d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38:
+ decoderImage, decoderError = gif.Decode(bytes.NewReader(d))
+
+ case len(d) > 12 && // WEBP
+ d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 &&
+ d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50:
+ decoderImage, decoderError = webp.Decode(bytes.NewReader(d))
+
+ default:
+ return decoderImage, errors.New("unsupported file type")
+ }
+ if decoderError != nil {
+ return nil, decoderError
+ }
+
+ return decoderImage, nil
+}
diff --git a/imageconvert/main.go b/imageconvert/main.go
new file mode 100644
index 0000000..6624454
--- /dev/null
+++ b/imageconvert/main.go
@@ -0,0 +1,208 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+type QueuedItem struct {
+ Basename string // Filename without Extension
+ Filename string // Filename with Extension
+ Nest []string // Subdirectories
+}
+
+const (
+ OUTPUT_DIR = "convert"
+ OUTPUT_FLAG = 0755
+)
+
+var (
+ featureResume bool = true
+ featureRecursive bool = false
+ featureSkipErrors bool = false
+ flags []string
+ queue []QueuedItem
+ workers = 1
+)
+
+func scan(nest []string, extensions []string) {
+ if len(nest) == 1 && strings.EqualFold(nest[0], OUTPUT_DIR) {
+ return
+ }
+ folder := path.Join(nest...)
+ if folder == "" {
+ folder = "."
+ }
+ files, err := os.ReadDir(folder)
+ if err != nil {
+ log.Fatalf("Error reading directory '%s': %s\n", folder, err)
+ }
+ for _, entry := range files {
+ filename := entry.Name()
+
+ // Scan Subdirectory
+ if entry.IsDir() {
+ if featureRecursive {
+ scan(append(nest, filename), extensions)
+ }
+ continue
+ }
+
+ // Match File Extension
+ for _, prefix := range extensions {
+ if len(filename) < len(prefix) {
+ continue
+ }
+ if !strings.EqualFold(filename[len(filename)-len(prefix):], prefix) {
+ continue
+ }
+
+ // Perform Resume Check
+ basename := filename[:strings.LastIndex(filename, ".")]
+ location := fmt.Sprint(path.Join(OUTPUT_DIR, folder, basename), ".", flags[2])
+ if featureResume {
+ if info, err := os.Stat(location); err == nil {
+ if info.Size() != 0 {
+ fmt.Printf("Skipping '%s' as it is already complete\n", filename)
+ continue
+ }
+ }
+ }
+
+ // Add Item to Queue
+ queue = append(queue, QueuedItem{
+ Filename: filename,
+ Basename: basename,
+ Nest: nest,
+ })
+ break
+ }
+ }
+}
+
+func main() {
+ t := time.Now()
+
+ // Collect Arguments
+ for _, arg := range os.Args {
+ if strings.EqualFold(arg, "--skip-resume") {
+ log.Println("Flag: Disabling Resume Check")
+ featureResume = false
+ continue
+ }
+ if strings.EqualFold(arg, "--recursive") {
+ log.Println("Flag: Scanning Recursively")
+ featureRecursive = true
+ continue
+ }
+ if strings.EqualFold(arg, "--multithread") {
+ log.Println("Flag: Enabling Multi-threading")
+ workers = runtime.NumCPU() - 1
+ if workers < 1 {
+ workers = 1
+ }
+ continue
+ }
+ if strings.EqualFold(arg, "--skip-errors") {
+ log.Println("Flag: Skipping on Conversion Error")
+ featureSkipErrors = true
+ continue
+ }
+ flags = append(flags, arg)
+ }
+ if len(flags) < 3 {
+ fmt.Println("imageconvert")
+ fmt.Println(" --skip-errors - Skip on conversion error")
+ fmt.Println(" --skip-resume - Skip Resume Checking")
+ fmt.Println(" --multithread - Use Multiple Threads")
+ fmt.Println(" --recursive - Scan Directories Recursively")
+ fmt.Println(" - File Extension(s) to convert from, delimited with comma")
+ fmt.Println(" - File Extension to convert into")
+ fmt.Println(" [Arguments] - Arguments to pass onto ImageMagick")
+ os.Exit(0)
+ }
+
+ // Scan Directory
+ scan([]string{}, strings.Split(flags[1], ","))
+
+ // Startup Workers
+ var consoleLock sync.Mutex
+ var awaitWorkers sync.WaitGroup
+ var itemsRemaining atomic.Int32
+ itemsRemaining.Add(int32(len(queue)))
+ jobs := make(chan int, len(queue))
+
+ log.Printf("Queued Files: %d\n", len(queue))
+ log.Printf("Worker Count: %d\n", workers)
+
+ for workerID := 0; workerID < workers; workerID++ {
+ awaitWorkers.Add(1)
+ go func() {
+ defer awaitWorkers.Done()
+ for i := range jobs {
+ info := queue[i]
+
+ // Generate Paths
+ directory := path.Join(info.Nest...)
+ srcPath := path.Join(directory, info.Filename)
+ dstPath := fmt.Sprint(path.Join(OUTPUT_DIR, directory, info.Basename), ".", flags[2])
+
+ if err := os.MkdirAll(path.Join(OUTPUT_DIR, directory), OUTPUT_FLAG); err != nil {
+ log.Fatalln("Cannot create output directory:", err)
+ }
+
+ // Compile Arguments
+ args := make([]string, 0, len(flags))
+ args = append(args, srcPath)
+ for i := 3; i < len(flags); i++ {
+ args = append(args, flags[i])
+ }
+ args = append(args, dstPath)
+ proc := exec.Command("magick", args...)
+
+ // Output Errors
+ if output, err := proc.CombinedOutput(); err != nil {
+
+ consoleLock.Lock()
+ exitcode := -1
+ if proc.ProcessState != nil {
+ exitcode = proc.ProcessState.ExitCode()
+ }
+ fmt.Printf("\r")
+ log.Printf("Processing Failed for '%s' with code: %d\n%s\n\n",
+ srcPath, exitcode, strings.TrimSpace(string(output)))
+ consoleLock.Unlock()
+
+ if !featureSkipErrors {
+ os.Exit(1)
+ }
+ }
+
+ // Output Progress
+ consoleLock.Lock()
+ fmt.Printf("\r ")
+ fmt.Printf("\rItems Left: %d", itemsRemaining.Add(-1))
+ consoleLock.Unlock()
+ }
+ }()
+ }
+
+ // Begin Processing
+ for i := 0; i < len(queue); i++ {
+ jobs <- i
+ }
+ close(jobs)
+ awaitWorkers.Wait()
+
+ // Processing Complete
+ fmt.Printf("\n")
+ log.Printf("Processing Completed in %s\n", time.Since(t))
+}
diff --git a/mangapub/go.mod b/mangapub/go.mod
new file mode 100644
index 0000000..487b77e
--- /dev/null
+++ b/mangapub/go.mod
@@ -0,0 +1,5 @@
+module github.com/bakonpancakz/clitools/mangapub
+
+go 1.24.0
+
+require golang.org/x/image v0.33.0
diff --git a/mangapub/go.sum b/mangapub/go.sum
new file mode 100644
index 0000000..ba0ff29
--- /dev/null
+++ b/mangapub/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
diff --git a/mangapub/main.go b/mangapub/main.go
new file mode 100644
index 0000000..d798286
--- /dev/null
+++ b/mangapub/main.go
@@ -0,0 +1,484 @@
+package main
+
+import (
+ "archive/zip"
+ "bytes"
+ "crypto/rand"
+ "embed"
+ "fmt"
+ "image"
+ "image/color"
+ "image/gif"
+ "image/jpeg"
+ "image/png"
+ "io"
+ "log"
+ "math"
+ "os"
+ "path"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "text/template"
+ "time"
+
+ "golang.org/x/image/draw"
+ "golang.org/x/image/webp"
+)
+
+type File struct {
+ Name string
+ Images []Image
+}
+
+type Image struct {
+ Name string
+ Data []byte
+ MimeType string
+}
+
+type QueuedItem struct {
+ Basename string // Filename without Extension
+ Filename string // Filename with Extension
+ Nest []string // Subdirectories
+}
+
+const (
+ OUTPUT_DIR = "convert"
+ OUTPUT_FLAG = 0755
+)
+
+var (
+ featureRecursive bool = false
+ featureExtract bool = false
+ featureHeight int = 800
+ featureWidth int = 600
+ featureQuality int = 25
+ flags []string
+ queue []QueuedItem
+)
+
+//go:embed templates/*
+var templateFS embed.FS
+
+func main() {
+ t := time.Now()
+
+ // Parse Arguments
+ for i := 1; i < len(os.Args); i++ {
+ segments := strings.SplitN(os.Args[i], "=", 2)
+ if len(segments) == 2 {
+ n := segments[0]
+ s := segments[1]
+ switch {
+ case strings.EqualFold(n, "--height"):
+ v := parseInteger(n, s, 128, math.MaxInt)
+ log.Printf("Flag: Height %d\n", v)
+ featureHeight = v
+
+ case strings.EqualFold(n, "--width"):
+ v := parseInteger(n, s, 128, math.MaxInt)
+ log.Printf("Flag: Width %d\n", v)
+ featureWidth = v
+
+ case strings.EqualFold(n, "--quality"):
+ v := parseInteger(n, s, 0, 100)
+ log.Printf("Flag: Quality %d\n", v)
+ featureQuality = v
+
+ default:
+ log.Printf("%s: Unknown Argument", n)
+ os.Exit(1)
+ }
+
+ } else {
+ n := segments[0]
+ if strings.EqualFold(n, "--recursive") {
+ log.Println("Flag: Scanning Recursively")
+ featureRecursive = true
+ continue
+ }
+ if strings.EqualFold(n, "--extract") {
+ log.Println("Flag: Extracting Images")
+ featureExtract = true
+ continue
+ }
+ flags = append(flags, segments[0])
+ }
+ }
+ if len(flags) < 1 {
+ fmt.Println("mangapub")
+ fmt.Println(" --extract - Extract Images to Directory")
+ fmt.Println(" --recursive - Scan Directories Recursively")
+ fmt.Println(" --height= - Image Height (Default: 800)")
+ fmt.Println(" --width= - Image Width (Default: 600)")
+ fmt.Println(" --quality= - JPEG Quality (Default: 25, Range: 0-100)")
+ fmt.Println(" - Directory to Scan (Use \".\" for current directory)")
+ os.Exit(0)
+ }
+
+ // Process Archives
+ scan([]string{})
+ for _, info := range queue {
+
+ // Generate Paths
+ directory := path.Join(info.Nest...)
+ srcPath := path.Join(directory, info.Filename)
+ dstPath := path.Join(OUTPUT_DIR, directory, info.Basename)
+ if err := os.MkdirAll(path.Join(OUTPUT_DIR, directory), OUTPUT_FLAG); err != nil {
+ log.Fatalln("Cannot create output directory:", err)
+ }
+ log.Printf("Converting: %s\n", srcPath)
+
+ // Convert Archive
+ contents, err := ParseCBZ(srcPath)
+ if err != nil {
+ log.Printf("Failed to parse CBZ '%s': %s\n", srcPath, err)
+ continue
+ }
+ if featureExtract {
+ if err := CreateDirectory(contents, dstPath); err != nil {
+ log.Printf("Failed to create DIR '%s': %s\n", dstPath, err)
+ continue
+ }
+ } else {
+ if err := CreateEPUB(contents, dstPath); err != nil {
+ log.Printf("Failed to create EPUB '%s': %s\n", dstPath, err)
+ continue
+ }
+ }
+
+ }
+
+ // Processing Complete
+ fmt.Printf("\n")
+ log.Printf("Processing Completed in %s\n", time.Since(t))
+}
+
+// Parse Integer for CLI Arguments
+func parseInteger(n string, s string, min int, max int) int {
+ v, err := strconv.Atoi(s)
+ if err != nil {
+ fmt.Printf("%s: Not A Number\n", n)
+ os.Exit(1)
+ }
+ if v < min {
+ fmt.Printf("%s: Value cannot be less than %d\n", n, min)
+ os.Exit(1)
+ }
+ if v > max {
+ fmt.Printf("%s: Value cannot be more than %d\n", n, max)
+ os.Exit(1)
+ }
+ return v
+}
+
+// Scan directory and append eligible items to queue
+func scan(nesting []string) {
+ if len(nesting) == 1 && strings.EqualFold(nesting[0], OUTPUT_DIR) {
+ return
+ }
+
+ // Read Entries in Directory
+ directory := path.Join(nesting...)
+ if directory == "" {
+ directory = path.Clean(flags[0])
+ nesting = []string{flags[0]}
+ }
+ dirEntries, err := os.ReadDir(directory)
+ if err != nil {
+ log.Fatalf("Error reading directory '%s': %s\n", directory, err)
+ }
+
+ for _, entry := range dirEntries {
+ fileName := entry.Name()
+
+ // Scan Subdirectory
+ if entry.IsDir() {
+ if featureRecursive {
+ scan(append(nesting, fileName))
+ }
+ continue
+ }
+
+ // Add Matching File Extensions to Queue
+ fileExt := path.Ext(fileName)
+ if !strings.EqualFold(fileExt, ".cbz") {
+ continue
+ }
+ queue = append(queue, QueuedItem{
+ Filename: fileName,
+ Basename: strings.TrimSuffix(fileName, fileExt),
+ Nest: nesting,
+ })
+ }
+}
+
+func GenerateUUID() string {
+ uuid := make([]byte, 16)
+ _, err := rand.Read(uuid)
+ if err != nil {
+ // Fallback to a timestamp-based ID if random generation fails
+ return fmt.Sprintf("%x", time.Now().UnixNano())
+ }
+
+ // Set version (4) and variant (RFC 4122)
+ uuid[6] = (uuid[6] & 0x0f) | 0x40
+ uuid[8] = (uuid[8] & 0x3f) | 0x80
+ return fmt.Sprintf("%x-%x-%x-%x-%x",
+ uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16])
+}
+
+func ParseCBZ(filename string) (*File, error) {
+
+ // CBZ files are really just zip archives
+ reader, err := zip.OpenReader(filename)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open CBZ file: %w", err)
+ }
+ defer reader.Close()
+
+ cbzFile := &File{
+ Name: filename,
+ Images: []Image{},
+ }
+
+ // Multithreaded image processing
+ var wc = make(chan int, len(reader.File))
+ var wg sync.WaitGroup
+ var wm sync.Mutex
+ for c := 0; c < runtime.NumCPU(); c++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := range wc {
+
+ // Ignore Directories
+ file := reader.File[i]
+ if file.FileInfo().IsDir() {
+ continue
+ }
+
+ // Read file contents inside archive
+ rc, err := file.Open()
+ if err != nil {
+ log.Printf("failed to open file in CBZ: %s\n", err)
+ continue
+ }
+ d, _ := io.ReadAll(rc)
+ rc.Close()
+
+ // Decode Image with the appropriate decoder based on it's starting bytes
+ // https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files)
+ var decoderImage image.Image
+ var decoderError error
+ switch {
+ case len(d) > 3 && // JPEG
+ d[0] == 0xFF && d[1] == 0xD8 && d[2] == 0xFF:
+ decoderImage, decoderError = jpeg.Decode(bytes.NewReader(d))
+
+ case len(d) > 8 && // PNG
+ d[0] == 0x89 && d[1] == 0x50 && d[2] == 0x4E && d[3] == 0x47 &&
+ d[4] == 0x0D && d[5] == 0x0A && d[6] == 0x1A && d[7] == 0x0A:
+ decoderImage, decoderError = png.Decode(bytes.NewReader(d))
+
+ case len(d) > 4 && // GIF
+ d[0] == 0x47 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x38:
+ decoderImage, decoderError = gif.Decode(bytes.NewReader(d))
+
+ case len(d) > 12 && // WEBP
+ d[0] == 0x52 && d[1] == 0x49 && d[2] == 0x46 && d[3] == 0x46 &&
+ d[8] == 0x57 && d[9] == 0x45 && d[10] == 0x42 && d[11] == 0x50:
+ decoderImage, decoderError = webp.Decode(bytes.NewReader(d))
+
+ default: // unsupported content type
+ continue
+ }
+ if decoderError != nil {
+ log.Printf("malformed image: %s\n", err)
+ continue
+ }
+
+ // Calculate Scaled Height and Width
+ bounds := decoderImage.Bounds()
+ targetW, targetH := featureWidth, featureHeight
+ iw, ih := bounds.Dx(), bounds.Dy()
+ ratio := math.Min(float64(targetW)/float64(iw), float64(targetH)/float64(ih))
+ sw, sh := int(float64(iw)*ratio), int(float64(ih)*ratio)
+ canvas := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
+
+ // Resize Image (White Background)
+ for x := 0; x < targetW; x++ {
+ for y := 0; y < targetH; y++ {
+ canvas.SetRGBA(x, y, color.RGBA{R: 255, G: 255, B: 255, A: 255})
+ }
+ }
+ offsetX := (targetW - sw) / 2
+ offsetY := (targetH - sh) / 2
+ draw.CatmullRom.Scale(canvas, image.Rect(offsetX, offsetY, offsetX+sw, offsetY+sh),
+ decoderImage, bounds, draw.Over, nil)
+
+ // Encode Resized Image into JPEG
+ enc := bytes.Buffer{}
+ if err := jpeg.Encode(&enc, canvas, &jpeg.Options{Quality: featureQuality}); err != nil {
+ log.Printf("encoding error: %s\n", err)
+ continue
+ }
+
+ // Append Image to List
+ wm.Lock()
+ cbzFile.Images = append(cbzFile.Images, Image{
+ Name: strings.TrimSuffix(path.Base(file.Name), path.Ext(file.Name)) + ".jpeg",
+ Data: enc.Bytes(),
+ MimeType: "image/jpeg",
+ })
+ wm.Unlock()
+ }
+ }()
+ }
+
+ // Wait for processing to complete
+ for i := 0; i < len(reader.File); i++ {
+ wc <- i
+ }
+ close(wc)
+ wg.Wait()
+
+ // Sort Images by Name
+ sort.Slice(cbzFile.Images, func(i, j int) bool {
+ return cbzFile.Images[i].Name < cbzFile.Images[j].Name
+ })
+ return cbzFile, nil
+}
+
+func CreateEPUB(input *File, filename string) error {
+
+ // EPUB files are really just zip archives
+ writer, err := os.Create(filename + ".epub")
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %w", err)
+ }
+ defer writer.Close()
+
+ archive := zip.NewWriter(writer)
+ defer archive.Close()
+
+ {
+ // Write Mime Header
+ mimetype, err := archive.CreateHeader(&zip.FileHeader{
+ Name: "mimetype",
+ Method: zip.Store,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to create mimetype file: %w", err)
+ }
+ if _, err = mimetype.Write([]byte("application/epub+zip")); err != nil {
+ return fmt.Errorf("failed to write mimetype file: %w", err)
+ }
+ }
+
+ type Item struct {
+ ID int
+ Base string
+ Type string
+ }
+ var (
+ ContentTitle = strings.TrimSuffix(path.Base(input.Name), path.Ext(input.Name))
+ ContentDate = time.Now().Format("2006-01-02")
+ ContentUUID = GenerateUUID()
+ ContentImages = make([]Item, 0, len(input.Images))
+ )
+ for i, image := range input.Images {
+
+ // Create Metadata Entry
+ pathBase := fmt.Sprintf("page%03d", i+1)
+ pathItem := Item{
+ ID: i + 1,
+ Base: pathBase,
+ Type: image.MimeType,
+ }
+
+ // Add HTML to Archive
+ {
+ pathOutput := fmt.Sprint("OEBPS/pages/", pathBase, ".xhtml")
+ pathTemplate := "templates/page.xml"
+ tmpl, err := template.ParseFS(templateFS, pathTemplate)
+ if err != nil {
+ return fmt.Errorf("cannot open template file '%s': %s", pathTemplate, err)
+ }
+ output, err := archive.Create(pathOutput)
+ if err != nil {
+ return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err)
+ }
+ if err := tmpl.Execute(output, pathItem); err != nil {
+ return fmt.Errorf("cannot execute template file '%s': %s", pathTemplate, err)
+ }
+ }
+
+ // Add Image to Archive
+ {
+ pathOutput := fmt.Sprint("OEBPS/images/", pathBase, ".jpeg")
+ output, err := archive.Create(pathOutput)
+ if err != nil {
+ return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err)
+ }
+ if _, err = output.Write(image.Data); err != nil {
+ return fmt.Errorf("cannot write archive file '%s': %s", pathOutput, err)
+ }
+ }
+
+ ContentImages = append(ContentImages, pathItem)
+ }
+
+ {
+ // Generate Metadata with Templates
+ literals := map[string]any{
+ "ContentTitle": ContentTitle,
+ "ContentDate": ContentDate,
+ "ContentUUID": ContentUUID,
+ "ContentImages": ContentImages,
+ }
+ for _, meta := range [][]string{
+ {"OEBPS/content.opf", "templates/content.opf"},
+ {"OEBPS/toc.ncx", "templates/toc.ncx"},
+ {"META-INF/container.xml", "templates/container.xml"},
+ } {
+ pathOutput := meta[0]
+ pathTemplate := meta[1]
+ tmpl, err := template.ParseFS(templateFS, pathTemplate)
+ if err != nil {
+ return fmt.Errorf("cannot open template file '%s': %s", pathTemplate, err)
+ }
+ output, err := archive.Create(pathOutput)
+ if err != nil {
+ return fmt.Errorf("cannot create archive file '%s': %s", pathOutput, err)
+ }
+ if err := tmpl.Execute(output, literals); err != nil {
+ return fmt.Errorf("cannot execute template file '%s': %s", pathTemplate, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+func CreateDirectory(input *File, filename string) error {
+
+ // Create Output Directory
+ if err := os.MkdirAll(filename, OUTPUT_FLAG); err != nil {
+ return fmt.Errorf("failed to create output dir: %w", err)
+ }
+
+ // Write Images
+ for i, image := range input.Images {
+ imageName := fmt.Sprintf("page%03d.jpeg", i+1)
+ imagePath := path.Join(filename, imageName)
+ if err := os.WriteFile(imagePath, image.Data, OUTPUT_FLAG); err != nil {
+ return fmt.Errorf("failed to write image: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/mangapub/templates/container.xml b/mangapub/templates/container.xml
new file mode 100644
index 0000000..173afcc
--- /dev/null
+++ b/mangapub/templates/container.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mangapub/templates/content.opf b/mangapub/templates/content.opf
new file mode 100644
index 0000000..092a10c
--- /dev/null
+++ b/mangapub/templates/content.opf
@@ -0,0 +1,24 @@
+
+
+
+ {{ .ContentTitle }}
+ en
+ urn:uuid:{{ .ContentUUID }}
+ {{ .ContentDate }}
+ bakonpancakz
+
+
+
+ {{ range .ContentImages }}
+
+ {{ end }}
+ {{ range .ContentImages }}
+
+ {{ end }}
+
+
+ {{ range .ContentImages }}
+
+ {{ end }}
+
+
\ No newline at end of file
diff --git a/mangapub/templates/page.xml b/mangapub/templates/page.xml
new file mode 100644
index 0000000..e6d37d9
--- /dev/null
+++ b/mangapub/templates/page.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Page {{ .ID }}
+
+
+
+
+

+
+
+
\ No newline at end of file
diff --git a/mangapub/templates/toc.ncx b/mangapub/templates/toc.ncx
new file mode 100644
index 0000000..a1852dc
--- /dev/null
+++ b/mangapub/templates/toc.ncx
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+ {{ .ContentTitle }}
+
+
+ {{ range .ContentImages }}
+
+
+ Page {{ .ID }}
+
+
+
+ {{ end }}
+
+
\ No newline at end of file
diff --git a/mediaconvert/main.go b/mediaconvert/main.go
new file mode 100644
index 0000000..79d32ee
--- /dev/null
+++ b/mediaconvert/main.go
@@ -0,0 +1,329 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+ "unsafe"
+)
+
+type QueuedItem struct {
+ Basename string // Filename without Extension
+ Filename string // Filename with Extension
+ Nest []string // Subdirectories
+}
+
+var (
+ OUTPUT_FLAG = os.FileMode(0666)
+ OUTPUT_DIR = "convert"
+ featureResume bool = true
+ featureRecursive bool = false
+ logLines []string
+ logMutex sync.Mutex
+ flags []string
+ queue []QueuedItem
+ itemsRemaining atomic.Int32
+ awaitWorkers sync.WaitGroup
+ workers = 1
+)
+
+func defaultField(m map[string]string, key string) string {
+ if val, ok := m[key]; ok {
+ return val
+ } else {
+ return ""
+ }
+}
+
+// Warning, magic numbers... really janky...
+
+func logUpdate(index int, message string) {
+ logMutex.Lock()
+ logLines[index+1] = fmt.Sprintf("[%02d] %s", index, message)
+ logMutex.Unlock()
+}
+
+func logRenderer() {
+ logLines = make([]string, workers+3)
+ for i := 0; i < len(logLines); i++ {
+ fmt.Println()
+ }
+ for {
+ logMutex.Lock()
+
+ // Footer
+ logLines[len(logLines)-1] = fmt.Sprintf("\rItems Left: %d\n", itemsRemaining.Load())
+
+ // Log Lines
+ fmt.Printf("\033[%dA", len(logLines)+1)
+ for _, line := range logLines {
+ fmt.Printf("\033[2K\r%-80s\n", line)
+ }
+ fmt.Printf("\r")
+
+ logMutex.Unlock()
+ time.Sleep(100 * time.Millisecond)
+ }
+}
+
+func scan(nest []string, extensions []string) {
+ if len(nest) == 1 && strings.EqualFold(nest[0], OUTPUT_DIR) {
+ return
+ }
+ folder := path.Join(nest...)
+ if folder == "" {
+ folder = "."
+ }
+ files, err := os.ReadDir(folder)
+ if err != nil {
+ log.Fatalf("Error reading directory '%s': %s\n", folder, err)
+ }
+ for _, entry := range files {
+ filename := entry.Name()
+
+ // Scan Subdirectory
+ if entry.IsDir() {
+ if featureRecursive {
+ scan(append(nest, filename), extensions)
+ }
+ continue
+ }
+
+ // Match File Extension
+ for _, prefix := range extensions {
+ if len(filename) < len(prefix) {
+ continue
+ }
+ if !strings.EqualFold(filename[len(filename)-len(prefix):], prefix) {
+ continue
+ }
+
+ // Perform Resume Check
+ basename := filename[:strings.LastIndex(filename, ".")]
+ location := fmt.Sprint(path.Join(OUTPUT_DIR, folder, basename), ".", flags[2])
+ if featureResume {
+ if info, err := os.Stat(location); err == nil {
+ if info.Size() != 0 {
+ fmt.Printf("Skipping '%s' as it is already complete\n", filename)
+ continue
+ }
+ }
+ }
+
+ // Add Item to Queue
+ queue = append(queue, QueuedItem{
+ Filename: filename,
+ Basename: basename,
+ Nest: nest,
+ })
+ break
+ }
+ }
+}
+
+func main() {
+ t := time.Now()
+
+ // Enable ANSI escape codes on Windows 10+
+ // I don't remember where I copied this from, sorry... (>_>)
+ if runtime.GOOS == "windows" {
+ stdout := os.Stdout.Fd()
+ var mode uint32
+ proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode")
+ proc.Call(stdout, uintptr(unsafe.Pointer(&mode)))
+ mode |= 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING
+ proc = syscall.NewLazyDLL("kernel32.dll").NewProc("SetConsoleMode")
+ proc.Call(stdout, uintptr(mode))
+ }
+
+ // Collect Arguments
+ for _, arg := range os.Args {
+ before, after, _ := strings.Cut(arg, "=")
+ if strings.EqualFold(before, "--skip-resume") {
+ log.Println("Flag: Disabling Resume Check")
+ featureResume = false
+ continue
+ }
+ if strings.EqualFold(before, "--recursive") {
+ log.Println("Flag: Scanning Recursively")
+ featureRecursive = true
+ continue
+ }
+ if strings.EqualFold(before, "--multithread") {
+ log.Println("Flag: Enabling Multi-threading")
+ workers = runtime.NumCPU()
+ continue
+ }
+ if strings.EqualFold(before, "--output") {
+ info, err := os.Stat(after)
+ if err == nil {
+ if !info.IsDir() {
+ err = fmt.Errorf("Not a directory")
+ }
+ }
+ if err != nil {
+ log.Fatalln("Invalid Output Directory:", err)
+ return
+ }
+ log.Println("Flag: Setting Output Directory:", after)
+ OUTPUT_DIR = after
+ continue
+ }
+ flags = append(flags, arg)
+ }
+ if len(flags) < 3 {
+ fmt.Println("mediaconvert")
+ fmt.Println(" --skip-resume - Skip Resume Checking")
+ fmt.Println(" --multithread - Use Multiple Threads")
+ fmt.Println(" --recursive - Scan Directories Recursively")
+ fmt.Println(" --output={...} - Set Output Directory")
+ fmt.Println(" - File Extension(s) to convert from, delimited with comma")
+ fmt.Println(" - File Extension to convert into")
+ fmt.Println(" [Arguments] - Arguments to pass onto FFMPEG")
+ fmt.Println("Templates:")
+ fmt.Println(" {filename} - Full Filename (e.g. myfile.txt)")
+ fmt.Println(" {basename} - Base Filename (e.g. myfile")
+ fmt.Println(" {directory} - Source Directory (e.g. /path/to/file)")
+ os.Exit(0)
+ }
+
+ // Scan Directory
+ scan([]string{}, strings.Split(flags[1], ","))
+
+ // Startup Workers
+ log.Printf("Queued Files: %d\n", len(queue))
+ log.Printf("Worker Count: %d\n", workers)
+ jobs := make(chan int, len(queue))
+ itemsRemaining.Add(int32(len(queue)))
+
+ if len(queue) > 0 {
+ go logRenderer()
+ }
+
+ for i := 0; i < workers; i++ {
+ awaitWorkers.Add(1)
+ go func(workerID int) {
+ defer awaitWorkers.Done()
+ for i := range jobs {
+ info := queue[i]
+ s := time.Now()
+
+ // Generate Paths
+ directory := path.Join(info.Nest...)
+ srcPath := path.Join(directory, info.Filename)
+ dstPath := fmt.Sprint(path.Join(OUTPUT_DIR, directory, info.Basename), ".", flags[2])
+
+ if err := os.MkdirAll(path.Join(OUTPUT_DIR, directory), OUTPUT_FLAG); err != nil {
+ log.Fatalln("Cannot create output directory:", err)
+ }
+
+ // Compile Arguments
+ args := []string{"-hide_banner", "-y", "-progress", "-", "-i", srcPath}
+ for i := 3; i < len(flags); i++ {
+ str := flags[i]
+ str = strings.ReplaceAll(str, "{basename}", info.Basename)
+ str = strings.ReplaceAll(str, "{filename}", info.Filename)
+ str = strings.ReplaceAll(str, "{directory}", path.Join(info.Nest...))
+ args = append(args, str)
+ }
+ args = append(args, dstPath)
+ proc := exec.Command("ffmpeg", args...)
+
+ // Collect Error Output
+ errors := bytes.Buffer{}
+ stderr, err := proc.StderrPipe()
+ if err != nil {
+ log.Fatalf("Failed to open Error Output: %s\n", err)
+ }
+ go func() {
+ scanner := bufio.NewScanner(stderr)
+ for scanner.Scan() {
+ errors.Write(scanner.Bytes())
+ errors.WriteRune('\n')
+ }
+ }()
+
+ // Collect Progress Output
+ output, err := proc.StdoutPipe()
+ if err != nil {
+ log.Fatalf("Failed to open Standard Output: %s\n", err)
+ }
+ go func() {
+ for {
+ buffer := make([]byte, 256)
+ r, err := output.Read(buffer)
+ if err != nil {
+ break
+ }
+ progress := map[string]string{}
+ metadata := strings.Split(string(buffer[:r]), "\n")
+ for _, line := range metadata {
+ values := strings.Split(line, "=")
+ if len(values) == 2 {
+ key := strings.TrimSpace(values[0])
+ val := strings.TrimSpace(values[1])
+ progress[key] = val
+ }
+ }
+ switch defaultField(progress, "progress") {
+ case "continue":
+ // Clear Output and Display Progress
+ sizeTotal := defaultField(progress, "total_size")
+ sizeFloat, _ := strconv.ParseFloat(sizeTotal, 64)
+ sizeValue := strconv.FormatFloat(sizeFloat/1024/1024, 'f', 2, 64)
+ logUpdate(workerID, fmt.Sprintf(
+ "Time: %s, Bitrate: %s, FPS: %s/%s/%s, Size: %sMB (%s)",
+ defaultField(progress, "out_time"),
+ defaultField(progress, "bitrate"),
+ defaultField(progress, "fps"),
+ defaultField(progress, "drop_frames"),
+ defaultField(progress, "dup_frames"),
+ sizeValue,
+ defaultField(progress, "speed"),
+ ))
+ case "end":
+ // Clear Output and Display Completion Time
+ logUpdate(workerID, fmt.Sprintf(
+ "Processing Completed in %s",
+ time.Since(s),
+ ))
+ }
+ }
+ }()
+
+ // Start Processing
+ if err := proc.Run(); err != nil {
+ exitcode := -1
+ if proc.ProcessState != nil {
+ exitcode = proc.ProcessState.ExitCode()
+ }
+ log.Printf("Processing Failed for '%s' with code: %d\n%s\n\n",
+ srcPath, exitcode, errors.String())
+ os.Exit(1)
+ }
+ itemsRemaining.Add(-1)
+ }
+ }(i)
+ }
+
+ // Begin Processing
+ for i := 0; i < len(queue); i++ {
+ jobs <- i
+ }
+ close(jobs)
+ awaitWorkers.Wait()
+
+ // Processing Complete
+ log.Printf("Processing Completed in %s\n", time.Since(t))
+}