mirror of
https://github.com/Belphemur/CBZOptimizer.git
synced 2026-07-22 03:15:41 +02:00
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
This commit is contained in:
@@ -58,6 +58,12 @@ cbzconverter optimize [folder] --format=webp
|
||||
cbzconverter optimize [folder] --format WEBP
|
||||
```
|
||||
|
||||
Preserve original page filenames in the output CBZ instead of sequential renumbering:
|
||||
|
||||
```sh
|
||||
cbzconverter optimize [folder] --keep-filenames --quality 85 --format webp
|
||||
```
|
||||
|
||||
With timeout to avoid hanging on problematic chapters:
|
||||
|
||||
```sh
|
||||
@@ -103,6 +109,7 @@ docker run -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest watc
|
||||
- `--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.
|
||||
|
||||
@@ -70,6 +70,19 @@ func setupSplitFlag(cmd *cobra.Command, defaultValue bool, bindViper bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// setupKeepFilenamesFlag sets up the keep-filenames flag for a command.
|
||||
//
|
||||
// Parameters:
|
||||
// - cmd: The Cobra command to add the keep-filenames flag to
|
||||
// - defaultValue: The default keep-filenames value
|
||||
// - bindViper: If true, binds the flag to viper for configuration file support
|
||||
func setupKeepFilenamesFlag(cmd *cobra.Command, defaultValue bool, bindViper bool) {
|
||||
cmd.Flags().Bool("keep-filenames", defaultValue, "Preserve original page filenames instead of renumbering to sequential indices")
|
||||
if bindViper {
|
||||
_ = viper.BindPFlag("keep-filenames", cmd.Flags().Lookup("keep-filenames"))
|
||||
}
|
||||
}
|
||||
|
||||
// setupTimeoutFlag sets up the timeout flag for a command.
|
||||
//
|
||||
// Parameters:
|
||||
@@ -96,5 +109,6 @@ func setupCommonFlags(cmd *cobra.Command, converterType *constant.ConversionForm
|
||||
setupQualityFlag(cmd, qualityDefault, bindViper)
|
||||
setupOverrideFlag(cmd, overrideDefault, bindViper)
|
||||
setupSplitFlag(cmd, splitDefault, bindViper)
|
||||
setupKeepFilenamesFlag(cmd, false, bindViper)
|
||||
setupTimeoutFlag(cmd, bindViper)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,13 @@ func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
log.Debug().Bool("split", split).Msg("Split parameter parsed")
|
||||
|
||||
keepFilenames, err := cmd.Flags().GetBool("keep-filenames")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to parse keep-filenames flag")
|
||||
return fmt.Errorf("invalid keep-filenames value")
|
||||
}
|
||||
log.Debug().Bool("keep-filenames", keepFilenames).Msg("Keep-filenames parameter parsed")
|
||||
|
||||
timeout, err := cmd.Flags().GetDuration("timeout")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to parse timeout flag")
|
||||
@@ -121,14 +128,15 @@ func ConvertCbzCommand(cmd *cobra.Command, args []string) error {
|
||||
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,
|
||||
Timeout: timeout,
|
||||
})
|
||||
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()
|
||||
|
||||
@@ -86,6 +86,7 @@ func TestConvertCbzCommand(t *testing.T) {
|
||||
cmd.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel")
|
||||
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
|
||||
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
|
||||
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
|
||||
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout")
|
||||
|
||||
// Execute the command
|
||||
@@ -197,6 +198,7 @@ func setupTestCommand(t *testing.T) (*cobra.Command, func()) {
|
||||
cmd.Flags().IntP("parallelism", "n", 1, "Number of chapters to convert in parallel")
|
||||
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
|
||||
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
|
||||
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
|
||||
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
|
||||
|
||||
// Reset converterType to default before test for consistency
|
||||
@@ -433,6 +435,7 @@ func TestConvertCbzCommand_ManyFiles_NoDeadlock(t *testing.T) {
|
||||
cmd.Flags().IntP("parallelism", "n", 2, "Number of chapters to convert in parallel")
|
||||
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
|
||||
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
|
||||
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
|
||||
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
|
||||
|
||||
converterType = constant.DefaultConversion
|
||||
@@ -535,6 +538,7 @@ func TestConvertCbzCommand_HighParallelism_NoDeadlock(t *testing.T) {
|
||||
cmd.Flags().IntP("parallelism", "n", 8, "Number of chapters to convert in parallel")
|
||||
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
|
||||
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
|
||||
cmd.Flags().Bool("keep-filenames", false, "Preserve original page filenames instead of renumbering to sequential indices")
|
||||
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
|
||||
|
||||
converterType = constant.DefaultConversion
|
||||
|
||||
@@ -65,6 +65,8 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
|
||||
split := viper.GetBool("split")
|
||||
|
||||
keepFilenames := viper.GetBool("keep-filenames")
|
||||
|
||||
timeout := viper.GetDuration("timeout")
|
||||
|
||||
backfill := viper.GetBool("backfill")
|
||||
@@ -79,7 +81,7 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
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("backfill", backfill).Msg("Watching directory")
|
||||
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 {
|
||||
@@ -98,6 +100,7 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
Quality: quality,
|
||||
Override: override,
|
||||
Split: split,
|
||||
KeepFilenames: keepFilenames,
|
||||
Timeout: timeout,
|
||||
})
|
||||
defer queue.Stop()
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
|
||||
@@ -12,6 +14,49 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// resolvePageName picks the final archive entry name for a page.
|
||||
//
|
||||
// Order of resolution:
|
||||
// 1. When page.OriginalName is set (keep-filenames mode), use its stem with
|
||||
// the current page.Extension. Split pages append the -NN suffix so all
|
||||
// parts of the same source still land in the archive.
|
||||
// 2. If that final name has already been used in this archive, fall back to
|
||||
// the indexed form (stem + "_%04d" + extension) so the zip stays valid.
|
||||
// 3. Otherwise (OriginalName is empty), use the historical %04d / %04d-NN
|
||||
// sequential naming.
|
||||
//
|
||||
// usedNames tracks every name already chosen for this archive and is mutated
|
||||
// in place so the caller can keep a single map across the whole chapter.
|
||||
func resolvePageName(page *manga.PageFile, usedNames map[string]struct{}) string {
|
||||
if page.OriginalName != "" {
|
||||
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
|
||||
var candidate string
|
||||
if page.IsSplitted {
|
||||
candidate = fmt.Sprintf("%s-%02d%s", stem, page.SplitPartIndex, page.Extension)
|
||||
} else {
|
||||
candidate = stem + page.Extension
|
||||
}
|
||||
if _, taken := usedNames[candidate]; !taken {
|
||||
usedNames[candidate] = struct{}{}
|
||||
return candidate
|
||||
}
|
||||
// Collision: fall back to the indexed form so the entry still gets
|
||||
// written, but make it visibly distinct from the preserved one.
|
||||
fallback := fmt.Sprintf("%s_%04d%s", stem, page.Index, page.Extension)
|
||||
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) {
|
||||
@@ -34,14 +79,14 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) (err error
|
||||
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
|
||||
// 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 {
|
||||
var fileName string
|
||||
if page.IsSplitted {
|
||||
fileName = fmt.Sprintf("%04d-%02d%s", page.Index, page.SplitPartIndex, page.Extension)
|
||||
} else {
|
||||
fileName = fmt.Sprintf("%04d%s", page.Index, page.Extension)
|
||||
}
|
||||
fileName := resolvePageName(page, usedNames)
|
||||
|
||||
log.Debug().
|
||||
Str("output_path", outputFilePath).
|
||||
|
||||
@@ -101,6 +101,70 @@ func TestWriteChapterToCBZ(t *testing.T) {
|
||||
},
|
||||
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 {
|
||||
|
||||
@@ -142,7 +142,13 @@ func IsAlreadyConverted(ctx context.Context, filePath string) (converted bool, e
|
||||
// ExtractChapter extracts an archive (CBZ/CBR) to a temp directory on disk.
|
||||
// Pages are streamed directly to files — no image data is held in memory.
|
||||
// Returns a Chapter with PageFile entries pointing to extracted files.
|
||||
func ExtractChapter(ctx context.Context, filePath string) (*manga.Chapter, error) {
|
||||
//
|
||||
// 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
|
||||
@@ -279,6 +285,9 @@ func ExtractChapter(ctx context.Context, filePath string) (*manga.Chapter, error
|
||||
Extension: ext,
|
||||
FilePath: outputPath,
|
||||
}
|
||||
if keepFilenames {
|
||||
page.OriginalName = filepath.Base(path)
|
||||
}
|
||||
chapter.Pages = append(chapter.Pages, page)
|
||||
|
||||
log.Debug().
|
||||
@@ -320,8 +329,9 @@ func isJunkFile(path string) bool {
|
||||
}
|
||||
|
||||
// LoadChapter extracts the chapter from a CBZ/CBR file to disk.
|
||||
// It delegates to ExtractChapter and always extracts all pages.
|
||||
// Use IsAlreadyConverted for a fast conversion status check without extraction.
|
||||
// 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)
|
||||
return ExtractChapter(context.Background(), filePath, false)
|
||||
}
|
||||
|
||||
@@ -157,12 +157,12 @@ func TestIsAlreadyConverted_CBZWithNonDateComment(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractChapter_NonexistentFile(t *testing.T) {
|
||||
_, err := ExtractChapter(context.Background(), "/nonexistent/file.cbz")
|
||||
_, 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")
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
@@ -174,17 +174,36 @@ func TestExtractChapter_PageExtensions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractChapter_PagesHaveSequentialIndices(t *testing.T) {
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractChapter_Cleanup(t *testing.T) {
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz")
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 128.cbz", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
tempDir := chapter.TempDir
|
||||
@@ -199,7 +218,7 @@ func TestExtractChapter_Cleanup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractChapter_CBR(t *testing.T) {
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 1.cbr")
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 1.cbr", false)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
@@ -212,7 +231,7 @@ func TestExtractChapter_CBR(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractChapter_ConvertedStatus(t *testing.T) {
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 10_converted.cbz")
|
||||
chapter, err := ExtractChapter(context.Background(), "../../testdata/Chapter 10_converted.cbz", false)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
@@ -356,7 +375,7 @@ func TestExtractChapter_WithConvertedTxt(t *testing.T) {
|
||||
_ = w.Close()
|
||||
_ = f.Close()
|
||||
|
||||
chapter, err := ExtractChapter(context.Background(), cbzPath)
|
||||
chapter, err := ExtractChapter(context.Background(), cbzPath, false)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = chapter.Cleanup() }()
|
||||
|
||||
|
||||
@@ -13,4 +13,10 @@ type PageFile struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -21,7 +21,12 @@ type OptimizeOptions struct {
|
||||
Quality uint8
|
||||
Override bool
|
||||
Split bool
|
||||
Timeout time.Duration
|
||||
// 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.
|
||||
@@ -38,6 +43,7 @@ func Optimize(options *OptimizeOptions) error {
|
||||
Uint8("quality", options.Quality).
|
||||
Bool("override", options.Override).
|
||||
Bool("split", options.Split).
|
||||
Bool("keep_filenames", options.KeepFilenames).
|
||||
Msg("Optimization parameters")
|
||||
|
||||
// Step 1: Fast conversion check before extracting (new requirement)
|
||||
@@ -64,7 +70,7 @@ func Optimize(options *OptimizeOptions) error {
|
||||
extractCtx = context.Background()
|
||||
}
|
||||
|
||||
chapter, err := cbz.ExtractChapter(extractCtx, options.Path)
|
||||
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)
|
||||
|
||||
@@ -25,6 +25,26 @@ import (
|
||||
|
||||
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
|
||||
@@ -218,14 +238,16 @@ func (converter *Converter) convertPageFile(ctx context.Context, page *manga.Pag
|
||||
Str("input", page.FilePath).
|
||||
Msg("Converting page file")
|
||||
|
||||
// If the page is already WebP, just return it as-is
|
||||
// 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, fmt.Sprintf("%04d.webp", page.Index))
|
||||
outputPath := filepath.Join(outputDir, intermediatePageName(page, ""))
|
||||
err := EncodeFile(page.FilePath, outputPath, uint(quality))
|
||||
|
||||
if err == nil {
|
||||
@@ -316,7 +338,7 @@ func (converter *Converter) splitAndConvert(ctx context.Context, page *manga.Pag
|
||||
partHeight = height - yOffset
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(outputDir, fmt.Sprintf("%04d-%02d.webp", page.Index, i))
|
||||
outputPath := filepath.Join(outputDir, intermediatePageName(page, fmt.Sprintf("-%02d", i)))
|
||||
err := EncodeFileWithCrop(page.FilePath, outputPath, uint(quality), 0, yOffset, width, partHeight)
|
||||
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user