Добавил логи

This commit is contained in:
2026-06-14 19:37:09 +03:00
parent d4bf8a8cad
commit 81ed58ecff
28 changed files with 379 additions and 121 deletions
+101 -16
View File
@@ -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