From 9dd36b340c994b4fb7dd45b77ac8202c3bd0b4fe Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 9 Apr 2026 19:21:08 +0200 Subject: [PATCH] up --- .gitignore | 12 ++++++++ config.example.yaml | 10 +++++++ config.go | 34 ++++++++++++++++++++++ go.mod | 5 ++++ go.sum | 4 +++ hardlink.go | 70 +++++++++++++++++++++++++++++++++++++++++++++ main.go | 56 ++++++++++++++++++++++++++++++++++++ 7 files changed, 191 insertions(+) create mode 100644 .gitignore create mode 100644 config.example.yaml create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hardlink.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c14438 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Binary +qbhardlink +qbhardlink.exe + +# Config (ne pas versionner la config personnelle) +config.yaml + +# Go +vendor/ + +# Claude +.claude/ diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..4196ec1 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,10 @@ +# Répertoire de destination pour les hardlinks +dest_base: /home/rouggy/torrents/qbittorrent/Complete + +# Mapping catégorie qBittorrent -> sous-dossier dans dest_base +# Ajouter autant de catégories que nécessaire +categories: + Radarr: Movies + Radarr4K: Movies-4K + Sonarr: Series + Sonarr4K: Series-4K diff --git a/config.go b/config.go new file mode 100644 index 0000000..0ebeefa --- /dev/null +++ b/config.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + DestBase string `yaml:"dest_base"` + Categories map[string]string `yaml:"categories"` +} + +func loadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file %q: %w", path, err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file %q: %w", path, err) + } + + if cfg.DestBase == "" { + return nil, fmt.Errorf("dest_base is required in config") + } + if len(cfg.Categories) == 0 { + return nil, fmt.Errorf("at least one category mapping is required in config") + } + + return &cfg, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba88e62 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/rouggy/qbhardlink + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hardlink.go b/hardlink.go new file mode 100644 index 0000000..5309d91 --- /dev/null +++ b/hardlink.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "io/fs" + "log" + "os" + "path/filepath" +) + +// processHardlinks creates hardlinks in destBase/destSubdir mirroring the +// structure found at contentPath (either a single file or a directory tree). +func processHardlinks(cfg *Config, contentPath, destSubdir string) error { + info, err := os.Stat(contentPath) + if err != nil { + return fmt.Errorf("stat %q: %w", contentPath, err) + } + + destDir := filepath.Join(cfg.DestBase, destSubdir) + + if info.IsDir() { + return hardlinkDir(contentPath, destDir) + } + return hardlinkFile(contentPath, filepath.Join(destDir, filepath.Base(contentPath))) +} + +// hardlinkDir walks srcDir and recreates the directory tree under destDir, +// creating hardlinks for every file found. +func hardlinkDir(srcDir, destDir string) error { + return filepath.WalkDir(srcDir, func(srcPath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(srcDir, srcPath) + if err != nil { + return err + } + destPath := filepath.Join(destDir, rel) + + if d.IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("mkdir %q: %w", destPath, err) + } + return nil + } + + return hardlinkFile(srcPath, destPath) + }) +} + +// hardlinkFile creates a hardlink at destPath pointing to srcPath. +// It creates parent directories as needed and skips if the link already exists. +func hardlinkFile(srcPath, destPath string) error { + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("mkdir %q: %w", filepath.Dir(destPath), err) + } + + if _, err := os.Lstat(destPath); err == nil { + log.Printf("skip (already exists): %s", destPath) + return nil + } + + if err := os.Link(srcPath, destPath); err != nil { + return fmt.Errorf("hardlink %q -> %q: %w", srcPath, destPath, err) + } + + log.Printf("hardlinked: %s -> %s", srcPath, destPath) + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..138f86e --- /dev/null +++ b/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" +) + +func defaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return filepath.Join(home, ".config", "qbhardlink", "config.yaml") +} + +func main() { + log.SetFlags(log.Ldate | log.Ltime) + + var ( + name = flag.String("name", "", "Torrent name (%N)") + category = flag.String("category", "", "Torrent category (%L) [required]") + contentPath = flag.String("content-path", "", "Content path (%F) [required]") + configPath = flag.String("config", defaultConfigPath(), "Path to config YAML file") + ) + flag.Parse() + + if *category == "" || *contentPath == "" { + fmt.Fprintln(os.Stderr, "Usage: qbhardlink --category --content-path [--name ] [--config ]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "qBittorrent run-on-finish example:") + os.Stderr.WriteString(" /usr/local/bin/qbhardlink --name \"%N\" --category \"%L\" --content-path \"%F\"\n") + os.Exit(1) + } + + cfg, err := loadConfig(*configPath) + if err != nil { + log.Fatalf("config: %v", err) + } + + destSubdir, ok := cfg.Categories[*category] + if !ok { + log.Printf("category %q not in config, nothing to do (torrent: %s)", *category, *name) + os.Exit(0) + } + + log.Printf("processing torrent %q (category: %s -> %s)", *name, *category, destSubdir) + + if err := processHardlinks(cfg, *contentPath, destSubdir); err != nil { + log.Fatalf("error: %v", err) + } + + log.Printf("done") +}