Фикс отображения путей

This commit is contained in:
2026-06-14 19:09:50 +03:00
parent 5fb2f4df43
commit d4bf8a8cad
13 changed files with 178 additions and 18 deletions
+1
View File
@@ -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
View File
@@ -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"
+6 -3
View File
@@ -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` — дожидаемся окончания переноса и только потом берём финальный
+3 -2
View File
@@ -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`). Исходный файл остаётся на месте (раздача продолжается),
+5 -3
View File
@@ -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).
+1 -1
View File
@@ -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
View File
@@ -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))
+1 -1
View File
@@ -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
+38
View File
@@ -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))
}
+71
View File
@@ -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)
}
})
}
}
+4 -3
View File
@@ -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
+38
View File
@@ -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)
}
})
}
}
+1
View File
@@ -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