diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 995c11b..3059a48 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -110,6 +110,7 @@ func runServe(args []string) error { Category: cfg.QBittorrent.Category, Tag: cfg.QBittorrent.Tag, SavePath: cfg.QBittorrent.SavePath, + PathMap: cfg.QBittorrent.PathMap, PollInterval: cfg.Worker.PollInterval.Std(), StuckAfter: cfg.Worker.StuckAfter.Std(), MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), diff --git a/config.example.toml b/config.example.toml index 322604f..4f1aa66 100644 --- a/config.example.toml +++ b/config.example.toml @@ -9,7 +9,7 @@ password = "" category = "jellybit" # категория для добавляемых jellybit раздач (push) tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы) savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении) -path_map = {} # фолбэк трансляции путей; обычно пуст +path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст [paths] downloads = "/srv/media/downloads" diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 1361a73..38e758a 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -142,7 +142,9 @@ password = "" category = "jellybit" savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) # Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично, -# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся. +# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся: +# самый длинный совпавший префикс save_path (ключ) меняется на хост-префикс +# (значение), совпадение — по границам сегментов. Напр. {"/data" = "/srv/media"}. path_map = {} [paths] @@ -215,8 +217,9 @@ format = "json" `stuck_after` → `stuck`/`failed`. - **ошибка:** `error`/`missingFiles` → `failed`. -Пути файлов берём из API (`save_path`/`content_path` + относительные -имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в +Пути файлов берём из API (`save_path` + относительные имена из +`/torrents/files`, уже включающие корневую папку торрента), не из +константы (обычно это уже хост-путь). «Incomplete»-каталог в qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы там, по завершении qBit переносит их в `/srv/media/downloads` (состояние `moving` — дожидаемся окончания переноса и только потом берём финальный diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index b88fe5d..d3135e0 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -37,8 +37,9 @@ series/ ## Сопоставление источник → цель -Источник берём по пути из qBittorrent (`save_path`/`content_path` + -относительное имя; это уже хост-путь, `path_map` — фолбэк). Для каждого +Источник берём по пути из qBittorrent (`save_path` + относительное имя +файла из `/torrents/files`, которое уже содержит корневую папку +многофайловой раздачи; это уже хост-путь, `path_map` — фолбэк). Для каждого распознанного **файла** (не каталога) создаётся **хардлинк** в `paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755, `1000:1000`). Исходный файл остаётся на месте (раздача продолжается), diff --git a/docs/specs/recognition.md b/docs/specs/recognition.md index d56f929..472348c 100644 --- a/docs/specs/recognition.md +++ b/docs/specs/recognition.md @@ -11,9 +11,11 @@ - Имя торрента и структура каталогов. - Список файлов с размерами и расширениями. Абсолютный путь источника - восстанавливаем как `save_path`/`content_path` из qBit (= хост-путь; - `path_map` обычно тождественен) + относительное имя файла; учитываем - одно- и многофайловые торренты. + восстанавливаем как `save_path` из qBit (= хост-путь; `path_map` обычно + тождественен) + относительное имя файла из `/torrents/files`. Имя уже + включает корневую папку для многофайловых торрентов, поэтому префикс — + именно `save_path`, а не `content_path` (последний удвоил бы корневую + папку и сломал бы однофайловые раздачи). - Текстовый контекст человека (+ накопленные подсказки из review). - Распарсенное сообщение торрент-бота (если через Telegram): название с годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md). diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 7a7ea09..ede9598 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -49,7 +49,7 @@ const ( // PlanFile — один файл к раскладке. type PlanFile struct { - Src string // абсолютный путь источника (content dir + относительное имя) + Src string // абсолютный путь источника (save_path + относительное имя) Role Role Season *int // для сериала Episode *int // для сериала diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index 398d7dc..9f25f59 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -55,8 +55,11 @@ type Torrent struct { InfohashV2 string `json:"infohash_v2"` } -// File — элемент /torrents/files: путь файла относительно content_path и -// его размер. +// File — элемент /torrents/files: путь файла относительно save_path +// (включая корневую папку торрента для многофайловых раздач) и его размер. +// Абсолютный путь на диске = filepath.Join(save_path, Name) — НЕ content_path: +// для многофайловой раздачи это удвоило бы корневую папку, для однофайловой +// дало бы путь под самим файлом. type File struct { Name string `json:"name"` Size int64 `json:"size"` @@ -226,8 +229,9 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro return ts, nil } -// Files возвращает список файлов торрента (имена относительно content_path и -// размеры). Нужен распознаванию как один из сигналов. +// Files возвращает список файлов торрента (имена относительно save_path, +// включая корневую папку для многофайловых раздач, и размеры). Нужен +// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name). func (c *Client) Files(ctx context.Context, hash string) ([]File, error) { resp, err := c.do(ctx, func() (*http.Request, error) { u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash)) diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go index d80f26d..a32ec27 100644 --- a/internal/recognize/recognize.go +++ b/internal/recognize/recognize.go @@ -54,7 +54,7 @@ func (r FileRole) valid() bool { } } -// File — входной файл торрента (путь относительно content_path и размер). +// File — входной файл торрента (путь относительно save_path и размер). type File struct { Path string Size int64 diff --git a/internal/worker/pathmap.go b/internal/worker/pathmap.go new file mode 100644 index 0000000..fcd4897 --- /dev/null +++ b/internal/worker/pathmap.go @@ -0,0 +1,38 @@ +package worker + +import ( + "path/filepath" + "strings" +) + +// translatePath переводит путь, как его сообщает qBittorrent API (save_path), +// в путь, видимый jellybit на хосте. Применяет самое длинное совпадение +// префикса из path_map (ключ — префикс в адресации qBit, значение — префикс на +// хосте). Совпадение — только по границам сегментов: ключ `/data` подходит для +// `/data` и `/data/x`, но не для `/data2`. +// +// Обычно path_map пуст — все медиа-приложения монтируют /srv/media:/srv/media +// идентично, поэтому путь из API уже равен хост-пути и возвращается как есть. +// Фолбэк нужен, если адресация qBit и jellybit разойдётся (см. +// docs/specs/architecture.md). +func translatePath(p string, pathMap map[string]string) string { + if len(pathMap) == 0 || p == "" { + return p + } + clean := filepath.Clean(p) + + bestKey, bestVal := "", "" + for key, val := range pathMap { + k := filepath.Clean(key) + if clean != k && !strings.HasPrefix(clean, k+string(filepath.Separator)) { + continue + } + if len(k) > len(bestKey) { + bestKey, bestVal = k, val + } + } + if bestKey == "" { + return p + } + return filepath.Join(filepath.Clean(bestVal), strings.TrimPrefix(clean, bestKey)) +} diff --git a/internal/worker/pathmap_test.go b/internal/worker/pathmap_test.go new file mode 100644 index 0000000..7d73836 --- /dev/null +++ b/internal/worker/pathmap_test.go @@ -0,0 +1,71 @@ +package worker + +import "testing" + +func TestTranslatePath(t *testing.T) { + cases := []struct { + name string + path string + m map[string]string + want string + }{ + { + name: "empty map returns path as is", + path: "/srv/media/downloads", + m: nil, + want: "/srv/media/downloads", + }, + { + name: "no matching prefix returns path as is", + path: "/other/downloads", + m: map[string]string{"/data": "/srv/media"}, + want: "/other/downloads", + }, + { + name: "prefix translated on segment boundary", + path: "/data/downloads/Show/e1.mkv", + m: map[string]string{"/data": "/srv/media"}, + want: "/srv/media/downloads/Show/e1.mkv", + }, + { + name: "exact match of key", + path: "/data", + m: map[string]string{"/data": "/srv/media"}, + want: "/srv/media", + }, + { + name: "does not match partial segment", + path: "/data2/downloads", + m: map[string]string{"/data": "/srv/media"}, + want: "/data2/downloads", + }, + { + name: "longest matching prefix wins", + path: "/data/dl/x.mkv", + m: map[string]string{ + "/data": "/srv/A", + "/data/dl": "/srv/B", + }, + want: "/srv/B/x.mkv", + }, + { + name: "trailing slashes in keys/values normalized", + path: "/data/downloads/x.mkv", + m: map[string]string{"/data/": "/srv/media/"}, + want: "/srv/media/downloads/x.mkv", + }, + { + name: "empty path returns empty", + path: "", + m: map[string]string{"/data": "/srv/media"}, + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := translatePath(tc.path, tc.m); got != tc.want { + t.Errorf("translatePath(%q) = %q, want %q", tc.path, got, tc.want) + } + }) + } +} diff --git a/internal/worker/review.go b/internal/worker/review.go index c5ece08..7662729 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -109,11 +109,12 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize. in.Files[i] = recognize.File{Path: f.Name, Size: f.Size} } + savePath := translatePath(t.SavePath, w.cfg.PathMap) res, err := w.recognizer.Recognize(ctx, in) if err != nil { - return recognize.Result{}, t.SavePath, err + return recognize.Result{}, savePath, err } - return res, t.SavePath, nil + return res, savePath, nil } // finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3 @@ -229,7 +230,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { } w.transition(ctx, *d, store.StateLinking, "", "") - if err := w.linkPlan(ctx, d, plan, tag, t.SavePath); err != nil { + if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil { return fmt.Errorf("apply: %w", err) } return nil diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 3fbb1d1..08f9f70 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -873,3 +873,41 @@ func TestToLayoutPlan(t *testing.T) { t.Errorf("provider tag = %q", lp.ProviderTag) } } + +// TestToLayoutPlan_SrcPrefixIsSavePath фиксирует семантику префикса: имена +// файлов из qBittorrent /torrents/files относительны save_path и уже содержат +// корневую папку для многофайловых раздач. Префикс — save_path, а не +// content_path (иначе корневая папка удвоилась бы, а однофайловая раздача +// получила бы путь под самим файлом). Это регрессионный страж против правки +// префикса на content_path. +func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) { + const savePath = "/srv/media/downloads" + s, e := 1, 1 + cases := []struct { + name string + src string + want string + }{ + // Многофайловая раздача: имя включает корневую папку торрента. + {"multi-file", "Show.S01/e1.mkv", filepath.Join(savePath, "Show.S01/e1.mkv")}, + // Однофайловая раздача: имя — просто файл (content_path = save_path+файл). + {"single-file", "movie.mkv", filepath.Join(savePath, "movie.mkv")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + plan := recognize.Plan{ + Type: recognize.MediaMovie, Title: "X", Year: 2020, + Files: []recognize.PlanFile{ + {Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e}, + }, + } + lp := toLayoutPlan(plan, savePath, "") + if len(lp.Files) != 1 { + t.Fatalf("want 1 file, got %d", len(lp.Files)) + } + if lp.Files[0].Src != tc.want { + t.Errorf("src = %q, want %q", lp.Files[0].Src, tc.want) + } + }) + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 9721e77..60acaf1 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -91,6 +91,7 @@ type Config struct { Category string Tag string // метка для усыновления существующих раздач (discovery) SavePath string + PathMap map[string]string // трансляция save_path qBit → хост-путь (обычно пусто) PollInterval time.Duration StuckAfter time.Duration // stalledDL дольше → stuck MagnetTimeout time.Duration // metaDL дольше → failed