Добавил логи
This commit is contained in:
+101
-16
@@ -15,7 +15,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
@@ -86,10 +88,12 @@ type Layouter struct {
|
||||
movies string
|
||||
series string
|
||||
dirMode os.FileMode
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает раскладчик. Корни нормализуются (filepath.Clean).
|
||||
func New(cfg Config) (*Layouter, error) {
|
||||
// New собирает раскладчик. Корни нормализуются (filepath.Clean). logger nil →
|
||||
// slog.Default().
|
||||
func New(cfg Config, logger *slog.Logger) (*Layouter, error) {
|
||||
if cfg.MoviesDir == "" || cfg.SeriesDir == "" {
|
||||
return nil, fmt.Errorf("layout: movies/series dirs required")
|
||||
}
|
||||
@@ -97,10 +101,14 @@ func New(cfg Config) (*Layouter, error) {
|
||||
if mode == 0 {
|
||||
mode = 0o755
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Layouter{
|
||||
movies: filepath.Clean(cfg.MoviesDir),
|
||||
series: filepath.Clean(cfg.SeriesDir),
|
||||
dirMode: mode,
|
||||
log: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -112,7 +120,7 @@ func (l *Layouter) root(t MediaType) (string, error) {
|
||||
case Series:
|
||||
return l.series, nil
|
||||
default:
|
||||
return "", fmt.Errorf("layout: неизвестный тип %q", t)
|
||||
return "", fmt.Errorf("layout: unknown type %q", t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,12 +160,12 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
|
||||
continue // роль не линкуется (extra/sample/ignore)
|
||||
}
|
||||
if !underRoot(root, dst) {
|
||||
return nil, fmt.Errorf("layout: цель %q вне библиотеки %q (файл %q)", dst, root, f.Src)
|
||||
return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src)
|
||||
}
|
||||
links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil, fmt.Errorf("layout: план не дал ни одной ссылки")
|
||||
return nil, fmt.Errorf("layout: plan produced no links")
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
@@ -180,7 +188,7 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
||||
return "", "", nil
|
||||
}
|
||||
if f.Season == nil || f.Episode == nil {
|
||||
return "", "", fmt.Errorf("layout: файл %q без season/episode", f.Src)
|
||||
return "", "", fmt.Errorf("layout: file %q has no season/episode", f.Src)
|
||||
}
|
||||
episodeEnd := 0
|
||||
if f.EpisodeEnd != nil {
|
||||
@@ -203,7 +211,8 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
||||
type LinkStatus string
|
||||
|
||||
const (
|
||||
StatusLinked LinkStatus = "linked" // ссылка создана
|
||||
StatusLinked LinkStatus = "linked" // хардлинк создан
|
||||
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
|
||||
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
|
||||
StatusCollision LinkStatus = "collision" // цель занята другим файлом
|
||||
)
|
||||
@@ -219,7 +228,8 @@ var ErrCollision = errors.New("layout: target collision")
|
||||
|
||||
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
|
||||
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
||||
// ErrCollision, не перезаписывая. EXDEV (разные ФС) — явная ошибка.
|
||||
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
|
||||
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
|
||||
func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
||||
results := make([]Result, 0, len(links))
|
||||
for _, ln := range links {
|
||||
@@ -228,23 +238,28 @@ func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
||||
root = l.series
|
||||
}
|
||||
if !underRoot(root, ln.Dst) {
|
||||
return results, fmt.Errorf("layout: цель %q вне библиотек", ln.Dst)
|
||||
return results, fmt.Errorf("layout: target %q is outside libraries", ln.Dst)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(ln.Dst), l.dirMode); err != nil {
|
||||
return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err)
|
||||
}
|
||||
|
||||
status, err := linkOne(ln.Src, ln.Dst)
|
||||
status, err := l.linkOne(ln.Src, ln.Dst)
|
||||
if err != nil {
|
||||
l.log.Error("layout: link failed",
|
||||
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "err", err)
|
||||
return results, err
|
||||
}
|
||||
l.log.Debug("layout: link applied",
|
||||
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
|
||||
results = append(results, Result{Link: ln, Status: status})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// linkOne создаёт одну ссылку, разбирая «уже существует».
|
||||
func linkOne(src, dst string) (LinkStatus, error) {
|
||||
// linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
|
||||
// хардлинка (фолбэк на копирование).
|
||||
func (l *Layouter) linkOne(src, dst string) (LinkStatus, error) {
|
||||
err := os.Link(src, dst)
|
||||
if err == nil {
|
||||
return StatusLinked, nil
|
||||
@@ -257,14 +272,82 @@ func linkOne(src, dst string) (LinkStatus, error) {
|
||||
if same {
|
||||
return StatusExists, nil // идемпотентно: тот же inode
|
||||
}
|
||||
return StatusCollision, fmt.Errorf("%w: %q занят другим файлом", ErrCollision, dst)
|
||||
return StatusCollision, fmt.Errorf("%w: %q is occupied by a different file", ErrCollision, dst)
|
||||
}
|
||||
if errors.Is(err, syscall.EXDEV) {
|
||||
return "", fmt.Errorf("layout: hardlink через границу ФС (%q → %q): %w", src, dst, err)
|
||||
if hardlinkUnsupported(err) {
|
||||
// Хардлинк невозможен (граница ФС или ФС без поддержки link). Не валим
|
||||
// раскладку — копируем файл и предупреждаем: диск дублируется, но
|
||||
// задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы
|
||||
// fs.ErrExist выше).
|
||||
l.log.Warn("layout: hardlink unsupported, falling back to file copy",
|
||||
"src", src, "dst", dst, "err", err)
|
||||
if cerr := copyFile(src, dst); cerr != nil {
|
||||
return "", fmt.Errorf("layout: copy fallback %q → %q: %w", src, dst, cerr)
|
||||
}
|
||||
return StatusCopied, nil
|
||||
}
|
||||
return "", fmt.Errorf("layout: link %q → %q: %w", src, dst, err)
|
||||
}
|
||||
|
||||
// hardlinkUnsupported сообщает, означает ли ошибка os.Link, что хардлинк между
|
||||
// этими путями в принципе невозможен (а не временный сбой): разные ФС (EXDEV)
|
||||
// или ФС без поддержки жёстких ссылок (ENOTSUP/EOPNOTSUPP, у части ФС — EPERM).
|
||||
func hardlinkUnsupported(err error) bool {
|
||||
return errors.Is(err, syscall.EXDEV) ||
|
||||
errors.Is(err, syscall.ENOTSUP) ||
|
||||
errors.Is(err, syscall.EOPNOTSUPP) ||
|
||||
errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
|
||||
// copyFile копирует src в dst через временный файл в каталоге назначения и
|
||||
// атомарный rename — так сбой посреди копирования не оставит частичный файл на
|
||||
// месте dst. Источник не модифицируется. Вызывается, только когда хардлинк
|
||||
// невозможен (см. linkOne).
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat source: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), ".jellybit-copy-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(tmp, in); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("copy data: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync temp: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpName, info.Mode().Perm()); err != nil {
|
||||
return fmt.Errorf("chmod temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
return fmt.Errorf("rename into place: %w", err)
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameFile сообщает, указывают ли src и dst на один inode.
|
||||
func sameFile(src, dst string) (bool, error) {
|
||||
si, err := os.Stat(src)
|
||||
@@ -289,15 +372,17 @@ func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) {
|
||||
root = l.series
|
||||
}
|
||||
if !underRoot(root, ln.Dst) {
|
||||
return removed, fmt.Errorf("layout: undo вне библиотеки: %q", ln.Dst)
|
||||
return removed, fmt.Errorf("layout: undo outside library: %q", ln.Dst)
|
||||
}
|
||||
if err := os.Remove(ln.Dst); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
l.log.Error("layout: undo remove failed", "dst", ln.Dst, "err", err)
|
||||
return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
|
||||
}
|
||||
removed++
|
||||
l.log.Debug("layout: link removed", "dst", ln.Dst)
|
||||
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
|
||||
}
|
||||
return removed, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func newFixture(t *testing.T) fixture {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
l, err := New(Config{MoviesDir: movies, SeriesDir: series})
|
||||
l, err := New(Config{MoviesDir: movies, SeriesDir: series}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -251,6 +252,63 @@ func TestUndo_Idempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile_DuplicatesContentAndKeepsSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src.mkv")
|
||||
dst := filepath.Join(dir, "sub", "dst.mkv")
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(src, []byte("payload"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
t.Fatalf("copyFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil || string(got) != "payload" {
|
||||
t.Fatalf("dst content = %q, err = %v", got, err)
|
||||
}
|
||||
// Источник цел и это отдельный inode (копия, не хардлинк).
|
||||
si, _ := os.Stat(src)
|
||||
di, _ := os.Stat(dst)
|
||||
if os.SameFile(si, di) {
|
||||
t.Error("dst must be a distinct copy, not a hardlink")
|
||||
}
|
||||
if di.Mode().Perm() != 0o640 {
|
||||
t.Errorf("dst mode = %v, want source mode 0640", di.Mode().Perm())
|
||||
}
|
||||
// Временные файлы копирования подчищены.
|
||||
entries, _ := os.ReadDir(filepath.Dir(dst))
|
||||
for _, e := range entries {
|
||||
if len(e.Name()) >= 14 && e.Name()[:14] == ".jellybit-copy" {
|
||||
t.Errorf("leftover temp file: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardlinkUnsupported(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{syscall.EXDEV, true},
|
||||
{syscall.ENOTSUP, true},
|
||||
{syscall.EOPNOTSUPP, true},
|
||||
{syscall.EPERM, true},
|
||||
{syscall.ENOENT, false},
|
||||
{os.ErrExist, false},
|
||||
{errors.New("random"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := hardlinkUnsupported(tc.err); got != tc.want {
|
||||
t.Errorf("hardlinkUnsupported(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_RefusesOutsideLibrary(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
outside := filepath.Join(f.downloads, "victim.mkv")
|
||||
|
||||
@@ -32,7 +32,7 @@ func sanitizeComponent(s string) string {
|
||||
func titleYear(title string, year int) (string, error) {
|
||||
t := sanitizeComponent(title)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("layout: пустое название после санитизации (%q)", title)
|
||||
return "", fmt.Errorf("layout: empty title after sanitization (%q)", title)
|
||||
}
|
||||
if year > 0 {
|
||||
return fmt.Sprintf("%s (%d)", t, year), nil
|
||||
|
||||
Reference in New Issue
Block a user