This commit is contained in:
2026-04-09 19:21:08 +02:00
parent 07bc594fb5
commit 9dd36b340c
7 changed files with 191 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Binary
qbhardlink
qbhardlink.exe
# Config (ne pas versionner la config personnelle)
config.yaml
# Go
vendor/
# Claude
.claude/

10
config.example.yaml Normal file
View File

@@ -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

34
config.go Normal file
View File

@@ -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
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/rouggy/qbhardlink
go 1.22
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -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=

70
hardlink.go Normal file
View File

@@ -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
}

56
main.go Normal file
View File

@@ -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 <cat> --content-path <path> [--name <name>] [--config <path>]")
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")
}