Фикс отображения путей
This commit is contained in:
@@ -110,6 +110,7 @@ func runServe(args []string) error {
|
|||||||
Category: cfg.QBittorrent.Category,
|
Category: cfg.QBittorrent.Category,
|
||||||
Tag: cfg.QBittorrent.Tag,
|
Tag: cfg.QBittorrent.Tag,
|
||||||
SavePath: cfg.QBittorrent.SavePath,
|
SavePath: cfg.QBittorrent.SavePath,
|
||||||
|
PathMap: cfg.QBittorrent.PathMap,
|
||||||
PollInterval: cfg.Worker.PollInterval.Std(),
|
PollInterval: cfg.Worker.PollInterval.Std(),
|
||||||
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
||||||
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ password = ""
|
|||||||
category = "jellybit" # категория для добавляемых jellybit раздач (push)
|
category = "jellybit" # категория для добавляемых jellybit раздач (push)
|
||||||
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
|
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
|
||||||
savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении)
|
savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении)
|
||||||
path_map = {} # фолбэк трансляции путей; обычно пуст
|
path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
downloads = "/srv/media/downloads"
|
downloads = "/srv/media/downloads"
|
||||||
|
|||||||
@@ -142,7 +142,9 @@ password = ""
|
|||||||
category = "jellybit"
|
category = "jellybit"
|
||||||
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
|
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
|
||||||
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
|
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
|
||||||
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся.
|
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся:
|
||||||
|
# самый длинный совпавший префикс save_path (ключ) меняется на хост-префикс
|
||||||
|
# (значение), совпадение — по границам сегментов. Напр. {"/data" = "/srv/media"}.
|
||||||
path_map = {}
|
path_map = {}
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
@@ -215,8 +217,9 @@ format = "json"
|
|||||||
`stuck_after` → `stuck`/`failed`.
|
`stuck_after` → `stuck`/`failed`.
|
||||||
- **ошибка:** `error`/`missingFiles` → `failed`.
|
- **ошибка:** `error`/`missingFiles` → `failed`.
|
||||||
|
|
||||||
Пути файлов берём из API (`save_path`/`content_path` + относительные
|
Пути файлов берём из API (`save_path` + относительные имена из
|
||||||
имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||||||
|
константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
||||||
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
||||||
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
||||||
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ series/
|
|||||||
|
|
||||||
## Сопоставление источник → цель
|
## Сопоставление источник → цель
|
||||||
|
|
||||||
Источник берём по пути из qBittorrent (`save_path`/`content_path` +
|
Источник берём по пути из qBittorrent (`save_path` + относительное имя
|
||||||
относительное имя; это уже хост-путь, `path_map` — фолбэк). Для каждого
|
файла из `/torrents/files`, которое уже содержит корневую папку
|
||||||
|
многофайловой раздачи; это уже хост-путь, `path_map` — фолбэк). Для каждого
|
||||||
распознанного **файла** (не каталога) создаётся **хардлинк** в
|
распознанного **файла** (не каталога) создаётся **хардлинк** в
|
||||||
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
|
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
|
||||||
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
|
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
|
|
||||||
- Имя торрента и структура каталогов.
|
- Имя торрента и структура каталогов.
|
||||||
- Список файлов с размерами и расширениями. Абсолютный путь источника
|
- Список файлов с размерами и расширениями. Абсолютный путь источника
|
||||||
восстанавливаем как `save_path`/`content_path` из qBit (= хост-путь;
|
восстанавливаем как `save_path` из qBit (= хост-путь; `path_map` обычно
|
||||||
`path_map` обычно тождественен) + относительное имя файла; учитываем
|
тождественен) + относительное имя файла из `/torrents/files`. Имя уже
|
||||||
одно- и многофайловые торренты.
|
включает корневую папку для многофайловых торрентов, поэтому префикс —
|
||||||
|
именно `save_path`, а не `content_path` (последний удвоил бы корневую
|
||||||
|
папку и сломал бы однофайловые раздачи).
|
||||||
- Текстовый контекст человека (+ накопленные подсказки из review).
|
- Текстовый контекст человека (+ накопленные подсказки из review).
|
||||||
- Распарсенное сообщение торрент-бота (если через Telegram): название с
|
- Распарсенное сообщение торрент-бота (если через Telegram): название с
|
||||||
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
|
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const (
|
|||||||
|
|
||||||
// PlanFile — один файл к раскладке.
|
// PlanFile — один файл к раскладке.
|
||||||
type PlanFile struct {
|
type PlanFile struct {
|
||||||
Src string // абсолютный путь источника (content dir + относительное имя)
|
Src string // абсолютный путь источника (save_path + относительное имя)
|
||||||
Role Role
|
Role Role
|
||||||
Season *int // для сериала
|
Season *int // для сериала
|
||||||
Episode *int // для сериала
|
Episode *int // для сериала
|
||||||
|
|||||||
+8
-4
@@ -55,8 +55,11 @@ type Torrent struct {
|
|||||||
InfohashV2 string `json:"infohash_v2"`
|
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 {
|
type File struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
@@ -226,8 +229,9 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro
|
|||||||
return ts, nil
|
return ts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files возвращает список файлов торрента (имена относительно content_path и
|
// Files возвращает список файлов торрента (имена относительно save_path,
|
||||||
// размеры). Нужен распознаванию как один из сигналов.
|
// включая корневую папку для многофайловых раздач, и размеры). Нужен
|
||||||
|
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
|
||||||
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
||||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||||
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
|
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (r FileRole) valid() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// File — входной файл торрента (путь относительно content_path и размер).
|
// File — входной файл торрента (путь относительно save_path и размер).
|
||||||
type File struct {
|
type File struct {
|
||||||
Path string
|
Path string
|
||||||
Size int64
|
Size int64
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
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)
|
res, err := w.recognizer.Recognize(ctx, in)
|
||||||
if err != nil {
|
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
|
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||||
@@ -229,7 +230,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.transition(ctx, *d, store.StateLinking, "", "")
|
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 fmt.Errorf("apply: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -873,3 +873,41 @@ func TestToLayoutPlan(t *testing.T) {
|
|||||||
t.Errorf("provider tag = %q", lp.ProviderTag)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ type Config struct {
|
|||||||
Category string
|
Category string
|
||||||
Tag string // метка для усыновления существующих раздач (discovery)
|
Tag string // метка для усыновления существующих раздач (discovery)
|
||||||
SavePath string
|
SavePath string
|
||||||
|
PathMap map[string]string // трансляция save_path qBit → хост-путь (обычно пусто)
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
StuckAfter time.Duration // stalledDL дольше → stuck
|
StuckAfter time.Duration // stalledDL дольше → stuck
|
||||||
MagnetTimeout time.Duration // metaDL дольше → failed
|
MagnetTimeout time.Duration // metaDL дольше → failed
|
||||||
|
|||||||
Reference in New Issue
Block a user