Files
jellybit/internal/layout/layout.go
T
avandClaude Opus 4.8 3f1a928000 Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:17:51 +03:00

505 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package layout раскладывает распознанные файлы по конвенциям Jellyfin
// хардлинками, не трогая исходную раздачу (см. docs/specs/jellyfin-layout.md).
//
// Инварианты безопасности (см. architecture.md → «Раскладка файлов»):
// - линкуем только файлы; целевые каталоги создаём mkdir;
// - целевое имя санитизируется, итоговый путь обязан быть строго под
// paths.movies/paths.series — иначе отказ (защита от traversal);
// - существующее не перезаписываем: тот же inode → идемпотентно «готово»,
// другой файл → коллизия (review);
// - источник неприкосновенен: undo удаляет только ссылки своего батча и
// только под библиотекой.
package layout
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"syscall"
"git.vakhrushev.me/av/jellybit/internal/logctx"
)
// MediaType — вид контента.
type MediaType string
const (
Movie MediaType = "movie"
Series MediaType = "series"
)
// Role — роль файла. Линкуются только main/episode/subtitle; остальные
// (extra/sample/ignore) раскладка пропускает.
type Role string
const (
RoleMain Role = "main"
RoleEpisode Role = "episode"
RoleSubtitle Role = "subtitle"
)
// Kind — вид целевой ссылки (для file_link.kind).
type Kind string
const (
KindVideo Kind = "video"
KindSubtitle Kind = "subtitle"
)
// PlanFile — один файл к раскладке.
type PlanFile struct {
Src string // абсолютный путь источника (save_path + относительное имя)
Role Role
Season *int // для сериала
Episode *int // для сериала
EpisodeEnd *int // двойная серия SxxEyy-Ezz (опц.)
Lang string // язык субтитров (опц.)
Flags []string // флаги субтитров: forced/sdh/... (опц.)
}
// Plan — что и куда раскладывать.
type Plan struct {
Type MediaType
Title string
Year int
ProviderTag string // напр. "tmdbid-693134"; пусто — без тега
Files []PlanFile
}
// Link — посчитанная пара источник → цель.
type Link struct {
Src string
Dst string
Kind Kind
}
// Config — корни библиотек и режим каталогов.
type Config struct {
MoviesDir string
SeriesDir string
DirMode os.FileMode // 0 → 0755
}
// Layouter строит и применяет раскладку.
type Layouter struct {
movies string
series string
dirMode os.FileMode
log *slog.Logger
}
// 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")
}
mode := cfg.DirMode
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
}
// root возвращает корень библиотеки для типа.
func (l *Layouter) root(t MediaType) (string, error) {
switch t {
case Movie:
return l.movies, nil
case Series:
return l.series, nil
default:
return "", fmt.Errorf("layout: unknown type %q", t)
}
}
// BuildLinks вычисляет целевые пути (без обращения к ФС, кроме отсутствия —
// чистая функция от плана). Файлы-роли вне main/episode/subtitle
// пропускаются. Любая невалидность (пустое название, серия без номера,
// выход за пределы библиотеки) — ошибка целиком, частичную раскладку не
// начинаем.
func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
root, err := l.root(p.Type)
if err != nil {
return nil, err
}
base, err := titleYear(p.Title, p.Year)
if err != nil {
return nil, err
}
folder := folderName(base, p.ProviderTag)
var links []Link
for i := range p.Files {
f := &p.Files[i]
var dst string
var kind Kind
var berr error
switch p.Type {
case Movie:
dst, kind, berr = l.movieDst(root, folder, base, f)
case Series:
dst, kind, berr = l.seriesDst(root, folder, base, f)
}
if berr != nil {
return nil, berr
}
if dst == "" {
continue // роль не линкуется (extra/sample/ignore)
}
if !underRoot(root, dst) {
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: plan produced no links")
}
return links, nil
}
func (l *Layouter) movieDst(root, folder, base string, f *PlanFile) (string, Kind, error) {
dir := filepath.Join(root, folder)
switch f.Role {
case RoleMain:
return filepath.Join(dir, base+ext(f.Src)), KindVideo, nil
case RoleSubtitle:
name := base + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src)
return filepath.Join(dir, name), KindSubtitle, nil
default:
return "", "", nil
}
}
func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Kind, error) {
if f.Role != RoleEpisode && f.Role != RoleSubtitle {
return "", "", nil
}
if f.Season == nil || f.Episode == nil {
return "", "", fmt.Errorf("layout: file %q has no season/episode", f.Src)
}
episodeEnd := 0
if f.EpisodeEnd != nil {
episodeEnd = *f.EpisodeEnd
}
dir := filepath.Join(root, folder, seasonFolder(*f.Season))
stem := episodeStem(base, *f.Season, *f.Episode, episodeEnd)
switch f.Role {
case RoleEpisode:
return filepath.Join(dir, stem+ext(f.Src)), KindVideo, nil
case RoleSubtitle:
name := stem + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src)
return filepath.Join(dir, name), KindSubtitle, nil
default:
return "", "", nil
}
}
// LinkStatus — исход создания одной ссылки.
type LinkStatus string
const (
StatusLinked LinkStatus = "linked" // хардлинк создан
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
StatusCollision LinkStatus = "collision" // цель занята другим файлом
StatusSuperseded LinkStatus = "superseded" // путь перехватила другая загрузка (см. state-reconciliation, владение путём)
)
// Result — итог по одной ссылке.
type Result struct {
Link Link
Status LinkStatus
Size int64 // размер разложенного файла (байт); 0, если stat не удался
}
// ErrCollision — цель существует и это другой файл (нужен review).
var ErrCollision = errors.New("layout: target collision")
// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных
// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не
// единственный файл (см. state-reconciliation, инвариант безопасного Undo).
var ErrLastCopy = errors.New("layout: refusing to remove last remaining copy")
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) {
log := logctx.FromOr(ctx, l.log)
results := make([]Result, 0, len(links))
for _, ln := range links {
root := l.movies
if !underRoot(l.movies, ln.Dst) {
root = l.series
}
if !underRoot(root, 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 := l.linkOne(log, ln.Src, ln.Dst)
if err != nil {
log.Error("layout link failed",
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "error", err)
return results, err
}
log.Debug("layout link applied",
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
// Размер разложенного файла для показа в веб-UI (фолбэк размера раздачи,
// когда торрента нет в снимке). Best-effort: файл только что слинкован/
// скопирован/уже существовал — stat должен пройти; сбой не валит
// раскладку, размер остаётся 0.
var size int64
if fi, serr := os.Stat(ln.Dst); serr == nil {
size = fi.Size()
} else {
log.Debug("layout stat size failed", "dst", ln.Dst, "error", serr)
}
results = append(results, Result{Link: ln, Status: status, Size: size})
}
return results, nil
}
// linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
// хардлинка (фолбэк на копирование).
func (l *Layouter) linkOne(log *slog.Logger, src, dst string) (LinkStatus, error) {
err := os.Link(src, dst)
if err == nil {
return StatusLinked, nil
}
if errors.Is(err, fs.ErrExist) {
same, serr := sameFile(src, dst)
if serr != nil {
return "", fmt.Errorf("layout: stat existing %q: %w", dst, serr)
}
if same {
return StatusExists, nil // идемпотентно: тот же inode
}
return StatusCollision, fmt.Errorf("%w: %q is occupied by a different file", ErrCollision, dst)
}
if hardlinkUnsupported(err) {
// Хардлинк невозможен (граница ФС или ФС без поддержки link). Не валим
// раскладку — копируем файл и предупреждаем: диск дублируется, но
// задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы
// fs.ErrExist выше).
log.Warn("layout hardlink unsupported, file copy fallback",
"src", src, "dst", dst, "error", 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)
if err != nil {
return false, err
}
di, err := os.Stat(dst)
if err != nil {
return false, err
}
return os.SameFile(si, di), nil
}
// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути
// строго под библиотеками (источник недосягаем). Отсутствующая цель — не
// ошибка (идемпотентно). Возвращает число удалённых ссылок.
//
// Защита от потери данных: сперва предпроверка всего батча — если хоть одна
// существующая цель оказывается последней копией (источник пропал или
// nlink<=1), весь Undo отклоняется с ErrLastCopy и НИ ОДНА ссылка не
// снимается (иначе частичный откат стёр бы часть данных). Так откат снимает
// лишний хардлинк, а не единственный файл.
func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) {
log := logctx.FromOr(ctx, l.log)
// Предпроверка: путь под библиотекой + не последняя копия.
for _, ln := range links {
if _, err := undoRoot(l, ln.Dst); err != nil {
return 0, err
}
fi, err := os.Lstat(ln.Dst)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // цели уже нет — снимать нечего (идемпотентно)
}
return 0, fmt.Errorf("layout: undo stat %q: %w", ln.Dst, err)
}
last, err := isLastCopy(fi, ln.Src)
if err != nil {
return 0, fmt.Errorf("layout: undo check %q: %w", ln.Dst, err)
}
if last {
return 0, fmt.Errorf("%w: %q (источник %q недоступен)", ErrLastCopy, ln.Dst, ln.Src)
}
}
removed := 0
for _, ln := range links {
root, err := undoRoot(l, ln.Dst)
if err != nil {
return removed, err
}
if err := os.Remove(ln.Dst); err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
log.Error("layout undo remove failed", "dst", ln.Dst, "error", err)
return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
}
removed++
log.Debug("layout link removed", "dst", ln.Dst)
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
}
return removed, nil
}
// Remove снимает целевые ссылки БЕЗ гарда последней копии — в отличие от Undo,
// который отказывается стирать единственную копию данных. Служит осознанному
// удалению загрузки («освободить место»): снимает последнюю библиотечную ссылку,
// даже если источник уже пропал. Как и Undo, трогает только пути строго под
// библиотеками (источник недосягаем) и идемпотентен к отсутствующей цели.
// Возвращает число удалённых ссылок.
func (l *Layouter) Remove(ctx context.Context, links []Link) (int, error) {
log := logctx.FromOr(ctx, l.log)
removed := 0
for _, ln := range links {
root, err := undoRoot(l, ln.Dst)
if err != nil {
return removed, err
}
if err := os.Remove(ln.Dst); err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue // цели уже нет — снимать нечего (идемпотентно)
}
log.Error("layout remove failed", "dst", ln.Dst, "error", err)
return removed, fmt.Errorf("layout: remove %q: %w", ln.Dst, err)
}
removed++
log.Debug("layout link removed (delete)", "dst", ln.Dst)
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
}
return removed, nil
}
// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку,
// если путь не под movies/series (откат трогает только библиотеку).
func undoRoot(l *Layouter, dst string) (string, error) {
root := l.movies
if !underRoot(l.movies, dst) {
root = l.series
}
if !underRoot(root, dst) {
return "", fmt.Errorf("layout: undo outside library: %q", dst)
}
return root, nil
}
// isLastCopy сообщает, является ли цель последней копией данных: исходный файл
// уже не существует, либо у цели не осталось других жёстких ссылок (nlink<=1).
// В обоих случаях unlink цели уничтожил бы единственную копию.
func isLastCopy(fi os.FileInfo, src string) (bool, error) {
if _, err := os.Lstat(src); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return true, nil // источника нет — цель единственная копия
}
return false, fmt.Errorf("stat source %q: %w", src, err)
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("unexpected stat type for %q", fi.Name())
}
return st.Nlink <= 1, nil
}
// pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root.
// Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно.
func pruneEmptyDirs(dir, root string) {
for dir != root && underRoot(root, dir) {
if err := os.Remove(dir); err != nil {
return // непустой или нет прав — останавливаемся
}
dir = filepath.Dir(dir)
}
}