Files
photocatalog/internal/organizer/organizer.go
2025-01-07 21:58:05 +03:00

331 lines
6.4 KiB
Go

package organizer
import (
"context"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"github.com/fsnotify/fsnotify"
"github.com/derfenix/photocatalog/v2/internal/metadata"
)
const (
defaultDirMode = 0o774
defaultFileMode = 0o644
)
type MetaExtractor interface {
Extract(_ string, data io.Reader) (metadata.Metadata, error)
}
type Mode interface {
PlaceIt(sourcePath, targetPath string, mode os.FileMode) error
}
func NewOrganizer(mode Mode, source, target string) *Organizer {
return &Organizer{
mode: mode,
sourceDir: source,
targetDir: target,
dirMode: defaultDirMode,
fileMode: defaultFileMode,
metaExtractors: map[string]MetaExtractor{
"": &metadata.Default{},
"jpg": metadata.Exif{},
"jpeg": metadata.Exif{},
"tiff": metadata.Exif{},
},
}
}
type Organizer struct {
mode Mode
sourceDir string
targetDir string
overwrite bool
dirMode os.FileMode
fileMode os.FileMode
errLogger func(error)
metaExtractors map[string]MetaExtractor
}
func (o *Organizer) WithOverwrite() *Organizer {
o.overwrite = true
return o
}
func (o *Organizer) WithDirMode(mode os.FileMode) *Organizer {
o.dirMode = mode
return o
}
func (o *Organizer) WithFileMode(mode os.FileMode) *Organizer {
o.fileMode = mode
return o
}
func (o *Organizer) WithErrLogger(f func(error)) *Organizer {
o.errLogger = f
return o
}
func (o *Organizer) logErr(err error) {
if o.errLogger != nil {
o.errLogger(err)
}
}
func (o *Organizer) Watch(ctx context.Context, wg *sync.WaitGroup) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("new watcher: %w", err)
}
if err := watcher.Add(o.sourceDir); err != nil {
return fmt.Errorf("add source dir to watcher: %w", err)
}
// Add all subfolders to the watcher.
err = filepath.WalkDir(o.sourceDir, func(path string, d fs.DirEntry, err error) error {
if path == o.sourceDir {
return nil
}
if d.IsDir() {
if err := watcher.Add(path); err != nil {
o.logErr(fmt.Errorf("add the directory %s to watcher: %w", path, err))
}
}
return nil
})
if err != nil {
return fmt.Errorf("add subdirs to watcher: %w", err)
}
wg.Add(2)
go func() {
defer wg.Done()
<-ctx.Done()
if err := watcher.Close(); err != nil {
o.logErr(fmt.Errorf("close watcher: %w", err))
}
syscall.Sync()
}()
go func() {
defer wg.Done()
for {
select {
case event := <-watcher.Events:
if event.Op == fsnotify.Write {
stat, err := os.Stat(event.Name)
if err != nil {
o.logErr(fmt.Errorf("stat %s: %w", event.Name, err))
continue
}
// Add new directories to the watcher.
if stat.IsDir() {
if err := watcher.Add(event.Name); err != nil {
o.logErr(fmt.Errorf("add the directory %s to watcher: %w", event.Name, err))
}
continue
}
go func() {
if err := o.processFile(event.Name); err != nil {
o.logErr(fmt.Errorf("process file %s: %w", event.Name, err))
}
}()
}
case <-ctx.Done():
return
}
}
}()
return nil
}
func (o *Organizer) FullSync(ctx context.Context) error {
err := filepath.WalkDir(o.sourceDir, func(path string, info fs.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if info.IsDir() {
return nil
}
if err := o.processFile(path); err != nil {
log.Printf("Process file `%s` failed: %s", path, err.Error())
return nil
}
return nil
})
if err != nil {
return fmt.Errorf("walking source dir: %w", err)
}
return nil
}
func (o *Organizer) getMetaForPath(fp string) (metadata.Metadata, error) {
file, err := os.OpenFile(fp, os.O_RDONLY, os.ModePerm)
if err != nil {
return metadata.Metadata{}, fmt.Errorf("open file: %w", err)
}
defer func() {
_ = file.Close()
}()
meta, err := o.getMetadata(fp, file)
if err != nil {
return metadata.Metadata{}, fmt.Errorf("get metadatas: %w", err)
}
return meta, nil
}
func (o *Organizer) getMetadata(fp string, data io.Reader) (metadata.Metadata, error) {
ext := strings.ToLower(filepath.Ext(fp))
if strings.HasPrefix(ext, ".") {
ext = ext[1:]
}
extractor, ok := o.metaExtractors[ext]
if !ok {
extractor = o.metaExtractors[""]
}
meta, err := extractor.Extract(fp, data)
if err != nil || meta.Created.IsZero() {
// Fallback to default extractor.
extractor = o.metaExtractors[""]
meta, err = extractor.Extract(fp, data)
if err != nil {
return metadata.Metadata{}, fmt.Errorf("extract metadata: %w", err)
}
}
return meta, nil
}
func (o *Organizer) processFile(sourcePath string) error {
meta, err := o.getMetaForPath(sourcePath)
if err != nil {
return err
}
targetPath, err := o.BuildTargetPath(sourcePath, meta)
if err != nil {
return fmt.Errorf("build target path: %w", err)
}
if o.pathExists(targetPath) && !o.overwrite {
return nil
}
if err := o.ensureTargetPath(filepath.Dir(targetPath)); err != nil {
return fmt.Errorf("ensure target path: %w", err)
}
if err := o.mode.PlaceIt(sourcePath, targetPath, o.fileMode); err != nil {
return fmt.Errorf("place file to new path: %w", err)
}
log.Printf("File %s placed at %s", sourcePath, targetPath)
return nil
}
func (o *Organizer) BuildTargetPath(sourcePath string, meta metadata.Metadata) (string, error) {
sourcePath, err := filepath.Rel(o.sourceDir, sourcePath)
if err != nil {
return "", fmt.Errorf("get a relative path: %w", err)
}
target := filepath.Join(
o.targetDir,
strconv.Itoa(meta.Created.Year()),
strconv.Itoa(int(meta.Created.Month())),
strconv.Itoa(meta.Created.Day()),
sourcePath,
)
return target, nil
}
func (o *Organizer) ensureTargetPath(targetPath string) error {
if o.pathExists(targetPath) {
return nil
}
relPath, err := filepath.Rel(o.targetDir, targetPath)
if err != nil {
return fmt.Errorf("get a relative path: %w", err)
}
dir := o.targetDir
for _, part := range strings.Split(relPath, string(filepath.Separator)) {
dir = filepath.Join(dir, part)
if err := os.Mkdir(dir, os.ModePerm); err != nil && !os.IsExist(err) {
return fmt.Errorf("create target directory path at %s: %w", dir, err)
}
if err := os.Chmod(dir, os.ModePerm&o.dirMode); err != nil {
return fmt.Errorf("chmod directory %s: %w", dir, err)
}
}
return nil
}
func (o *Organizer) pathExists(path string) bool {
_, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false
}
o.logErr(fmt.Errorf("pathExists stat %s: %w", path, err))
return true
}
return true
}