| -rw-r--r-- | config/config-b2.sample.json | 126 | ||||
| -rw-r--r-- | config/config-local-unix.sample.json | 20 | ||||
| -rw-r--r-- | config/config.go | 98 | ||||
| -rw-r--r-- | internal/client/input/b2.go | 3 | ||||
| -rw-r--r-- | internal/client/input/input_client_interface.go | 1 | ||||
| -rw-r--r-- | internal/client/input/local_unix.go | 7 | ||||
| -rw-r--r-- | internal/client/output/b2.go | 3 | ||||
| -rw-r--r-- | internal/client/output/local_unix.go | 7 | ||||
| -rw-r--r-- | internal/client/output/output_client_interface.go | 3 | ||||
| -rw-r--r-- | internal/converter/converter_interface.go | 6 | ||||
| -rw-r--r-- | internal/converter/webp.go | 52 | ||||
| -rw-r--r-- | main.go | 97 |
12 files changed, 150 insertions, 273 deletions
diff --git a/config/config-b2.sample.json b/config/config-b2.sample.json index 043c1a6..c48b1b2 100644 --- a/config/config-b2.sample.json +++ b/config/config-b2.sample.json @@ -20,116 +20,26 @@ "png" ] }, - "Converters": [ - { - "Type": "webp", - "Config": { - "Quality": 80, - "Size": { - "MaxWidth": 320, - "MaxHeight": 0 - } - }, - "Output": { - "Storage": { - "Type": "b2", - "Config": { - "BucketName": "sayana-photos", - "Region": "eu-central-003", - "Prefix": "webp-320p/", - "KeyID": "${B2_KEY_ID}", - "ApplicationKey": "${B2_APPLICATION_KEY}" - } - } - } - }, - { - "Type": "webp", - "Config": { - "Quality": 80, - "Size": { - "MaxWidth": 560, - "MaxHeight": 0 - } - }, - "Output": { - "Storage": { - "Type": "b2", - "Config": { - "BucketName": "sayana-photos", - "Region": "eu-central-003", - "Prefix": "webp-560p/", - "KeyID": "${B2_KEY_ID}", - "ApplicationKey": "${B2_APPLICATION_KEY}" - } - } - } - }, - { - "Type": "webp", - "Config": { - "Quality": 80, - "Size": { - "MaxWidth": 800, - "MaxHeight": 0 - } - }, - "Output": { - "Storage": { - "Type": "b2", - "Config": { - "BucketName": "sayana-photos", - "Region": "eu-central-003", - "Prefix": "webp-800p/", - "KeyID": "${B2_KEY_ID}", - "ApplicationKey": "${B2_APPLICATION_KEY}" - } - } - } - }, - { - "Type": "webp", - "Config": { - "Quality": 80, - "Size": { - "MaxWidth": 1200, - "MaxHeight": 0 - } - }, - "Output": { - "Storage": { - "Type": "b2", - "Config": { - "BucketName": "sayana-photos", - "Region": "eu-central-003", - "Prefix": "webp-1200p/", - "KeyID": "${B2_KEY_ID}", - "ApplicationKey": "${B2_APPLICATION_KEY}" - } - } + "Converter": { + "Type": "webp", + "Config": { + "Quality": 80, + "Size": { + "MaxWidth": 800, + "MaxHeight": 0 } - }, - { - "Type": "webp", + } + }, + "Output": { + "Storage": { + "Type": "b2", "Config": { - "Quality": 80, - "Size": { - "MaxWidth": 1600, - "MaxHeight": 0 - } - }, - "Output": { - "Storage": { - "Type": "b2", - "Config": { - "BucketName": "sayana-photos", - "Region": "eu-central-003", - "Prefix": "webp-1600p/", - "KeyID": "${B2_KEY_ID}", - "ApplicationKey": "${B2_APPLICATION_KEY}" - } - } + "BucketName": "sayana-photos", + "Region": "eu-central-003", + "Prefix": "thumbnails/", + "KeyID": "${B2_KEY_ID}", + "ApplicationKey": "${B2_APPLICATION_KEY}" } } - ] + } }
\ No newline at end of file diff --git a/config/config-local-unix.sample.json b/config/config-local-unix.sample.json index 97f69f1..36d8114 100644 --- a/config/config-local-unix.sample.json +++ b/config/config-local-unix.sample.json @@ -25,16 +25,16 @@ "MaxWidth": 800, "MaxHeight": 0 } - }, - "Output": { - "Storage": { - "Type": "local-unix", - "Config": { - "Path": "/tmp/thumbnailing/thumbnails/", - "DirPermissionMode": "0755", - "FilePermissionMode": "0644", - "AttributesImplementation": "xattr" - } + } + }, + "Output": { + "Storage": { + "Type": "local-unix", + "Config": { + "Path": "/tmp/thumbnailing/thumbnails/", + "DirPermissionMode": "0755", + "FilePermissionMode": "0644", + "AttributesImplementation": "xattr" } } } diff --git a/config/config.go b/config/config.go index ab051b2..5b05ce9 100644 --- a/config/config.go +++ b/config/config.go @@ -10,12 +10,13 @@ import ( ) type Config struct { - Input InputConfig `json:"Input" validate:"required"` - Converters []ConverterConfig `json:"Converters" validate:"required"` - MaxProcessThreads int `json:"MaxProcessThreads" validate:"required,min=1"` - MaxPreProcessThreads int `json:"MaxPreProcessThreads" validate:"min=1;gtefield=MaxProcessThreads"` - ForceRewrite bool `json:"ForceRewrite" validate:"required"` - LogLevel slog.Level `json:"LogLevel" validate:"required"` + Input InputConfig `json:"Input" validate:"required"` + Converter ConverterConfig `json:"Converter" validate:"required"` + Output OutputConfig `json:"Output" validate:"required"` + MaxProcessThreads int `json:"MaxProcessThreads" validate:"required,min=1"` + MaxPreProcessThreads int `json:"MaxPreProcessThreads" validate:"min=1;gtefield=MaxProcessThreads"` + ForceRewrite bool `json:"ForceRewrite" validate:"required"` + LogLevel slog.Level `json:"LogLevel" validate:"required"` } type InputConfig struct { @@ -60,50 +61,6 @@ func (sc *InputStorageConfig) UnmarshalJSON(data []byte) error { return nil } -type ConverterConfig struct { - Type string `json:"Type" validate:"required,oneof=webp"` - Config any `json:"Config" validate:"required"` - Output OutputConfig `json:"Output" validate:"required"` -} - -func (pc *ConverterConfig) UnmarshalJSON(data []byte) error { - var tmp struct { - Type string `json:"Type"` - Config json.RawMessage `json:"Config"` - Output OutputConfig `json:"Output"` - } - - if err := json.Unmarshal(data, &tmp); err != nil { - return err - } - - pc.Type = tmp.Type - pc.Output = tmp.Output - - switch tmp.Type { - case "webp": - var webpConfig WebpConfig - if err := json.Unmarshal(tmp.Config, &webpConfig); err != nil { - return fmt.Errorf("unmarshal WebpConfig: %w", err) - } - pc.Config = &webpConfig - default: - return fmt.Errorf("unsupported storage type: %s", tmp.Type) - } - - return nil -} - -type WebpConfig struct { - Quality int `json:"Quality" validate:"required,min=1,max=100"` - Size SizeConfig `json:"Size" validate:"required"` -} - -type SizeConfig struct { - MaxWidth int `json:"MaxWidth" validate:"required,min=0"` - MaxHeight int `json:"MaxHeight" validate:"required,min=0"` -} - type OutputStorageConfig struct { Type string `json:"Type" validate:"required,oneof=b2 local-unix"` Config any `json:"Config" validate:"required"` @@ -165,6 +122,47 @@ type OutputLocalUnixConfig struct { AttributesImplementation string `json:"AttributesImplementation" validate:"required,oneof=xattr none"` } +type ConverterConfig struct { + Type string `json:"Type" validate:"required,oneof=webp"` + Config any `json:"Config" validate:"required"` +} + +func (pc *ConverterConfig) UnmarshalJSON(data []byte) error { + var tmp struct { + Type string `json:"Type"` + Config json.RawMessage `json:"Config"` + } + + if err := json.Unmarshal(data, &tmp); err != nil { + return err + } + + pc.Type = tmp.Type + + switch tmp.Type { + case "webp": + var webpConfig WebpConfig + if err := json.Unmarshal(tmp.Config, &webpConfig); err != nil { + return fmt.Errorf("unmarshal WebpConfig: %w", err) + } + pc.Config = &webpConfig + default: + return fmt.Errorf("unsupported storage type: %s", tmp.Type) + } + + return nil +} + +type WebpConfig struct { + Quality int `json:"Quality" validate:"required,min=1,max=100"` + Size SizeConfig `json:"Size" validate:"required"` +} + +type SizeConfig struct { + MaxWidth int `json:"MaxWidth" validate:"required,min=0"` + MaxHeight int `json:"MaxHeight" validate:"required,min=0"` +} + func LoadConfig(path string, config *Config) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/internal/client/input/b2.go b/internal/client/input/b2.go index db68268..0092313 100644 --- a/internal/client/input/b2.go +++ b/internal/client/input/b2.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "slices" + "strconv" "strings" "github.com/Backblaze/blazer/b2" @@ -101,8 +102,8 @@ func (c *B2InputClient) ReadMetadata(path string) (*MetadataStruct, error) { FirstCreated: attrs.UploadTimestamp, LastModified: attrs.LastModified, Misc: attrs.Info, - Size: attrs.Size, } + metadata.Misc["Size"] = strconv.FormatInt(attrs.Size, 10) switch attrs.Status { case b2.Uploaded: diff --git a/internal/client/input/input_client_interface.go b/internal/client/input/input_client_interface.go index 4930aa0..5d12fa8 100644 --- a/internal/client/input/input_client_interface.go +++ b/internal/client/input/input_client_interface.go @@ -20,7 +20,6 @@ type MetadataStruct struct { ContentType string FirstCreated time.Time LastModified time.Time - Size int64 Misc map[string]string } diff --git a/internal/client/input/local_unix.go b/internal/client/input/local_unix.go index 27a7947..2df3a41 100644 --- a/internal/client/input/local_unix.go +++ b/internal/client/input/local_unix.go @@ -87,6 +87,10 @@ func (c *LocalUnixInputClient) ReadMetadata(path string) (*MetadataStruct, error stat_t := fileInfo.Sys().(*syscall.Stat_t) creationTime := time.Unix(stat_t.Ctim.Sec, stat_t.Ctim.Nsec) + misc := map[string]string{ + "Size": strconv.FormatInt(fileInfo.Size(), 10), + } + return &MetadataStruct{ Name: fileInfo.Name(), StorageType: "local-unix", @@ -94,8 +98,7 @@ func (c *LocalUnixInputClient) ReadMetadata(path string) (*MetadataStruct, error ContentType: mime.TypeByExtension("." + nodeExt), FirstCreated: creationTime, LastModified: fileInfo.ModTime(), - Size: fileInfo.Size(), - Misc: map[string]string{}, + Misc: misc, }, nil } diff --git a/internal/client/output/b2.go b/internal/client/output/b2.go index f53532a..e741e25 100644 --- a/internal/client/output/b2.go +++ b/internal/client/output/b2.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strconv" "github.com/Backblaze/blazer/b2" "github.com/SayaAndy/saya-today-thumbnail-generator/config" @@ -69,8 +70,8 @@ func (c *B2OutputClient) ReadMetadata(path string) (*MetadataStruct, error) { FirstCreated: attrs.UploadTimestamp, LastModified: attrs.LastModified, Misc: attrs.Info, - Size: attrs.Size, } + metadata.Misc["Size"] = strconv.FormatInt(attrs.Size, 10) switch attrs.Status { case b2.Uploaded: diff --git a/internal/client/output/local_unix.go b/internal/client/output/local_unix.go index dd53e79..b106726 100644 --- a/internal/client/output/local_unix.go +++ b/internal/client/output/local_unix.go @@ -93,6 +93,10 @@ func (c *LocalUnixOutputClient) ReadMetadata(path string) (*MetadataStruct, erro stat_t := fileInfo.Sys().(*syscall.Stat_t) creationTime := time.Unix(stat_t.Ctim.Sec, stat_t.Ctim.Nsec) + misc := map[string]string{ + "Size": strconv.FormatInt(fileInfo.Size(), 10), + } + mddateOriginal := make([]byte, 0) switch c.attrMode { case "xattr": @@ -118,8 +122,7 @@ func (c *LocalUnixOutputClient) ReadMetadata(path string) (*MetadataStruct, erro ContentType: mime.TypeByExtension("." + nodeExt), FirstCreated: creationTime, LastModified: fileInfo.ModTime(), - Size: fileInfo.Size(), - Misc: map[string]string{}, + Misc: misc, }, nil } diff --git a/internal/client/output/output_client_interface.go b/internal/client/output/output_client_interface.go index c74851d..437895c 100644 --- a/internal/client/output/output_client_interface.go +++ b/internal/client/output/output_client_interface.go @@ -10,7 +10,7 @@ import ( type OutputClient interface { GetWriter(path string, inputMetadata *input.MetadataStruct) (io.WriteCloser, error) - ReadMetadata(path string) (*MetadataStruct, error) + ReadMetadata(string) (*MetadataStruct, error) IsMissing(path string) bool } @@ -22,7 +22,6 @@ type MetadataStruct struct { ContentType string FirstCreated time.Time LastModified time.Time - Size int64 Misc map[string]string } diff --git a/internal/converter/converter_interface.go b/internal/converter/converter_interface.go index fda23d1..342ca1e 100644 --- a/internal/converter/converter_interface.go +++ b/internal/converter/converter_interface.go @@ -4,15 +4,11 @@ import ( "io" "github.com/SayaAndy/saya-today-thumbnail-generator/config" - "github.com/SayaAndy/saya-today-thumbnail-generator/internal/client/input" - "github.com/SayaAndy/saya-today-thumbnail-generator/internal/client/output" ) type Converter interface { - Process(inputMetadata *input.MetadataStruct, reader io.Reader, outputName string) error DeductOutputPath(inputPath string) string - ReadMetadata(path string) (*output.MetadataStruct, error) - IsMissing(path string) bool + Process(ext string, reader io.ReadCloser, writer io.WriteCloser) error } var NewConverterMap = map[string]func(cfg *config.ConverterConfig) (Converter, error){ diff --git a/internal/converter/webp.go b/internal/converter/webp.go index 8cfe5a7..8a9c09a 100644 --- a/internal/converter/webp.go +++ b/internal/converter/webp.go @@ -12,8 +12,6 @@ import ( "golang.org/x/image/draw" "github.com/SayaAndy/saya-today-thumbnail-generator/config" - "github.com/SayaAndy/saya-today-thumbnail-generator/internal/client/input" - "github.com/SayaAndy/saya-today-thumbnail-generator/internal/client/output" "github.com/kolesa-team/go-webp/encoder" "github.com/kolesa-team/go-webp/webp" ) @@ -21,10 +19,9 @@ import ( var _ Converter = (*WebpConverter)(nil) type WebpConverter struct { - maxWidth int - maxHeight int - quality int - outputClient output.OutputClient + maxWidth int + maxHeight int + quality int } func NewWebpConverter(cfg *config.ConverterConfig) (Converter, error) { @@ -33,24 +30,26 @@ func NewWebpConverter(cfg *config.ConverterConfig) (Converter, error) { } webpCfg := cfg.Config.(*config.WebpConfig) - outputClient, err := output.NewOutputClientMap[cfg.Output.Storage.Type](&cfg.Output) - if err != nil { - return nil, fmt.Errorf("fail to initialize output client: %w", err) - } + return &WebpConverter{webpCfg.Size.MaxWidth, webpCfg.Size.MaxHeight, webpCfg.Quality}, nil +} - return &WebpConverter{webpCfg.Size.MaxWidth, webpCfg.Size.MaxHeight, webpCfg.Quality, outputClient}, nil +func (p *WebpConverter) DeductOutputPath(inputPath string) string { + pathParts := strings.Split(inputPath, ".") + if len(pathParts) < 2 { + return inputPath + ".webp" + } + pathParts[len(pathParts)-1] = "webp" + return strings.Join(pathParts, ".") } -func (p *WebpConverter) Process(inputMetadata *input.MetadataStruct, reader io.Reader, outputName string) error { +func (p *WebpConverter) Process(contentType string, reader io.ReadCloser, writer io.WriteCloser) error { var src image.Image + var err error - writer, err := p.outputClient.GetWriter(outputName, inputMetadata) - if err != nil { - return fmt.Errorf("fail to initialize writer for output: %w", err) - } + defer reader.Close() defer writer.Close() - switch inputMetadata.ContentType { + switch contentType { case "image/jpeg": src, err = jpeg.Decode(reader) if err != nil { @@ -62,7 +61,7 @@ func (p *WebpConverter) Process(inputMetadata *input.MetadataStruct, reader io.R return fmt.Errorf("decode png: %w", err) } default: - return fmt.Errorf("unsupported content type: %s", inputMetadata.ContentType) + return fmt.Errorf("unsupported content type: %s", contentType) } opts, err := encoder.NewLossyEncoderOptions(encoder.PresetDefault, float32(p.quality)) @@ -94,20 +93,3 @@ func (p *WebpConverter) Process(inputMetadata *input.MetadataStruct, reader io.R return webp.Encode(writer, dst, opts) } - -func (p *WebpConverter) DeductOutputPath(inputPath string) string { - pathParts := strings.Split(inputPath, ".") - if len(pathParts) < 2 { - return inputPath + ".webp" - } - pathParts[len(pathParts)-1] = "webp" - return strings.Join(pathParts, ".") -} - -func (p *WebpConverter) ReadMetadata(path string) (*output.MetadataStruct, error) { - return p.outputClient.ReadMetadata(path) -} - -func (p *WebpConverter) IsMissing(path string) bool { - return p.outputClient.IsMissing(path) -} @@ -1,17 +1,16 @@ package main import ( - "bytes" "flag" "log/slog" "os" "os/signal" - "strings" "sync" "syscall" "github.com/SayaAndy/saya-today-thumbnail-generator/config" "github.com/SayaAndy/saya-today-thumbnail-generator/internal/client/input" + "github.com/SayaAndy/saya-today-thumbnail-generator/internal/client/output" "github.com/SayaAndy/saya-today-thumbnail-generator/internal/converter" ) @@ -47,20 +46,24 @@ func main() { os.Exit(1) } - converters := make([]converter.Converter, 0, len(cfg.Converters)) - var converterTypes []string - for _, converterCfg := range cfg.Converters { - conv, err := converter.NewConverterMap[converterCfg.Type](&converterCfg) - if err != nil { - slog.Error("fail to initialize converter", slog.String("error", err.Error())) - os.Exit(1) - } - converters = append(converters, conv) - converterTypes = append(converterTypes, converterCfg.Type) + outputClient, err := output.NewOutputClientMap[cfg.Output.Storage.Type](&cfg.Output) + if err != nil { + slog.Error("fail to initialize output client", slog.String("error", err.Error())) + os.Exit(1) + } + + conv, err := converter.NewConverterMap[cfg.Converter.Type](&cfg.Converter) + if err != nil { + slog.Error("fail to initialize converter", slog.String("error", err.Error())) + os.Exit(1) } - generalLogger := slog.With(slog.String("input_storage", cfg.Input.Storage.Type)) - generalLogger.Info("initialized input client and converters", slog.String("converter_types", strings.Join(converterTypes, " "))) + generalLogger := slog.With( + slog.String("input_storage", cfg.Input.Storage.Type), + slog.String("converter_type", cfg.Converter.Type), + slog.String("output_storage", cfg.Output.Storage.Type), + ) + generalLogger.Info("initialized clients and converter") select { case <-sigTermChan: @@ -74,8 +77,7 @@ func main() { generalLogger.Error("fail to scan input files", slog.String("error", err.Error())) os.Exit(1) } - fileCount := len(files) - generalLogger.Info("scanned files", slog.Int("file_count", fileCount)) + generalLogger.Info("scanned files", slog.Int("file_count", len(files))) select { case <-sigTermChan: @@ -87,12 +89,13 @@ func main() { processSemaphore := make(chan struct{}, cfg.MaxProcessThreads) queueSemaphore := make(chan struct{}, cfg.MaxPreProcessThreads) var wg sync.WaitGroup - wg.Add(fileCount) + wg.Add(len(files)) for i, file := range files { queueSemaphore <- struct{}{} go func(index int, inputName string) { - fileLogger := generalLogger.With(slog.String("input_path", inputName), slog.Int("file_index", index)) + outputName := conv.DeductOutputPath(inputName) + fileLogger := generalLogger.With(slog.String("input_path", inputName), slog.String("output_path", outputName), slog.Int("file_index", index)) threadSigTermChannel := make(chan os.Signal, 1) signal.Notify(threadSigTermChannel, os.Interrupt, syscall.SIGTERM) @@ -112,60 +115,42 @@ func main() { return } - convertersToLaunch := []int{} - for j, conv := range converters { - outputName := conv.DeductOutputPath(inputName) - originalInputHash := "" - convLogger := fileLogger.With(slog.String("output_path", outputName), slog.Int("conv_index", j)) - - if !cfg.ForceRewrite && !conv.IsMissing(outputName) { - outputMetadata, err := conv.ReadMetadata(outputName) - if err != nil { - convLogger.Warn("fail to read metadata of (supposedly existing) output file", slog.String("error", err.Error())) - continue - } - originalInputHash = outputMetadata.HashOriginal - if inputMetadata.Hash == originalInputHash { - convLogger.Info("skip already processed file (based on equal hash)", slog.String("input_hash", inputMetadata.Hash)) - continue - } + originalInputHash := "" + if !cfg.ForceRewrite && !outputClient.IsMissing(outputName) { + outputMetadata, err := outputClient.ReadMetadata(outputName) + if err != nil { + fileLogger.Warn("fail to read metadata of (supposedly existing) output file", slog.String("error", err.Error())) + return + } + originalInputHash = outputMetadata.HashOriginal + if inputMetadata.Hash == originalInputHash { + fileLogger.Info("skip already processed file (based on equal hash)", slog.String("input_hash", inputMetadata.Hash)) + return } - convertersToLaunch = append(convertersToLaunch, j) - } - - if len(convertersToLaunch) == 0 { - return } processSemaphore <- struct{}{} defer func() { <-processSemaphore }() - fileLogger.Info("start to process file", slog.String("input_hash", inputMetadata.Hash)) + fileLogger.Info("start to process file", slog.String("input_hash", inputMetadata.Hash), slog.String("original_input_hash", originalInputHash)) reader, err := inputClient.GetReader(inputName) if err != nil { fileLogger.Warn("fail to get reader for input file", slog.String("error", err.Error())) return } - fileContent := make([]byte, inputMetadata.Size) - if _, err = reader.Read(fileContent); err != nil { - fileLogger.Warn("fail to read content of input file", slog.String("error", err.Error())) + writer, err := outputClient.GetWriter(outputName, inputMetadata) + if err != nil { + fileLogger.Warn("fail to get writer for output file", slog.String("error", err.Error())) return } - reader.Close() - - for _, convIndex := range convertersToLaunch { - conv := converters[convIndex] - outputName := conv.DeductOutputPath(inputName) - convLogger := fileLogger.With(slog.String("output_path", outputName), slog.Int("conv_index", convIndex)) - if err := conv.Process(inputMetadata, bytes.NewReader(fileContent), outputName); err != nil { - convLogger.Warn("fail to convert file", slog.String("error", err.Error())) - return - } - - convLogger.Info("successfully processed file", slog.String("input_hash", inputMetadata.Hash)) + if err := conv.Process(inputMetadata.ContentType, reader, writer); err != nil { + fileLogger.Warn("fail to convert file", slog.String("error", err.Error())) + return } + + fileLogger.Info("successfully processed file", slog.String("input_hash", inputMetadata.Hash)) }(i, file) } |