mirror of
https://github.com/derfenix/photocatalog.git
synced 2026-03-11 11:52:57 +03:00
331 lines
6.4 KiB
Go
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
|
|
}
|