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

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 -1
View File
@@ -49,7 +49,7 @@ const (
// PlanFile — один файл к раскладке.
type PlanFile struct {
Src string // абсолютный путь источника (content dir + относительное имя)
Src string // абсолютный путь источника (save_path + относительное имя)
Role Role
Season *int // для сериала
Episode *int // для сериала
+8 -4
View File
@@ -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))
+1 -1
View File
@@ -54,7 +54,7 @@ func (r FileRole) valid() bool {
}
}
// File — входной файл торрента (путь относительно content_path и размер).
// File — входной файл торрента (путь относительно save_path и размер).
type File struct {
Path string
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}
}
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
+38
View File
@@ -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)
}
})
}
}
+1
View File
@@ -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