mirror of
https://github.com/Belphemur/CBZOptimizer.git
synced 2026-07-22 03:15:41 +02:00
- 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.
170 lines
5.9 KiB
Go
170 lines
5.9 KiB
Go
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
|
|
}
|